P0-P3 全量重构:退出码 / 解析修复、manifest 与 7z t 校验、干跑、排除规则、日志、测试与计划任务
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 全部一致。
This commit is contained in:
1 parent
045d51ac9c
commit
e114cae8c8
13 files changed
+1729
-110
No files matched your search
@@ -9,6 +9,9 @@ logs/
|
||||
*.tmp.zip
|
||||
*.tmp.rar
|
||||
|
||||
# 测试用的本地依赖(Pester 等,见 tools/Install-TestDependencies.ps1)
|
||||
.tools/
|
||||
|
||||
# 编辑器 / 系统杂项
|
||||
.vscode/
|
||||
*.swp
|
||||
|
||||
+119
-37
@@ -239,13 +239,14 @@ function Save-ItemRecord {
|
||||
}
|
||||
|
||||
# 压缩 + 校验 + 原子替换;返回 @{ Ok; ExitCode; Warnings; Reason }
|
||||
#
|
||||
# $SourceGroups 支持"一个软件包含多个目录":每个元素是
|
||||
# @{ ParentDir; RelativePaths; Label }。7z/RAR 对同一归档多次 `a` 会把内容并入,
|
||||
# 所以按父目录分组、逐组追加,归档里每个目录仍保留自己的名字与层级。
|
||||
function Invoke-BackupItem {
|
||||
param(
|
||||
[string]$SourcePath,
|
||||
[string[]]$RelativePaths,
|
||||
[string]$ItemName,
|
||||
[string]$ParentDir,
|
||||
[string]$FinalPath,
|
||||
[Parameter(Mandatory = $true)][array]$SourceGroups,
|
||||
[Parameter(Mandatory = $true)][string]$FinalPath,
|
||||
[string[]]$ExcludePatterns,
|
||||
[switch]$UseEncryption,
|
||||
[switch]$ProtectPrevious,
|
||||
@@ -255,38 +256,50 @@ function Invoke-BackupItem {
|
||||
$tempPath = "$FinalPath.tmp$($tool.Extension)"
|
||||
if (Test-Path -LiteralPath $tempPath) { Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue }
|
||||
|
||||
# 排除模式用**源目录名**作前缀(归档里就是这个名字),
|
||||
# 与 7z 的路径匹配语义一致;软件名条目的归档根目录是软件名,但源目录名仍在其下一层。
|
||||
$excludeArgument = Get-ArchiveExcludeArgument -ItemName $ItemName -Patterns $ExcludePatterns
|
||||
if ($excludeArgument.Count -gt 0) {
|
||||
Write-Log ("排除 {0} 项:{1}" -f $excludeArgument.Count, ($excludeArgument -join ' ')) -Level DEBUG
|
||||
}
|
||||
|
||||
$sourceListFile = $null
|
||||
$warnings = $false
|
||||
$lastExitCode = 0
|
||||
$lastParentDir = $null
|
||||
|
||||
try {
|
||||
if ($SourceGroups.Count -eq 0) {
|
||||
return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '没有可打包的源目录' }
|
||||
}
|
||||
|
||||
$groupIndex = 0
|
||||
foreach ($group in $SourceGroups) {
|
||||
$groupIndex++
|
||||
$parentDir = $group.ParentDir
|
||||
$relativePaths = @($group.RelativePaths)
|
||||
if ($relativePaths.Count -eq 0) { continue }
|
||||
$lastParentDir = $parentDir
|
||||
|
||||
# 排除模式的**前缀用这一组的源目录名**(归档里就是这个层级)。
|
||||
$prefixName = if ($group.Label) { $group.Label } else { Split-Path -Path $relativePaths[0] -Leaf }
|
||||
$excludeArgument = Get-ArchiveExcludeArgument -ItemName $prefixName -Patterns $ExcludePatterns
|
||||
|
||||
if ($tool.Name -eq '7z') {
|
||||
$optimized = Get-Optimized7zArgument -SourcePath $SourcePath -Level $script:Config.CompressionLevel
|
||||
$probePath = "$($parentDir.TrimEnd('\'))\$($relativePaths[0])"
|
||||
$optimized = Get-Optimized7zArgument -SourcePath $probePath -Level $script:Config.CompressionLevel
|
||||
$argument = @($optimized.Argument) + $toolQuietArgument + $excludeArgument
|
||||
|
||||
if ($UseEncryption) {
|
||||
if (-not $password) {
|
||||
return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '需要加密但取不到口令(设置 BAKNRET_PASSWORD 或用 -KeyFile 指定密码文件)' }
|
||||
return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '需要加密但取不到口令(见 README「加密」)' }
|
||||
}
|
||||
$argument += "-p$password"
|
||||
if ($script:Config.Encryption.EncryptHeaders) { $argument += '-mhe=on' }
|
||||
}
|
||||
|
||||
$argument += $tempPath
|
||||
foreach ($relative in $relativePaths) { $argument += $relative }
|
||||
|
||||
foreach ($relative in $RelativePaths) { $argument += $relative }
|
||||
|
||||
$exitCode = Invoke-ExternalCommand -FilePath $tool.Command -ArgumentList $argument -WorkingDirectory $ParentDir
|
||||
$exitCode = Invoke-ExternalCommand -FilePath $tool.Command -ArgumentList $argument -WorkingDirectory $parentDir
|
||||
$lastExitCode = $exitCode
|
||||
# 7z: 0 成功;1 警告(有文件读不到或跳过);2 及以上为失败
|
||||
if ($exitCode -ne 0 -and $exitCode -ne 1) {
|
||||
return [pscustomobject]@{ Ok = $false; ExitCode = $exitCode; Warnings = $false; Reason = "压缩工具退出码 $exitCode" }
|
||||
return [pscustomobject]@{ Ok = $false; ExitCode = $exitCode; Warnings = $warnings; Reason = "第 $groupIndex 组($prefixName)压缩工具退出码 $exitCode" }
|
||||
}
|
||||
$warnings = ($exitCode -eq 1)
|
||||
if ($exitCode -eq 1) { $warnings = $true }
|
||||
}
|
||||
elseif ($tool.Name -eq 'RAR') {
|
||||
$argument = @('a', '-m5', '-idp', '-idn') + $toolQuietArgument + $excludeArgument
|
||||
@@ -297,22 +310,35 @@ function Invoke-BackupItem {
|
||||
$argument += "-p$password"
|
||||
}
|
||||
$argument += $tempPath
|
||||
# RAR 没有 -spf,退回"直接打包源目录",归档根目录就是源目录名
|
||||
foreach ($relative in $RelativePaths) { $argument += $relative }
|
||||
foreach ($relative in $relativePaths) { $argument += $relative }
|
||||
|
||||
$exitCode = Invoke-ExternalCommand -FilePath $tool.Command -ArgumentList $argument -WorkingDirectory $ParentDir
|
||||
$exitCode = Invoke-ExternalCommand -FilePath $tool.Command -ArgumentList $argument -WorkingDirectory $parentDir
|
||||
$lastExitCode = $exitCode
|
||||
if ($exitCode -ne 0) {
|
||||
return [pscustomobject]@{ Ok = $false; ExitCode = $exitCode; Warnings = $false; Reason = "压缩工具退出码 $exitCode" }
|
||||
return [pscustomobject]@{ Ok = $false; ExitCode = $exitCode; Warnings = $warnings; Reason = "第 $groupIndex 组($prefixName)压缩工具退出码 $exitCode" }
|
||||
}
|
||||
$warnings = $false
|
||||
}
|
||||
else {
|
||||
if ($UseEncryption) {
|
||||
return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '内置 ZIP 不支持加密,请改用 7z 或去掉 encrypt 标记' }
|
||||
}
|
||||
$fullPaths = @($RelativePaths | ForEach-Object { Join-Path $ParentDir $_ })
|
||||
# Compress-Archive 不能追加;多组时逐组重打(先把已有临时归档解开再合并会让代码复杂得多,
|
||||
# 而 ZIP 本来就是降级路径,这里只保证内容完整)
|
||||
$fullPaths = @($relativePaths | ForEach-Object { Join-Path $parentDir $_ })
|
||||
if ($groupIndex -gt 1 -and (Test-Path -LiteralPath $tempPath)) {
|
||||
$staging = Join-Path $env:TEMP ("bnr-zip-" + [guid]::NewGuid().ToString('N'))
|
||||
New-Item -ItemType Directory -Path $staging -Force | Out-Null
|
||||
try {
|
||||
Expand-Archive -LiteralPath $tempPath -DestinationPath $staging -Force
|
||||
$fullPaths += @(Get-ChildItem -LiteralPath $staging -Force | Select-Object -ExpandProperty FullName)
|
||||
Compress-Archive -Path $fullPaths -DestinationPath $tempPath -CompressionLevel Optimal -Force
|
||||
$warnings = $false
|
||||
} finally {
|
||||
Remove-Item -LiteralPath $staging -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
} else {
|
||||
Compress-Archive -Path $fullPaths -DestinationPath $tempPath -CompressionLevel Optimal -Force
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $tempPath)) {
|
||||
@@ -325,12 +351,25 @@ function Invoke-BackupItem {
|
||||
if ($UseEncryption -and $password) { $verifyArgument += "-p$password" }
|
||||
$verifyArgument += $tempPath
|
||||
|
||||
$verifyCode = Invoke-ExternalCommand -FilePath $tool.Command -ArgumentList $verifyArgument -WorkingDirectory $ParentDir
|
||||
$verifyCode = Invoke-ExternalCommand -FilePath $tool.Command -ArgumentList $verifyArgument -WorkingDirectory $lastParentDir
|
||||
if ($verifyCode -ne 0) {
|
||||
Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue
|
||||
return [pscustomobject]@{ Ok = $false; ExitCode = $verifyCode; Warnings = $false; Reason = "归档校验失败(7z t 退出码 $verifyCode),已丢弃临时文件" }
|
||||
}
|
||||
Write-Log '归档校验通过(7z t)' -Level DEBUG
|
||||
|
||||
# 多目录时确认每个目录都真的进了归档:7z 的"警告"可能只体现在某一组里
|
||||
if ($SourceGroups.Count -gt 1) {
|
||||
$listed = @(Get-ArchiveTopLevelNames -ArchivePath $tempPath -SevenZip $tool.Command -Password $(if ($UseEncryption) { $password } else { $null }))
|
||||
if ($listed.Count -gt 0) {
|
||||
$expected = @($SourceGroups | ForEach-Object { Split-Path -Path $_.RelativePaths[0] -Leaf })
|
||||
$absent = @($expected | Where-Object { $_ -notin $listed })
|
||||
if ($absent.Count -gt 0) {
|
||||
Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue
|
||||
return [pscustomobject]@{ Ok = $false; ExitCode = $lastExitCode; Warnings = $true; Reason = ("归档缺少这些目录:{0}(归档内实际有:{1})" -f ($absent -join '、'), ($listed -join '、')) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# 关键保护:压缩工具报了警告(通常是有文件被占用读不到)时,
|
||||
@@ -341,7 +380,7 @@ function Invoke-BackupItem {
|
||||
Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue
|
||||
return [pscustomobject]@{
|
||||
Ok = $false
|
||||
ExitCode = $exitCode
|
||||
ExitCode = $lastExitCode
|
||||
Warnings = $true
|
||||
Reason = '压缩工具报告有文件被占用而读不到,新归档不完整。为避免覆盖现有的完整归档已保留旧归档;请关闭占用该目录的程序后重跑,或确认可以接受后用 -AcceptWarnings 强制覆盖'
|
||||
}
|
||||
@@ -354,10 +393,6 @@ function Invoke-BackupItem {
|
||||
Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = "$_" }
|
||||
} finally {
|
||||
if ($sourceListFile -and (Test-Path -LiteralPath $sourceListFile)) {
|
||||
Remove-Item -LiteralPath $sourceListFile -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,10 +424,16 @@ foreach ($line in $lines) {
|
||||
|
||||
$record = New-ItemRecord -BaseName $baseName -Source $displayPath -ResolvedSource $sourcePath -Phase 'backup'
|
||||
$record.archive = $baseName + $tool.Extension
|
||||
$record.roots = @($resolved.Sources | ForEach-Object { $_.RootName })
|
||||
if ($resolved.CatalogEntry) { $record.catalog = $resolved.CatalogEntry.Path }
|
||||
$finalPath = Join-Path $BackupDir $record.archive
|
||||
|
||||
# root= 在 README 里被列为可用标记,但归档内的根目录实际上始终是源目录名
|
||||
# (见 README「设计取舍」:不套一层软件名目录)。7z 命令行也没有"入库时改名"
|
||||
# 的能力,所以这里明确告警而不是让它静默失效——静默失效正是本次重构要消灭的东西。
|
||||
if (@($item.Flags | Where-Object { $_ -like 'root=*' }).Count -gt 0) {
|
||||
Write-Log "警告: $displayPath 使用了 root= 标记,该功能尚未实现(归档内的根目录始终是源目录名),本次忽略" -Level WARN
|
||||
}
|
||||
|
||||
# 备份列表里写重了会生成两个同名归档,互相覆盖 —— 直接报错,不猜。
|
||||
if ($seenBaseNames.ContainsKey($baseName)) {
|
||||
$reason = "归档名 '$baseName' 与清单中的 '$($seenBaseNames[$baseName])' 重复(由 '$displayPath' 生成),两者会互相覆盖"
|
||||
@@ -453,6 +494,14 @@ foreach ($line in $lines) {
|
||||
}
|
||||
}
|
||||
|
||||
# 归档内的顶层条目名 = 每个**真实存在**的源在归档里的第一层名字,也就是源目录
|
||||
# (或源文件)自己的名字。刻意不用 $resolved.Sources[].RootName:那套"归档内套一层
|
||||
# 软件名"的设想已按设计取舍放弃,实际布局始终是 <源目录名>\...。
|
||||
# 这里记录可核对的事实,之前写成软件名会让 Edge(实际是 "User Data")之类的条目对不上。
|
||||
$record.roots = @($liveSources | ForEach-Object {
|
||||
$_.RelativePaths | ForEach-Object { ($_ -split '[\\/]')[0] }
|
||||
} | Select-Object -Unique)
|
||||
|
||||
$primarySource = $liveSources[0].SourcePath
|
||||
$parentDir = $liveSources[0].ParentDir
|
||||
# 排除模式的前缀始终用**源目录名**(归档里就是这个层级)
|
||||
@@ -538,9 +587,19 @@ foreach ($line in $lines) {
|
||||
}
|
||||
}
|
||||
|
||||
$firstSource = $liveSources[0]
|
||||
$result = Invoke-BackupItem -SourcePath $firstSource.SourcePath -RelativePaths $firstSource.RelativePaths `
|
||||
-ItemName $itemName -ParentDir $firstSource.ParentDir `
|
||||
# 多目录:每个源组各带自己的父目录与相对名。7z 会对同一归档逐组追加。
|
||||
# Label 刻意留空:排除模式的前缀必须是**归档里的那一层名字**,也就是源目录名
|
||||
# (归档内布局是 `<源目录名>\...`)。若把软件名当 Label 传下去,
|
||||
# 排除模式就会变成 `软件名\skip.bin`,与实际路径对不上而静默失效。
|
||||
$sourceGroups = @($liveSources | ForEach-Object {
|
||||
[pscustomobject]@{
|
||||
ParentDir = $_.ParentDir
|
||||
RelativePaths = @($_.RelativePaths)
|
||||
Label = $null
|
||||
}
|
||||
})
|
||||
|
||||
$result = Invoke-BackupItem -SourceGroups $sourceGroups `
|
||||
-FinalPath $finalPath -ExcludePatterns $effectiveExcludes -UseEncryption:$useEncryption `
|
||||
-ProtectPrevious:$protectPrevious -AcceptWarnings:$AcceptWarnings
|
||||
|
||||
@@ -594,6 +653,29 @@ if ($DryRun) {
|
||||
Write-Log "manifest 已更新:$manifestPath" -Level DEBUG
|
||||
}
|
||||
|
||||
# 孤儿归档审计:磁盘上有、但清单里任何条目都不指向的归档。
|
||||
# Restore.ps1 只能按条目名找归档,所以孤儿是**恢复不到**的 —— 必须显式点名,
|
||||
# 免得下次清理时把还有用的归档当垃圾删掉(重构前那个 2.8 GB 的归档就是这么成孤儿的)。
|
||||
# 只在整表运行时做:带 -Only/-Skip 时未选中的条目本来就不在 $seenBaseNames 里,
|
||||
# 那种情况下报出来的全是假孤儿。
|
||||
if (-not $DryRun -and $Only.Count -eq 0 -and $Skip.Count -eq 0) {
|
||||
$known = @{}
|
||||
foreach ($key in $seenBaseNames.Keys) { $known[$key] = $true }
|
||||
foreach ($key in $manifest.items.Keys) { $known[$key] = $true }
|
||||
|
||||
$orphanArchives = @(Get-ChildItem -LiteralPath $BackupDir -File -Force -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Extension.ToLower() -in @('.7z', '.rar', '.zip', '.tar') -and -not $known.ContainsKey($_.BaseName) })
|
||||
|
||||
if ($orphanArchives.Count -gt 0) {
|
||||
Write-Log ("发现 {0} 个孤儿归档(没有任何清单条目指向,恢复不到,注意别误删):" -f $orphanArchives.Count) -Level WARN
|
||||
foreach ($orphan in $orphanArchives) {
|
||||
Write-Log (" - {0}({1:N1} MB,{2})" -f $orphan.Name, ($orphan.Length / 1MB), $orphan.LastWriteTime) -Level WARN
|
||||
}
|
||||
} else {
|
||||
Write-Log '孤儿归档审计:没有发现(所有归档都有清单条目指向)' -Level DEBUG
|
||||
}
|
||||
}
|
||||
|
||||
if ($failures.Count -gt 0) {
|
||||
Write-Log '失败条目:' -Level ERROR
|
||||
foreach ($failure in $failures) { Write-Log " - $failure" -Level ERROR }
|
||||
|
||||
+21
-3
@@ -14,6 +14,14 @@
|
||||
# 使用 -Snapshot 时留存带时间戳的副本
|
||||
SnapshotDir = 'Backups\snapshots'
|
||||
|
||||
# 软件名录("软件名 -> 目录"映射表)。相对路径按本配置所在目录解析。
|
||||
# BackupList.txt 里写软件名时靠它换成真实目录,归档名也取软件名。
|
||||
SoftwareCatalog = 'SoftwareCatalog.psd1'
|
||||
|
||||
# 名录里写的目录不存在时,按 "<名>_*" / "<名>-*" 向下找几层做前缀补全
|
||||
# (应对 legendary -> legendary_2.0.4 这类带后缀的目录)
|
||||
CatalogMaxDepth = 5
|
||||
|
||||
# 低于这个剩余空间(GB)就告警;真正放不下某个条目时会直接跳过该条目
|
||||
MinFreeSpaceGB = 5
|
||||
|
||||
@@ -37,9 +45,19 @@
|
||||
}
|
||||
|
||||
# 加密。默认关闭:一旦开启而口令丢失,备份就再也解不开。
|
||||
# 口令来源:环境变量 BAKNRET_PASSWORD,或 PasswordFile 指向的文件首行(用 -KeyFile 覆盖)。
|
||||
# 开启方式见 README「加密」一节。注意 7z 只接受命令行口令,
|
||||
# 口令在本机进程列表里短暂可见,这是 7z 本身的限制。
|
||||
#
|
||||
# **本仓库不存放任何口令**,这里只记"去哪儿找":
|
||||
# Encryption.Enabled = $true -> 所有条目都加密
|
||||
# 或 BackupList.txt 里给单个条目加 @encrypt(如 .ssh @encrypt)
|
||||
#
|
||||
# 口令本身按以下优先级获取(见 README「加密」):
|
||||
# 1. -Password 命令行参数
|
||||
# 2. $env:BAKNRET_PASSWORD
|
||||
# 3. PasswordFile 指向的文件首行(文件必须在仓库之外)
|
||||
# 4. 交互式询问(仅交互式会话;计划任务里不会停下来等输入)
|
||||
# 全都拿不到时该条目明确失败,绝不退化成明文归档。
|
||||
#
|
||||
# 注意 7z 只接受命令行口令,口令在本机进程列表里短暂可见,这是 7z 本身的限制。
|
||||
Encryption = @{
|
||||
Enabled = $false
|
||||
PasswordFile = ''
|
||||
|
||||
+8
-1
@@ -54,9 +54,16 @@ twinkle-tray
|
||||
# Default\Session Storage、Default\blob_storage、Default\WebStorage 也加进来。
|
||||
# !*Cache 一次覆盖 Cache / Code Cache / GPUCache / DawnCache / GrShaderCache 等一批。
|
||||
#
|
||||
# 注意:不带 ! 的普通模式是**相对归档根目录锚定**的(会展开成 `-x!User?Data\<模式>`),
|
||||
# 所以它只排除根目录下那一份。Edge 的 OneAuth\WebView2\EBWebView\ 里还有一整套
|
||||
# 自己的 Crashpad / BrowserMetrics / ProvenanceData / optimization_guide,
|
||||
# 根锚定模式碰不到它们 —— 这些可再生的东西一律用 ! 形式按组件名排除(-xr!),任意层级都命中。
|
||||
# 实测:根锚定的 Edge 归档 1781 MB / 27961 项 -> 改成 ! 形式后 72 MB / 2303 项,
|
||||
# 书签、密码(Login Data)、Cookies、偏好、历史、IndexedDB / Local Storage 全部保留。
|
||||
#
|
||||
# 注意:Edge 常驻时打包会有上百个文件读不到(含 Login Data / Cookies),
|
||||
# 脚本检测到警告后不会用这份不完整的归档覆盖已有的完整归档。备份前建议先退出 Edge。
|
||||
Edge :: !*Cache,component_crx_cache,Default\Service Worker,Default\Extensions,Default\ExtensionActivityEdge,ProvenanceData,optimization_guide,Crashpad,BrowserMetrics,Snapshots,Edge Sidebar,Edge Shopping
|
||||
Edge :: !*Cache,!component_crx_cache,!ProvenanceData,!optimization_guide,!Crashpad,!BrowserMetrics,Default\Service Worker,Default\Extensions,Default\ExtensionActivityEdge,Snapshots,Edge Sidebar,Edge Shopping
|
||||
WindowsTerminal
|
||||
|
||||
# ---- 系统 ----
|
||||
|
||||
+234
-56
@@ -392,52 +392,89 @@ function ConvertFrom-BackupListLine {
|
||||
return $null
|
||||
}
|
||||
|
||||
# `:` 在 Windows 路径里只可能是盘符,`::` 不可能出现在真实路径中,
|
||||
# 因此可以安全地按第一个 `::` 切分,不受引号位置影响。
|
||||
$separatorIndex = $content.IndexOf('::')
|
||||
if ($separatorIndex -ge 0) {
|
||||
$pathPart = $content.Substring(0, $separatorIndex)
|
||||
$tailPart = $content.Substring($separatorIndex + 2)
|
||||
# 行内记号(都用到 `:`,因为 `:` 在 Windows 路径里只可能是盘符,
|
||||
# 而 `::` `:+` `:-` 都不可能出现在真实路径里,所以切分不受引号位置影响):
|
||||
# :: 排除模式(历史写法,等价于 :-)
|
||||
# :+ 追加一个目录(等价于名录里的 Dirs 数组)
|
||||
# :- 排除模式
|
||||
# 记号可以出现多次、顺序任意:`Foo :+ D:\a D:\b :- logs\ !*Cache`
|
||||
# 第一段(第一个记号之前)是主路径/软件名。
|
||||
$segments = [System.Collections.Generic.List[object]]::new()
|
||||
$cursor = 0
|
||||
$currentKind = 'main'
|
||||
$currentText = ''
|
||||
$contentLength = $content.Length
|
||||
|
||||
while ($cursor -lt $contentLength) {
|
||||
$colonIndex = $content.IndexOf(':', $cursor)
|
||||
if ($colonIndex -lt 0) {
|
||||
$currentText += $content.Substring($cursor)
|
||||
break
|
||||
}
|
||||
|
||||
$currentText += $content.Substring($cursor, $colonIndex - $cursor)
|
||||
|
||||
# 记号必须是 `:` 后紧跟 `:` `+` `-` 之一
|
||||
if ($colonIndex + 1 -lt $contentLength -and $content[$colonIndex + 1] -in @(':', '+', '-')) {
|
||||
$kind = switch ($content[$colonIndex + 1]) {
|
||||
'+' { 'add' }
|
||||
'-' { 'exclude' }
|
||||
default { 'exclude' } # `::` 等同排除
|
||||
}
|
||||
$segments.Add([pscustomobject]@{ Kind = $currentKind; Text = $currentText.Trim() })
|
||||
$currentKind = $kind
|
||||
$currentText = ''
|
||||
$cursor = $colonIndex + 2
|
||||
} else {
|
||||
$pathPart = $content
|
||||
$tailPart = ''
|
||||
# 单个 `:`(盘符)属于内容
|
||||
$currentText += ':'
|
||||
$cursor = $colonIndex + 1
|
||||
}
|
||||
}
|
||||
$segments.Add([pscustomobject]@{ Kind = $currentKind; Text = $currentText.Trim() })
|
||||
|
||||
$pathPart = ''
|
||||
$addedPaths = @()
|
||||
$excludes = @()
|
||||
$flags = @()
|
||||
|
||||
# 标记(@encrypt 等)可能挂在任意段的末尾,所以每一段都先摘标记:
|
||||
# 历史写法 `C:\x :: a,b @encrypt` 里标记就是跟在排除表后面的。
|
||||
foreach ($segment in $segments) {
|
||||
if ([string]::IsNullOrWhiteSpace($segment.Text)) { continue }
|
||||
|
||||
$split = Split-TrailingFlags -Text $segment.Text
|
||||
if ($split.Flags.Count -gt 0) { $flags = @($flags) + @($split.Flags) }
|
||||
$body = $split.Remainder
|
||||
if ([string]::IsNullOrWhiteSpace($body)) { continue }
|
||||
|
||||
switch ($segment.Kind) {
|
||||
'main' { $pathPart = $body }
|
||||
'add' { $addedPaths += $body }
|
||||
'exclude' {
|
||||
$excludes += @($body -split '[,;]' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# 引号只应包住路径。整行被一对引号包住时(历史写法),
|
||||
# 上面的切分已经把排除表摘出去了,此时路径这半只剩开引号、
|
||||
# 闭引号留在了 tail 末尾,因此两侧各剥一次,不要求成对。
|
||||
# 切分后主路径这半只剩开引号、闭引号留在了尾段,因此两侧各剥一次,不要求成对。
|
||||
$pathPart = $pathPart.Trim()
|
||||
if ($pathPart.StartsWith('"')) { $pathPart = $pathPart.Substring(1) }
|
||||
if ($pathPart.EndsWith('"')) { $pathPart = $pathPart.Substring(0, $pathPart.Length - 1) }
|
||||
$pathPart = $pathPart.Trim()
|
||||
if ([string]::IsNullOrEmpty($pathPart)) { return $null }
|
||||
|
||||
$tailPart = $tailPart.Trim()
|
||||
if ($tailPart.EndsWith('"')) { $tailPart = $tailPart.Substring(0, $tailPart.Length - 1).Trim() }
|
||||
# 尾段可能残留闭引号(历史 `"路径 :: 排除表"` 写法)
|
||||
$excludes = @($excludes | ForEach-Object { $_.TrimEnd('"').Trim() } | Where-Object { $_ })
|
||||
|
||||
# 从尾部摘出 @标记。没有 `::` 时标记直接跟在路径后面
|
||||
# (如 `%UserProfile%\.ssh @encrypt`),因此 tail 为空时还要从路径那半再摘一次。
|
||||
# 标记可能出现在任一侧,所以两边都要摘。
|
||||
$tailSplit = Split-TrailingFlags -Text $tailPart
|
||||
$flags = @($tailSplit.Flags)
|
||||
$tailPart = $tailSplit.Remainder
|
||||
|
||||
$pathSplit = Split-TrailingFlags -Text $pathPart
|
||||
if ($pathSplit.Flags.Count -gt 0) {
|
||||
$flags = @($pathSplit.Flags) + $flags
|
||||
$pathPart = $pathSplit.Remainder
|
||||
}
|
||||
|
||||
$excludes = @()
|
||||
if ($tailPart) {
|
||||
$excludes = @($tailPart -split '[,;]' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
||||
}
|
||||
|
||||
return [pscustomobject]@{
|
||||
Path = $pathPart
|
||||
# 目录名或文件名,需要靠 SoftwareCatalog 换成真实路径;
|
||||
# 带分隔符或 %变量% 的写法按字面路径处理(并给出警告)。
|
||||
IsName = (-not (Test-LiteralPath -Path $pathPart))
|
||||
AddedPaths = $addedPaths
|
||||
ExcludePatterns = $excludes
|
||||
Flags = $flags
|
||||
Raw = $Line
|
||||
@@ -620,29 +657,60 @@ function Get-SoftwareCatalog {
|
||||
$entry = if ($data -is [System.Collections.IDictionary]) { $data[$key] } else { $data.$key }
|
||||
|
||||
$rawPath = $null
|
||||
$variants = $null
|
||||
$dirList = @()
|
||||
if ($entry -is [System.Collections.IDictionary]) {
|
||||
# Dirs = 一个软件包含的多个目录(推荐写法)
|
||||
# Variants = 同名目录出现在多个位置(旧写法,等价于 Dirs,保留兼容)
|
||||
if ($entry.Contains('Dirs')) { $dirList = @($entry['Dirs']) }
|
||||
elseif ($entry.Contains('Variants')) { $dirList = @($entry['Variants']) }
|
||||
if ($entry.Contains('Path')) { $rawPath = [string]$entry['Path'] }
|
||||
if ($entry.Contains('Variants')) { $variants = @($entry['Variants']) }
|
||||
} else {
|
||||
$rawPath = [string]$entry
|
||||
}
|
||||
if (-not $rawPath) { continue }
|
||||
if (-not $rawPath -and $dirList.Count -eq 0) { continue }
|
||||
|
||||
$resolved = [Environment]::ExpandEnvironmentVariables($rawPath)
|
||||
$resolved = if ($rawPath) { [Environment]::ExpandEnvironmentVariables($rawPath) } else { $null }
|
||||
|
||||
# 多目录:逐个解析(每个都可以用前缀补全),任一解析不出来就整体 Unresolved
|
||||
if ($dirList.Count -gt 0) {
|
||||
$paths = @()
|
||||
$unresolved = @()
|
||||
foreach ($item in $dirList) {
|
||||
$candidate = [Environment]::ExpandEnvironmentVariables([string]$item)
|
||||
if (Test-Path -LiteralPath $candidate) {
|
||||
$paths += $candidate
|
||||
continue
|
||||
}
|
||||
$parent = Split-Path -Path $candidate -Parent
|
||||
$leaf = Split-Path -Path $candidate -Leaf
|
||||
$found = $null
|
||||
if ($parent -and $leaf -and (Test-Path -LiteralPath $parent)) {
|
||||
$found = @(Find-ChildDirectoryByName -Parent $parent -Name $leaf -MaxDepth $MaxDepth)
|
||||
}
|
||||
if ($found -and $found.Count -gt 0) {
|
||||
$paths += $found[0]
|
||||
Write-Log "名录:$name 的 $candidate -> $($found[0])(按前缀补全)" -Level DEBUG
|
||||
} else {
|
||||
$unresolved += $candidate
|
||||
}
|
||||
}
|
||||
|
||||
$kind = if ($paths.Count -eq 0) { 'Unresolved' } elseif ($unresolved.Count -gt 0) { 'Partial' } else { 'Multi' }
|
||||
if ($unresolved.Count -gt 0) {
|
||||
Write-Log ("名录:{0} 有 {1} 个目录找不到:{2}" -f $name, $unresolved.Count, ($unresolved -join ';')) -Level WARN
|
||||
}
|
||||
|
||||
if ($variants -and $variants.Count -gt 0) {
|
||||
# 同名目录出现在多个位置:不猜,所有位置都作为归档根目录。
|
||||
$resolvedVariants = @($variants | ForEach-Object { [Environment]::ExpandEnvironmentVariables([string]$_) })
|
||||
$result[$name] = [pscustomobject]@{
|
||||
Name = $name
|
||||
Path = $rawPath
|
||||
ResolvedPath = $resolved
|
||||
Variants = $resolvedVariants
|
||||
Kind = 'Variant'
|
||||
ResolvedPath = $(if ($paths.Count -gt 0) { $paths[0] } else { $resolved })
|
||||
Variants = $paths
|
||||
Dirs = $paths
|
||||
Missing = $unresolved
|
||||
Kind = $kind
|
||||
Raw = $entry
|
||||
}
|
||||
Write-Log "名录:$name 有 $($resolvedVariants.Count) 个候选位置" -Level DEBUG
|
||||
Write-Log "名录:$name -> $($paths.Count) 个目录" -Level DEBUG
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -655,7 +723,7 @@ function Get-SoftwareCatalog {
|
||||
$found = $null
|
||||
if ($parent -and (Test-Path -LiteralPath $parent)) {
|
||||
$found = @(Find-ChildDirectoryByName -Parent $parent -Name $leaf -MaxDepth $MaxDepth)
|
||||
if ($found.Count -gt 1) { $kind = 'Variant' } elseif ($found.Count -eq 1) { $kind = 'Single' } else { $kind = 'Unresolved' }
|
||||
if ($found.Count -gt 1) { $kind = 'Multi' } elseif ($found.Count -eq 1) { $kind = 'Single' } else { $kind = 'Unresolved' }
|
||||
} else {
|
||||
$kind = 'Unresolved'
|
||||
}
|
||||
@@ -667,6 +735,8 @@ function Get-SoftwareCatalog {
|
||||
Path = $rawPath
|
||||
ResolvedPath = $resolved
|
||||
Variants = @($found)
|
||||
Dirs = @($found)
|
||||
Missing = @()
|
||||
Kind = $kind
|
||||
Raw = $entry
|
||||
Suffixed = $true
|
||||
@@ -681,6 +751,8 @@ function Get-SoftwareCatalog {
|
||||
Path = $rawPath
|
||||
ResolvedPath = $resolved
|
||||
Variants = @()
|
||||
Dirs = $(if ($kind -eq 'Single') { @($resolved) } else { @() })
|
||||
Missing = @()
|
||||
Kind = $kind
|
||||
Raw = $entry
|
||||
}
|
||||
@@ -689,6 +761,51 @@ function Get-SoftwareCatalog {
|
||||
return $result
|
||||
}
|
||||
|
||||
function Get-ArchiveTopLevelNames {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
列出归档内的顶层条目名(用于确认多目录打包时每个目录都真的进去了)。
|
||||
|
||||
.DESCRIPTION
|
||||
**刻意不解析 7z 的输出**:读取子进程 stdout 需要创建管道,本机沙箱会直接拒绝
|
||||
(Access to the path '\\.\pipe\LOCAL\dotnet_...' denied),文件重定向(> file)
|
||||
同样被拒。所以改成"把归档解到临时目录,再看文件系统上有哪些顶层条目",
|
||||
只依赖文件系统。代价是多一次解压(只在多目录条目上跑),
|
||||
好处是这个校验在受限环境里真的会执行,而不是静默退化成空数组。
|
||||
|
||||
解压失败或拿不到 7z 时返回空数组,调用方据此跳过顶层名核对。
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$ArchivePath,
|
||||
[Parameter(Mandatory = $true)][string]$SevenZip,
|
||||
[string]$Password
|
||||
)
|
||||
|
||||
$staging = Join-Path $env:TEMP ("bnr-inspect-" + [guid]::NewGuid().ToString('N'))
|
||||
$names = @()
|
||||
try {
|
||||
New-Item -ItemType Directory -Path $staging -Force | Out-Null
|
||||
|
||||
$argument = @('x', '-bso0', '-bsp0', '-y', "-o$staging")
|
||||
if ($Password) { $argument += "-p$Password" }
|
||||
$argument += $ArchivePath
|
||||
|
||||
$exitCode = Invoke-ExternalCommand -FilePath $SevenZip -ArgumentList $argument
|
||||
if ($exitCode -ne 0) { return @() }
|
||||
|
||||
# 先把名字读进变量,再在 finally 里删临时目录;
|
||||
# 直接在 return 里管道 Get-ChildItem 会被 finally 抢在前面,拿到空数组。
|
||||
$names = @(Get-ChildItem -LiteralPath $staging -Force -ErrorAction SilentlyContinue |
|
||||
Select-Object -ExpandProperty Name)
|
||||
} catch {
|
||||
Write-Log "无法清点归档内容(跳过顶层名核对):$($_.Exception.Message)" -Level DEBUG
|
||||
$names = @()
|
||||
} finally {
|
||||
Remove-Item -LiteralPath $staging -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
return $names
|
||||
}
|
||||
function Find-ChildDirectoryByName {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
@@ -793,6 +910,11 @@ function Resolve-BackupEntry {
|
||||
$baseName = Get-ItemArchiveName -Entry $Entry -CatalogPath $CatalogPath -MaxDepth $MaxDepth
|
||||
$rootNames = @($Entry.Flags | Where-Object { $_ -like 'root=*' } | ForEach-Object { $_.Substring(5) })
|
||||
|
||||
# 必须在下面任何一个 return 之前算出来:名录里没有这个名字时也要走
|
||||
# "路径不存在" 分支,那条分支引用 $rootName。原先它写在后面,靠 PowerShell
|
||||
# 的隐式 $null 侥幸不报错,但会把**外层作用域**残留的 $rootName 带进来。
|
||||
$rootName = if ($rootNames.Count -gt 0) { $rootNames[0] } else { $baseName }
|
||||
|
||||
if (-not $isName) {
|
||||
$sourcePath = [Environment]::ExpandEnvironmentVariables($Entry.Path)
|
||||
return [pscustomobject]@{
|
||||
@@ -821,7 +943,6 @@ function Resolve-BackupEntry {
|
||||
}
|
||||
|
||||
$catalogEntry = $catalog[$Entry.Path]
|
||||
$rootName = if ($rootNames.Count -gt 0) { $rootNames[0] } else { $baseName }
|
||||
|
||||
# @pathname 必须排在 Unresolved 分支之前:否则路径不存在的条目会先被
|
||||
# 当作普通软件名条目返回 Flavor='name',把 @pathname 覆盖悄悄吃掉。
|
||||
@@ -882,15 +1003,21 @@ function Resolve-BackupEntry {
|
||||
|
||||
$sources = @()
|
||||
|
||||
if ($catalogEntry.Kind -eq 'Variant') {
|
||||
$parent = Split-Path -Path $catalogEntry.ResolvedPath -Parent
|
||||
$relatives = @($catalogEntry.Variants | ForEach-Object { Split-Path -Path $_ -Leaf })
|
||||
$sources = @([pscustomobject]@{
|
||||
RootName = $rootName
|
||||
ParentDir = $parent
|
||||
RelativePaths = $relatives
|
||||
SourcePath = $parent
|
||||
})
|
||||
if ($catalogEntry.Kind -in @('Multi', 'Partial')) {
|
||||
# 一个软件包含多个目录:每个目录各占归档里的一个根目录层。
|
||||
# 归档内层用**源目录自己的名字**(不用软件名),这样恢复时
|
||||
# 每个目录都能各自找到父目录与末级名,互不干扰。
|
||||
foreach ($dir in $catalogEntry.Dirs) {
|
||||
$dirParent = Split-Path -Path $dir -Parent
|
||||
$dirLeaf = Split-Path -Path $dir -Leaf
|
||||
if (-not $dirParent -or -not $dirLeaf) { continue }
|
||||
$sources += [pscustomobject]@{
|
||||
RootName = $null
|
||||
ParentDir = $dirParent
|
||||
RelativePaths = @($dirLeaf)
|
||||
SourcePath = $dir
|
||||
}
|
||||
}
|
||||
} elseif ($catalogEntry.Kind -eq 'Single') {
|
||||
$sources = @([pscustomobject]@{
|
||||
RootName = $rootName
|
||||
@@ -911,6 +1038,22 @@ function Resolve-BackupEntry {
|
||||
}
|
||||
}
|
||||
|
||||
# 清单里的 `:+ <路径>` 追加项,叠加在名录的目录之后
|
||||
if ($Entry.AddedPaths -and $Entry.AddedPaths.Count -gt 0) {
|
||||
foreach ($added in $Entry.AddedPaths) {
|
||||
$addedPath = [Environment]::ExpandEnvironmentVariables($added)
|
||||
$addedParent = Split-Path -Path $addedPath -Parent
|
||||
$addedLeaf = Split-Path -Path $addedPath -Leaf
|
||||
if (-not $addedParent -or -not $addedLeaf) { continue }
|
||||
$sources += [pscustomobject]@{
|
||||
RootName = $null
|
||||
ParentDir = $addedParent
|
||||
RelativePaths = @($addedLeaf)
|
||||
SourcePath = $addedPath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [pscustomobject]@{
|
||||
IsName = $true
|
||||
CatalogEntry = $catalogEntry
|
||||
@@ -1195,14 +1338,26 @@ function Get-BaknretConfig {
|
||||
function Get-BaknretPassword {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
从环境变量 BAKNRET_PASSWORD 或密码文件取加密口令;取不到返回 $null。
|
||||
取加密口令:命令行参数 > 环境变量 > 密码文件 > 交互式询问。
|
||||
|
||||
.DESCRIPTION
|
||||
口令**不写入仓库**。若清单要求加密但取不到口令,调用方必须失败退出,
|
||||
绝不能默默写出明文归档。
|
||||
#>
|
||||
param([string]$PasswordFile)
|
||||
口令**绝不写入仓库**。优先级:
|
||||
1. -Password(命令行传参,注意会短暂出现在进程列表里)
|
||||
2. $env:BAKNRET_PASSWORD
|
||||
3. PasswordFile 的首行(文件必须在仓库之外,脚本只记路径)
|
||||
4. 交互式询问(仅当 allowPrompt 且当前是交互式会话)
|
||||
全都拿不到就返回 $null,调用方必须失败退出,绝不能默默写明文归档。
|
||||
|
||||
交互式询问用的是 Read-Host -AsSecureString,输入不回显;但它需要真实控制台,
|
||||
在计划任务/CI 里会把用户晾在那里等输入,所以只在交互式会话里才提示。
|
||||
#>
|
||||
param(
|
||||
[string]$Password,
|
||||
[string]$PasswordFile,
|
||||
[switch]$AllowPrompt
|
||||
)
|
||||
|
||||
if ($Password) { return $Password }
|
||||
if ($env:BAKNRET_PASSWORD) { return $env:BAKNRET_PASSWORD }
|
||||
|
||||
if ($PasswordFile -and (Test-Path -LiteralPath $PasswordFile)) {
|
||||
@@ -1210,6 +1365,28 @@ function Get-BaknretPassword {
|
||||
if ($line) { return $line.Trim() }
|
||||
}
|
||||
|
||||
if ($AllowPrompt) {
|
||||
# 只有在真的会等人输入时才提示,避免计划任务里静默挂起
|
||||
$interactive = $true
|
||||
try { $interactive = -not [System.Console]::IsInputRedirected } catch { $interactive = $false }
|
||||
|
||||
if ($interactive) {
|
||||
Write-Log '需要加密口令,请在弹出的提示里输入(不会回显、不会落盘)' -Level WARN
|
||||
try {
|
||||
$secure = Read-Host -Prompt '请输入加密口令' -AsSecureString
|
||||
$bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure)
|
||||
try {
|
||||
return [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr)
|
||||
} finally {
|
||||
[System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
|
||||
}
|
||||
} catch {
|
||||
Write-Log "口令输入失败:$_" -Level ERROR
|
||||
return $null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
@@ -1219,6 +1396,7 @@ Export-ModuleMember -Function @(
|
||||
'ConvertTo-NativeArgumentString', 'Invoke-ExternalCommand', 'Resolve-CompressionTool', 'Get-Optimized7zArgument',
|
||||
'ConvertFrom-BackupListLine', 'Get-ArchiveExcludeArgument', 'Test-LiteralPath',
|
||||
'Resolve-CatalogPath', 'Get-SoftwareCatalog', 'Find-ChildDirectoryByName', 'Format-CatalogName',
|
||||
'Get-ArchiveTopLevelNames',
|
||||
'Get-ItemArchiveName', 'Resolve-BackupEntry', 'Get-BackupBaseName', 'Convert-BackupFileNameToPath',
|
||||
'Get-FolderSummary',
|
||||
'Read-BaknretManifest', 'Write-BaknretManifest', 'Move-BaknretArchiveIntoPlace',
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
|
||||
- 清单里**直接写软件名**即可(如 `FooClolor`),目录映射维护在 `SoftwareCatalog.psd1` 里。
|
||||
- 归档名就是软件名(`FooClolor.7z`),不再是 `FooClolor_from_C_+Programs.7z`。
|
||||
- 只依赖 PowerShell(5.1 或 7.x)与 7-Zip,无需安装模块。
|
||||
- 只依赖 PowerShell(5.1 或 7.x)与 7-Zip,**运行备份/恢复不需要任何模块**(只有跑 Pester 测试才需要 Pester 5)。
|
||||
- 每个归档写完后做 `7z t` 内容校验,**先写临时文件、校验通过再原子替换**。
|
||||
- 每次运行产出可核对的 `Backups/manifest.json` 与 `logs/*.log`。
|
||||
- 退出码可靠:有失败就返回 `1`,计划任务能正确判断成败。
|
||||
- 备份结束做**孤儿归档审计**:磁盘上有、但没有任何清单条目指向的归档会被点名(它们恢复不到,别误删)。
|
||||
- 恢复支持 `-WhatIf` / `-DryRun` / `-VerifyOnly` / `-Only` / `-Skip`;其中三种"只看不写"的模式(`-WhatIf` / `-DryRun` / `-VerifyOnly`)**一个字节都不写**。
|
||||
|
||||
---
|
||||
|
||||
@@ -48,9 +50,10 @@
|
||||
| `Common.psm1` | 公共模块(日志、外部命令、解析、名录、manifest) |
|
||||
| `Backups/` | 归档与 `manifest.json`(已 gitignore) |
|
||||
| `logs/` | 每次运行的日志(已 gitignore) |
|
||||
| `tests/` | 单元测试与端到端验收 |
|
||||
| `tests/` | 测试:Pester 套件、零依赖套件、端到端验收、真实归档恢复演练 |
|
||||
| `tools/Register-BackupTask.ps1` | 注册 / 移除计划任务 |
|
||||
| `tools/Rename-Archives.ps1` | 把按路径命名的旧归档重命名成软件名(默认试运行) |
|
||||
| `tools/Install-TestDependencies.ps1` | 把 Pester 5 装到仓库内的 `.tools/`(不动机器上的全局模块) |
|
||||
|
||||
## SoftwareCatalog.psd1 —— 软件名 → 目录
|
||||
|
||||
@@ -106,7 +109,7 @@
|
||||
| --- | --- |
|
||||
| `encrypt` | 用 7z 加密该归档,见下文「加密」 |
|
||||
| `pathname` | 用路径命名算法而不是软件名 |
|
||||
| `root=<名>` | 覆盖归档内的根目录名(默认源目录名) |
|
||||
| `root=<名>` | **尚未实现**:归档内的根目录始终是源目录名。用了会打印告警,不会静默失效 |
|
||||
|
||||
排除模式:相对归档根目录。以 `!` 开头表示"任意层级下匹配这个组件名"(7z 的 `-xr!`)。
|
||||
分隔符 `,` 与 `;` 都可以。**不要自己写引号**;模式里的空格会被自动转成 `?`。
|
||||
@@ -117,6 +120,22 @@
|
||||
- **模式里的空格会被自动转成 `?`。** 7z 的排除模式不支持空格:`Default\Code Cache` 匹配不到任何东西,`Default\Code?Cache` 才可以。
|
||||
- **以第一个 `::` 为界切分。** `:` 在 Windows 路径里只可能是盘符,`::` 不会出现在真实路径里,所以整行被一对引号包住的历史写法也能正确解析。
|
||||
- **归档名重复会直接报错。** 归档名就是软件名,所以同一个软件写两遍会让两个条目互相覆盖 —— 脚本拒绝执行并提示。
|
||||
- **不带 `!` 的普通模式是"相对归档根目录"锚定的**(展开成 `-x!<归档内完整路径>`),所以只排除根目录下那一份。
|
||||
Edge 的 `OneAuth\WebView2\EBWebView\` 里还藏着一整套自己的 `Crashpad` / `BrowserMetrics` /
|
||||
`ProvenanceData` / `optimization_guide`,根锚定模式碰不到它们 —— 这类可再生的东西要用
|
||||
`!<组件名>`(展开成 `-xr!`)才会在任意层级命中。
|
||||
- **`!` 是按"路径组件"精确匹配,不是子串。** `!Crashpad` 不会误伤 `CrashpadMetrics.pma`
|
||||
或 `ProvenanceDataTensors`,也不会漏掉嵌套的 `...\EBWebView\Crashpad\`。
|
||||
|
||||
实测效果(本机真实 Edge 配置,源 4619.9 MB):
|
||||
|
||||
| Edge 归档 | 大小 | 条目数 |
|
||||
| --- | --- | --- |
|
||||
| 排除规则生效前 | 1781 MB | 27961 |
|
||||
| 排除规则生效后 | 72 MB | 2294 |
|
||||
|
||||
书签、密码(`Login Data`)、`Cookies`、偏好、历史、`IndexedDB`、`Local Storage` 全部保留;
|
||||
缓存、组件缓存、Service Worker、扩展本体、遥测与优化数据全部排除。
|
||||
|
||||
## 归档命名与迁移
|
||||
|
||||
@@ -141,6 +160,7 @@
|
||||
- **不做镜像同步**:目标目录里多出来的文件不会被删除。想得到"完全等于归档"的目录,请先清空目标。
|
||||
- 目标目录比归档新时**默认跳过**,需要覆盖就加 `-Force`。
|
||||
- `-WhatIf` / `-DryRun` 只打印计划;`-VerifyOnly` 只跑 `7z t`。
|
||||
这三种模式**一个字节都不写**(`manifest.json` 也不会被碰)。
|
||||
- **排除规则只在下一份归档里生效**:已经生成的归档不会因为改了排除表而"变干净"。
|
||||
|
||||
## manifest.json
|
||||
@@ -151,7 +171,7 @@
|
||||
| --- | --- |
|
||||
| `source` | 清单里的原始写法(软件名或路径) |
|
||||
| `resolvedSource` | 展开后的路径 |
|
||||
| `roots` | 归档内的根目录名 |
|
||||
| `roots` | 归档内**真实**的顶层条目名(就是源目录 / 源文件名;只统计真实存在的源)。每次重新处理该条目时刷新 |
|
||||
| `catalog` | 名录里记录的路径(便于追溯软件名到底指向哪) |
|
||||
| `archive` | 归档文件名 |
|
||||
| `action` | `backed-up` / `skip-unchanged` / `missing-source` / `invalid-path` / `failed` / `planned` |
|
||||
@@ -225,12 +245,34 @@ $env:BAKNRET_PASSWORD = '...' # 或
|
||||
|
||||
## 测试
|
||||
|
||||
四套,按"需要多少依赖"分层:
|
||||
|
||||
| 套件 | 命令 | 需要什么 | 覆盖 |
|
||||
| --- | --- | --- | --- |
|
||||
| **Pester 套件**(推荐) | `.\tests\Run-Pester.ps1` | Pester 5.0+ 与 7z | 62 项:解析、命名、排除翻译、命令行拼接、manifest / 配置 / 名录,外加**用子进程真正跑 `Backup.ps1` / `Restore.ps1`** 的端到端与回归 |
|
||||
| 零依赖套件 | `.\tests\Run-Tests.ps1` | 只要 PowerShell + 7z | 49 项:同样的单元面,适合没装 Pester 的机器 |
|
||||
| 端到端验收 | `.\tests\Run-E2E.ps1` | 只要 PowerShell + 7z | 23 项:备份 → 确认排除生效 → 删源 → 恢复 → 逐字节对拍 |
|
||||
| **真实归档恢复演练** | `.\tests\Restore-Drill.ps1` | 只要 PowerShell + 7z | 把 `Backups/` 里**真实的那批归档**解到临时目录,再和活源逐字节对拍(全程不碰真实目录) |
|
||||
|
||||
演练会把"源在备份之后变过"和"归档/解压有问题"分开:内容不一致时看活源文件的修改时间,
|
||||
晚于归档时间就算"源变了"(只提示),不晚于归档时间却内容不同才算失败。真实机器上的归档
|
||||
常常是几周前的,不这样区分就天天报假失败。
|
||||
|
||||
Pester 套件要求 **5.0+**。系统自带的是 3.4.0,没有 `Should -Be`,套件会直接语法错误,
|
||||
所以 `Run-Pester.ps1` 会先查版本,查不到就以退出码 2 结束并打印安装命令。两种装法:
|
||||
|
||||
```powershell
|
||||
.\tests\Run-Tests.ps1 # 单元测试 42 项
|
||||
.\tests\Run-E2E.ps1 # 端到端验收 23 项
|
||||
.\tools\Install-TestDependencies.ps1 # 只装进仓库内的 .tools/(推荐,不动机器上的全局模块)
|
||||
# 或者
|
||||
Install-Module Pester -Scope CurrentUser -MinimumVersion 5.0.0
|
||||
```
|
||||
|
||||
零依赖,不需要 Pester(本机只有 3.4.0,`Should -Be` 会直接语法错误)。
|
||||
`Run-Pester.ps1` 优先使用 `.tools/` 里的本地副本,其次是机器上已装的 5.x;
|
||||
`.tools/` 已进 `.gitignore`。
|
||||
|
||||
Pester 套件里的端到端用例是**用子进程**跑 `Backup.ps1` / `Restore.ps1` 的,原因有二:
|
||||
两个脚本结尾都会 `exit`,同进程 `&` 调用会把 Pester 宿主一起带走;而且子进程给出的是
|
||||
真正的进程退出码,正好独立验证"退出码取法"这条修复。
|
||||
|
||||
## 相对旧版修了什么
|
||||
|
||||
@@ -247,6 +289,10 @@ $env:BAKNRET_PASSWORD = '...' # 或
|
||||
| 恢复用 `-Filter "$baseName.*"` | 含 `[` `]` 的路径会失配 | 精确比较 `BaseName`,且优先查 manifest |
|
||||
| tar 分支 `$LASTEXITCODE -ne 0 -and $proc.ExitCode -ne 0` | `$LASTEXITCODE` 是上一条原生命令的残留值,恰为 0 时把解压失败吞掉 | 三条分支统一走同一个取退出码的封装 |
|
||||
| 恢复没有干跑 | 直接覆盖 `E:\CodeSpace`、Edge User Data 这类真实目录 | `-WhatIf` / `-DryRun` / `-VerifyOnly` / `-Only` |
|
||||
| `manifest.json` 的 `roots` | 记的是软件名,与归档里真实的顶层目录对不上(`Edge` vs `User Data`) | 记归档内真实的顶层条目名,并且和归档内容对账过 |
|
||||
| `-DryRun` / `-WhatIf` / `-VerifyOnly` | 仍然写回 `manifest.json`,违背"不会写入任何文件" | 只有真的恢复成功了才写回(用 manifest 的 SHA256 前后对比验证) |
|
||||
| 孤儿归档 | 只在恢复时列一下;带 `-Only` 时还会把未选中的归档误报成孤儿,吓得人不敢删 | 备份端也做孤儿审计;`-Only` / `-Skip` 时不再误报 |
|
||||
| `Resolve-BackupEntry` 里的 `$rootName` | 在赋值之前就被引用,会读到外层作用域残留的值 | 提前赋值,回归测试钉死 |
|
||||
| 没有名录、manifest、测试、README,不是 git 仓库 | — | 都有 |
|
||||
|
||||
## 设计取舍(有意为之,不是遗漏)
|
||||
@@ -265,3 +311,5 @@ $env:BAKNRET_PASSWORD = '...' # 或
|
||||
- `-Snapshot` 目前是"复制一份带时间戳的副本",不做自动轮转清理(`KeepCount` / `KeepDays` 尚未实现)。
|
||||
- 加密归档的常规备份/恢复不依赖 `RAR`;`RAR` 与内置 `ZIP` 分支仅作降级,未做加密支持(ZIP 明确拒绝加密请求)。
|
||||
- `Variants`(同名目录分散在多处)当前打包第一个位置,恢复时逐个位置各解压一份。
|
||||
- **`root=<名>` 标记尚未实现。** 归档内的根目录始终是源目录名(见「设计取舍」)。7z 命令行没有"入库时改名"的能力;用了该标记会打印告警,不会静默失效。
|
||||
- **磁盘空间守卫是逐条目判断的**,不预留"本次运行后续条目"的空间。`MinFreeSpaceGB` 只是告警阈值;真正拦条目的是"剩余空间 < 该条目预估大小"。往接近写满的卷上备份时请自己留意总用量。
|
||||
+17
-1
@@ -279,6 +279,12 @@ $stats = @{ restored = 0; skipped = 0; failed = 0; verified = 0; planned = 0 }
|
||||
$failures = @()
|
||||
$referencedArchives = @()
|
||||
|
||||
# 只有真的恢复成功了才允许写回 manifest。
|
||||
# -WhatIf / -DryRun / -VerifyOnly 以及"全部跳过"的运行必须一个字节都不写:
|
||||
# 之前这里无条件写回,实际上只是把 updatedAt 改了,却直接违背了
|
||||
# "试运行不会写入任何文件" 的承诺(已用 manifest 的 SHA256 复现)。
|
||||
$manifestDirty = $false
|
||||
|
||||
Write-Log '开始执行恢复' -Level INFO
|
||||
|
||||
foreach ($line in $lines) {
|
||||
@@ -426,6 +432,7 @@ foreach ($line in $lines) {
|
||||
} else {
|
||||
$record | Add-Member -NotePropertyName lastRestoreAt -NotePropertyValue ((Get-Date).ToString('o')) -Force
|
||||
}
|
||||
$manifestDirty = $true
|
||||
}
|
||||
} else {
|
||||
$stats.failed++
|
||||
@@ -442,7 +449,7 @@ foreach ($line in $lines) {
|
||||
# 收尾:报告孤儿归档
|
||||
# ============================================================================
|
||||
|
||||
if (-not $VerifyOnly) {
|
||||
if (-not $VerifyOnly -and $Only.Count -eq 0 -and $Skip.Count -eq 0) {
|
||||
$orphans = @(Get-ChildItem -LiteralPath $BackupDir -File -Force -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Extension.ToLower() -in $SupportedFormats -and $_.BaseName -notin $referencedArchives })
|
||||
|
||||
@@ -452,10 +459,19 @@ if (-not $VerifyOnly) {
|
||||
Write-Log (" - {0}({1} MB,{2})" -f $orphan.Name, [math]::Round($orphan.Length / 1MB, 2), $orphan.LastWriteTime) -Level WARN
|
||||
}
|
||||
}
|
||||
} elseif (-not $VerifyOnly) {
|
||||
# 带 -Only/-Skip 时只有被选中的条目会被处理,其余归档都不在 $referencedArchives 里,
|
||||
# 按上面的算法报出来全是假孤儿(还会吓唬人说"注意别误删"),所以整段跳过。
|
||||
Write-Log '本次只恢复了部分条目,跳过孤儿归档审计(避免把未选中的归档误报成孤儿)' -Level DEBUG
|
||||
}
|
||||
|
||||
try {
|
||||
if ($manifestDirty) {
|
||||
Write-BaknretManifest -Path $manifestPath -Manifest $manifest | Out-Null
|
||||
Write-Log 'manifest 已更新(记下本次恢复时间)' -Level DEBUG
|
||||
} else {
|
||||
Write-Log 'manifest 无需更新:本次没有实际恢复任何条目' -Level DEBUG
|
||||
}
|
||||
} catch {
|
||||
Write-Log "manifest 写回失败(不影响本次恢复):$_" -Level WARN
|
||||
}
|
||||
|
||||
@@ -31,12 +31,16 @@
|
||||
1. 目录不存在时会按前缀补全:写 'D:\Programs\legendary',实际目录是
|
||||
'D:\Programs\legendary_2.0.4',会自动匹配(只认 `<名>_*` 与 `<名>-*`,
|
||||
不会把 Legendary 误配成 LegendarySomething)。
|
||||
2. 同名目录出现在多个位置时,用字典形式显式列出,所有位置都会打进同一个归档:
|
||||
2. **一个软件包含多个目录**时,用 Dirs 数组列出,全部打进同一个归档:
|
||||
|
||||
ImHex = @{
|
||||
Path = 'D:\Hex\ImHex'
|
||||
Variants = @('D:\Hex\ImHex', 'E:\Backup\ImHex')
|
||||
}
|
||||
scoop = @{ Dirs = @(
|
||||
'%UserProfile%\scoop\persist'
|
||||
'C:\Programs\ScoopApps\persist'
|
||||
'%UserProfile%\.config\scoop'
|
||||
) }
|
||||
|
||||
归档里每个目录仍是自己的名字与层级,恢复时各自还原回原位。
|
||||
同名目录出现在多个位置的旧写法 Variants 等价于 Dirs,继续可用。
|
||||
|
||||
分文件维护:用 Includes 引入其它名录文件(路径相对本文件):
|
||||
|
||||
|
||||
@@ -0,0 +1,697 @@
|
||||
<#
|
||||
.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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
真实归档的恢复演练:把**硬盘上真实的归档**恢复到临时目标,再和活的源目录逐字节对拍。
|
||||
|
||||
.DESCRIPTION
|
||||
和 tests/Run-E2E.ps1 的分工:
|
||||
* Run-E2E.ps1 用自己造的假数据,证明的是"整条链路能跑通";
|
||||
* 本脚本证明的是"**这一批真实归档**解得开,而且解出来的东西和源一致"。
|
||||
|
||||
关键设计:**绝不碰真实目录**。做法是给一份临时名录(SoftwareCatalog),
|
||||
把软件名映射到临时目标目录,于是 Restore.ps1 会把归档解到临时目录,
|
||||
而不是 ~\.ssh、C:\Programs\... 这些真地方。真实归档本身只被读取。
|
||||
|
||||
对拍规则(关键:先把"源变了"和"归档坏了"分开):
|
||||
* 恢复树里每个文件都必须在活源里存在 —— 否则失败(说明归档里混进了别的东西);
|
||||
* 内容不一致时看活源文件的修改时间:晚于归档时间 ⇒ 源在备份之后被改过,
|
||||
只提示、不算失败;不晚于归档时间却内容不同 ⇒ 归档或解压有问题,算失败;
|
||||
* 活源里在备份之后新增 / 删掉的文件只提示;
|
||||
* 一个条目一个文件都对不上 —— 失败(多半是空归档,必须点名)。
|
||||
|
||||
真实机器上的归档常常是几周前的,所以"必须和今天逐字节一致"不是合理判据;
|
||||
上面对"源变了"的区分让这个演练在活的机器上也能天天跑。
|
||||
|
||||
.EXAMPLE
|
||||
# 用真实归档(默认读 BackupConfig.psd1 里的 BackupDir)做演练
|
||||
pwsh -File .\tests\Restore-Drill.ps1
|
||||
|
||||
.EXAMPLE
|
||||
# 只演练指定条目,并保留下临时工作目录
|
||||
pwsh -File .\tests\Restore-Drill.ps1 -Entries '.ssh','legendary' -KeepWorkRoot
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
# 归档所在目录;默认取 BackupConfig.psd1 里的 BackupDir
|
||||
[string]$BackupDir,
|
||||
|
||||
# 要演练的条目(软件名)。默认是一组"小、静态、无排除规则"的条目
|
||||
[string[]]$Entries = @(
|
||||
'.ssh', 'legendary', 'scoop-config', 'opencode',
|
||||
'PowerShell', 'WindowsPowerShell', 'MiFlash', 'MiFlash_Unlock',
|
||||
'Startup', 'WindowsTerminal', 'Aria'
|
||||
),
|
||||
|
||||
[string]$ConfigPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'BackupConfig.psd1'),
|
||||
|
||||
[string]$WorkRoot,
|
||||
|
||||
# 活源在备份之后变过的文件只告警、不算失败
|
||||
[switch]$AllowChanged,
|
||||
|
||||
[switch]$KeepWorkRoot
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$projectRoot = Split-Path -Parent $PSScriptRoot
|
||||
$restoreScript = Join-Path $projectRoot 'Restore.ps1'
|
||||
|
||||
Import-Module (Join-Path $projectRoot 'Common.psm1') -Force
|
||||
|
||||
if (-not (Test-Path -LiteralPath $restoreScript)) {
|
||||
Write-Error "找不到 Restore.ps1:$restoreScript"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$config = Get-BaknretConfig -Path $ConfigPath
|
||||
$catalogPath = Resolve-CatalogPath -Configured $config.SoftwareCatalog -Root $projectRoot
|
||||
|
||||
if (-not $BackupDir) {
|
||||
$BackupDir = $config.BackupDir
|
||||
if (-not [System.IO.Path]::IsPathRooted($BackupDir)) { $BackupDir = Join-Path $projectRoot $BackupDir }
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $BackupDir)) {
|
||||
Write-Error "归档目录不存在:$BackupDir"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (-not $WorkRoot) {
|
||||
$WorkRoot = Join-Path $env:TEMP ('baknret-drill-' + [guid]::NewGuid().ToString('N').Substring(0, 8))
|
||||
}
|
||||
New-Item -ItemType Directory -Path $WorkRoot -Force | Out-Null
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '== 真实归档恢复演练:归档 -> 临时目标 -> 与活源逐字节对拍 ==' -ForegroundColor Cyan
|
||||
Write-Host " 归档目录:$BackupDir"
|
||||
Write-Host " 软件名录:$catalogPath"
|
||||
Write-Host " 工作目录:$WorkRoot"
|
||||
Write-Host ''
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 工具
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
function Compare-RestoredTree {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
把恢复出来的树和活源逐字节对拍。
|
||||
|
||||
.DESCRIPTION
|
||||
对拍结果分成两类,因为它们的含义完全不同:
|
||||
* Stale(活源在归档之后被改过):**只提示**。这是源变了,不是归档坏了 ——
|
||||
真实机器上的归档往往是几周前的,硬按"必须和今天一致"判失败毫无意义。
|
||||
* Changed(活源时间不晚于归档,内容却不一样):**失败**。这说明归档本身
|
||||
或者解压环节有问题,是真正要查的。
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$RestoredPath,
|
||||
[Parameter(Mandatory = $true)][string]$LivePath,
|
||||
[Parameter(Mandatory = $true)][datetime]$ArchiveTime
|
||||
)
|
||||
|
||||
$report = [pscustomobject]@{
|
||||
Restored = 0
|
||||
Matched = 0
|
||||
Stale = @()
|
||||
Changed = @()
|
||||
Extra = @()
|
||||
Missing = @()
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $RestoredPath)) { throw "恢复目标不存在:$RestoredPath" }
|
||||
|
||||
$liveItem = Get-Item -LiteralPath $LivePath -Force -ErrorAction Stop
|
||||
$restoredItem = Get-Item -LiteralPath $RestoredPath -Force -ErrorAction Stop
|
||||
|
||||
# 源本身是个文件(例如 translucenttb 的 settings.json):直接比这一个
|
||||
if (-not $liveItem.PSIsContainer) {
|
||||
if ($restoredItem.PSIsContainer) { throw "源是文件,恢复出来的却是目录:$RestoredPath" }
|
||||
$report.Restored = 1
|
||||
if ((Get-FileHash -LiteralPath $restoredItem.FullName -Algorithm SHA256).Hash -eq
|
||||
(Get-FileHash -LiteralPath $liveItem.FullName -Algorithm SHA256).Hash) {
|
||||
$report.Matched = 1
|
||||
} elseif ($liveItem.LastWriteTime -gt $ArchiveTime) {
|
||||
$report.Stale = @($restoredItem.Name)
|
||||
} else {
|
||||
$report.Changed = @($restoredItem.Name)
|
||||
}
|
||||
return $report
|
||||
}
|
||||
|
||||
$restoredRoot = $restoredItem.FullName
|
||||
$liveRoot = $liveItem.FullName
|
||||
|
||||
$restoredFiles = @(Get-ChildItem -LiteralPath $restoredRoot -Recurse -Force -File -ErrorAction SilentlyContinue)
|
||||
$report.Restored = $restoredFiles.Count
|
||||
|
||||
foreach ($file in $restoredFiles) {
|
||||
$relative = $file.FullName.Substring($restoredRoot.Length).TrimStart('\')
|
||||
$liveFile = Join-Path $liveRoot $relative
|
||||
|
||||
if (-not (Test-Path -LiteralPath $liveFile)) {
|
||||
$report.Extra += $relative
|
||||
continue
|
||||
}
|
||||
|
||||
if ((Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256).Hash -eq
|
||||
(Get-FileHash -LiteralPath $liveFile -Algorithm SHA256).Hash) {
|
||||
$report.Matched++
|
||||
continue
|
||||
}
|
||||
|
||||
# 内容不一样:先看是不是"源在归档之后动过"
|
||||
if ((Get-Item -LiteralPath $liveFile -Force).LastWriteTime -gt $ArchiveTime) {
|
||||
$report.Stale += $relative
|
||||
} else {
|
||||
$report.Changed += $relative
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($file in @(Get-ChildItem -LiteralPath $liveRoot -Recurse -Force -File -ErrorAction SilentlyContinue)) {
|
||||
$relative = $file.FullName.Substring($liveRoot.Length).TrimStart('\')
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $restoredRoot $relative))) {
|
||||
$report.Missing += $relative
|
||||
}
|
||||
}
|
||||
|
||||
return $report
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 演练
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
$rows = @()
|
||||
$failures = @()
|
||||
$checked = 0
|
||||
|
||||
foreach ($name in $Entries) {
|
||||
$entry = ConvertFrom-BackupListLine -Line $name
|
||||
if (-not $entry) { continue }
|
||||
|
||||
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $catalogPath -MaxDepth $config.CatalogMaxDepth
|
||||
|
||||
if (-not $resolved.BaseName) {
|
||||
$failures += "$name :解析不出归档名"
|
||||
$rows += [pscustomobject]@{ Entry = $name; Status = 'FAIL'; Detail = '解析不出归档名' }
|
||||
continue
|
||||
}
|
||||
|
||||
$archivePath = Join-Path $BackupDir ($resolved.BaseName + '.7z')
|
||||
if (-not (Test-Path -LiteralPath $archivePath)) {
|
||||
$rows += [pscustomobject]@{ Entry = $name; Status = 'SKIP'; Detail = "归档不存在:$($resolved.BaseName).7z" }
|
||||
continue
|
||||
}
|
||||
$archiveTime = (Get-Item -LiteralPath $archivePath).LastWriteTime
|
||||
|
||||
$sources = @($resolved.Sources)
|
||||
if ($sources.Count -eq 0) {
|
||||
$rows += [pscustomobject]@{ Entry = $name; Status = 'SKIP'; Detail = '名录解析不出源路径' }
|
||||
continue
|
||||
}
|
||||
if ($sources.Count -gt 1) {
|
||||
# 多目录条目恢复时会整包解压到每个位置,逐个对拍意义不大,默认不演练
|
||||
$rows += [pscustomobject]@{ Entry = $name; Status = 'SKIP'; Detail = "多目录条目($($sources.Count) 个源),不在演练范围内" }
|
||||
continue
|
||||
}
|
||||
|
||||
$liveSource = $sources[0].SourcePath
|
||||
if (-not (Test-Path -LiteralPath $liveSource)) {
|
||||
$rows += [pscustomobject]@{ Entry = $name; Status = 'SKIP'; Detail = "活源不存在,无法对拍:$liveSource" }
|
||||
continue
|
||||
}
|
||||
|
||||
$leaf = Split-Path -Path $liveSource -Leaf
|
||||
$restoreParent = Join-Path (Join-Path $WorkRoot 'restore') $name
|
||||
$scratchTarget = Join-Path $restoreParent $leaf
|
||||
New-Item -ItemType Directory -Path $restoreParent -Force | Out-Null
|
||||
|
||||
# 临时名录:把这个软件名指到临时目标,Restore 就会解到这里,碰不到真实目录
|
||||
$scratchCatalog = Join-Path $WorkRoot ("catalog-$name.psd1")
|
||||
$scratchList = Join-Path $WorkRoot ("list-$name.txt")
|
||||
$scratchConfig = Join-Path $WorkRoot ("config-$name.psd1")
|
||||
|
||||
[System.IO.File]::WriteAllText($scratchCatalog, "@{`n '$name' = '$scratchTarget'`n}`n", [System.Text.UTF8Encoding]::new($false))
|
||||
[System.IO.File]::WriteAllText($scratchList, "$name`n", [System.Text.UTF8Encoding]::new($false))
|
||||
[System.IO.File]::WriteAllText($scratchConfig, @"
|
||||
@{
|
||||
BackupDir = '$BackupDir'
|
||||
LogDir = '$(Join-Path $WorkRoot 'logs')'
|
||||
SoftwareCatalog = '$scratchCatalog'
|
||||
CatalogMaxDepth = $($config.CatalogMaxDepth)
|
||||
VerifyArchive = `$true
|
||||
}
|
||||
"@, [System.Text.UTF8Encoding]::new($false))
|
||||
|
||||
Write-Host ("-- 演练 {0}(归档 {1}.7z)" -f $name, $resolved.BaseName) -ForegroundColor Gray
|
||||
|
||||
# 用**子进程**跑 Restore.ps1:它结尾会 exit,子进程既不会打断演练,
|
||||
# 给出的也是真正的进程退出码(和 Pester 套件里的做法一致)。
|
||||
$restoreExit = 0
|
||||
$restoreOutput = @()
|
||||
try {
|
||||
$restoreOutput = & pwsh -NoProfile -NonInteractive -File $restoreScript `
|
||||
-BackupListPath $scratchList -ConfigPath $scratchConfig -BackupDir $BackupDir -Force 2>&1
|
||||
$restoreExit = $LASTEXITCODE
|
||||
} catch {
|
||||
$restoreExit = -1
|
||||
Write-Host (" Restore.ps1 调用失败:$_") -ForegroundColor Red
|
||||
}
|
||||
|
||||
if ($restoreExit -ne 0) {
|
||||
foreach ($line in @($restoreOutput | Select-Object -Last 12)) {
|
||||
Write-Host (" | {0}" -f $line) -ForegroundColor DarkGray
|
||||
}
|
||||
$rows += [pscustomobject]@{ Entry = $name; Status = 'FAIL'; Detail = "Restore.ps1 退出码 $restoreExit" }
|
||||
$failures += "$name :Restore.ps1 退出码 $restoreExit"
|
||||
continue
|
||||
}
|
||||
|
||||
$checked++
|
||||
$report = Compare-RestoredTree -RestoredPath $scratchTarget -LivePath $liveSource -ArchiveTime $archiveTime
|
||||
|
||||
$detail = "对拍 $($report.Matched)/$($report.Restored)"
|
||||
$status = 'PASS'
|
||||
|
||||
if ($report.Extra.Count -gt 0) {
|
||||
$status = 'FAIL'
|
||||
$detail += ";归档里有源里没有的 $($report.Extra.Count) 个文件"
|
||||
$failures += "$name :恢复出源里没有的文件(首例 $($report.Extra[0]))"
|
||||
}
|
||||
if ($report.Stale.Count -gt 0) {
|
||||
# 活源在归档之后被改过:源变了,不是归档坏了,只提示
|
||||
$detail += ";源在备份后变过 $($report.Stale.Count) 个(不算失败)"
|
||||
}
|
||||
if ($report.Changed.Count -gt 0) {
|
||||
if ($AllowChanged) {
|
||||
$detail += ";与活源不一致 $($report.Changed.Count) 个(-AllowChanged,已容忍)"
|
||||
} else {
|
||||
$status = 'FAIL'
|
||||
$detail += ";与活源不一致 $($report.Changed.Count) 个(首例 $($report.Changed[0]))"
|
||||
$failures += "$name :与活源不一致(首例 $($report.Changed[0]))"
|
||||
}
|
||||
}
|
||||
if (($report.Matched + $report.Stale.Count) -eq 0) {
|
||||
$status = 'FAIL'
|
||||
$detail += ";没有任何文件能对上(多半是空归档)"
|
||||
$failures += "$name :恢复出 0 个可对拍的文件"
|
||||
}
|
||||
if ($report.Missing.Count -gt 0) {
|
||||
# 排除规则命中的文件、以及备份之后新增的文件都会落在这里,只是提示
|
||||
$detail += ";活源另有 $($report.Missing.Count) 个文件不在归档里(排除规则/备份后新增)"
|
||||
}
|
||||
|
||||
$rows += [pscustomobject]@{ Entry = $name; Status = $status; Detail = $detail }
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 报告
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '演练结果:' -ForegroundColor Cyan
|
||||
$rows | Format-Table -AutoSize | Out-String -Width 200 | Write-Host
|
||||
|
||||
if ($failures.Count -gt 0) {
|
||||
Write-Host '失败明细:' -ForegroundColor Red
|
||||
foreach ($failure in $failures) { Write-Host " - $failure" -ForegroundColor Red }
|
||||
}
|
||||
|
||||
$passed = @($rows | Where-Object { $_.Status -eq 'PASS' }).Count
|
||||
$skipped = @($rows | Where-Object { $_.Status -eq 'SKIP' }).Count
|
||||
$failed = @($rows | Where-Object { $_.Status -eq 'FAIL' }).Count
|
||||
|
||||
Write-Host ("恢复演练:通过 {0},跳过 {1},失败 {2}(真正对拍的条目 {3})" -f $passed, $skipped, $failed, $checked) -ForegroundColor $(if ($failed -gt 0) { 'Red' } else { 'Green' })
|
||||
|
||||
if ($KeepWorkRoot) {
|
||||
Write-Host "临时工作目录保留在:$WorkRoot" -ForegroundColor Yellow
|
||||
} else {
|
||||
Remove-Item -LiteralPath $WorkRoot -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
if ($failed -gt 0) { exit 1 }
|
||||
exit 0
|
||||
@@ -0,0 +1,85 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
跑 BakNRet 的 Pester 测试套件(tests/BakNRet.Tests.ps1)。
|
||||
|
||||
.DESCRIPTION
|
||||
Pester 从哪儿来,按优先级:
|
||||
1. 仓库内的 .tools\modules(由 tools/Install-TestDependencies.ps1 放的本地副本,已 gitignore);
|
||||
2. 机器上已安装的 Pester 5+。
|
||||
|
||||
两者都没有时给出一条明确的安装命令并以退出码 2 结束,而不是抛一堆看不懂的错。
|
||||
|
||||
为什么显式要求 Pester 5.0+:3.4.0 没有 `Should -Be`,本套件会直接语法错误。
|
||||
本脚本只把 .tools\modules 临时加进 PSModulePath,**不会**去改机器上的全局模块。
|
||||
|
||||
.EXAMPLE
|
||||
pwsh -File .\tests\Run-Pester.ps1
|
||||
|
||||
.EXAMPLE
|
||||
pwsh -File .\tests\Run-Pester.ps1 -Verbosity Minimal
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[ValidateSet('Detailed', 'Normal', 'Minimal', 'None')]
|
||||
[string]$Verbosity = 'Detailed',
|
||||
|
||||
[string[]]$Tag,
|
||||
|
||||
[string]$TestPath = (Join-Path $PSScriptRoot 'BakNRet.Tests.ps1')
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$projectRoot = Split-Path -Parent $PSScriptRoot
|
||||
$localModules = Join-Path $projectRoot '.tools\modules'
|
||||
if (Test-Path -LiteralPath $localModules) {
|
||||
$env:PSModulePath = $localModules + [System.IO.Path]::PathSeparator + $env:PSModulePath
|
||||
}
|
||||
|
||||
$pester = Get-Module -ListAvailable Pester |
|
||||
Where-Object { [version]$_.Version -ge [version]'5.0.0' } |
|
||||
Sort-Object { [version]$_.Version } -Descending |
|
||||
Select-Object -First 1
|
||||
|
||||
if (-not $pester) {
|
||||
$installed = @(Get-Module -ListAvailable Pester | ForEach-Object { $_.Version.ToString() })
|
||||
|
||||
Write-Host ''
|
||||
Write-Host '找不到 Pester 5.0+,无法运行 Pester 套件。' -ForegroundColor Red
|
||||
if ($installed.Count -gt 0) {
|
||||
Write-Host ("已安装的版本:{0}(3.x 没有 Should -Be,本套件会语法错误)" -f ($installed -join '、')) -ForegroundColor Yellow
|
||||
}
|
||||
Write-Host ''
|
||||
Write-Host '两种拿到 Pester 5+ 的方式(任选其一):' -ForegroundColor Cyan
|
||||
Write-Host ' A. 只装到仓库里(推荐,不动机器上的全局模块):' -ForegroundColor Cyan
|
||||
Write-Host ' .\tools\Install-TestDependencies.ps1' -ForegroundColor Gray
|
||||
Write-Host ' B. 装到当前用户:' -ForegroundColor Cyan
|
||||
Write-Host ' Install-Module Pester -Scope CurrentUser -MinimumVersion 5.0.0' -ForegroundColor Gray
|
||||
Write-Host ''
|
||||
Write-Host '零依赖的替代方案:.\tests\Run-Tests.ps1(不需要 Pester)' -ForegroundColor Cyan
|
||||
Write-Host ''
|
||||
exit 2
|
||||
}
|
||||
|
||||
Import-Module $pester.Path -Force
|
||||
Write-Host ("Pester {0} ({1})" -f $pester.Version, $pester.ModuleBase) -ForegroundColor DarkGray
|
||||
Write-Host ("测试文件:{0}" -f $TestPath) -ForegroundColor DarkGray
|
||||
|
||||
$configuration = New-PesterConfiguration
|
||||
$configuration.Run.Path = $TestPath
|
||||
$configuration.Run.PassThru = $true
|
||||
$configuration.Run.Exit = $false
|
||||
$configuration.Output.Verbosity = $Verbosity
|
||||
if ($Tag) { $configuration.Filter.Tag = $Tag }
|
||||
|
||||
$result = Invoke-Pester -Configuration $configuration
|
||||
|
||||
Write-Host ''
|
||||
if ($result.FailedCount -gt 0) {
|
||||
Write-Host ("Pester 测试通过 {0} 项,失败 {1} 项,跳过 {2} 项" -f $result.PassedCount, $result.FailedCount, $result.SkippedCount) -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host ("Pester 测试全部通过:{0} 项(跳过 {1} 项)" -f $result.PassedCount, $result.SkippedCount) -ForegroundColor Green
|
||||
exit 0
|
||||
@@ -89,6 +89,81 @@ Test-Case '@ 标记跟在排除表后面' {
|
||||
Assert-Equal 2 $r.ExcludePatterns.Count
|
||||
Assert-Equal 'encrypt' $r.Flags[0]
|
||||
}
|
||||
Test-Case ':+ 追加目录(可多个、位置无关)' {
|
||||
$r = ConvertFrom-BackupListLine -Line 'MyApp :+ D:\a :+ D:\b'
|
||||
Assert-Equal 'MyApp' $r.Path
|
||||
Assert-Equal 2 $r.AddedPaths.Count
|
||||
Assert-Equal 'D:\a' $r.AddedPaths[0]
|
||||
Assert-Equal 'D:\b' $r.AddedPaths[1]
|
||||
Assert-Equal 0 $r.ExcludePatterns.Count
|
||||
}
|
||||
|
||||
Test-Case ':- 排除,与 :+ 混用且顺序任意' {
|
||||
$r = ConvertFrom-BackupListLine -Line 'MyApp :- logs\ :+ D:\a :- !*Cache'
|
||||
Assert-Equal 'MyApp' $r.Path
|
||||
Assert-Equal 1 $r.AddedPaths.Count
|
||||
Assert-Equal 2 $r.ExcludePatterns.Count
|
||||
Assert-Equal 'logs\' $r.ExcludePatterns[0]
|
||||
Assert-Equal '!*Cache' $r.ExcludePatterns[1]
|
||||
}
|
||||
|
||||
Test-Case ':: 与 :- 等价' {
|
||||
$a = ConvertFrom-BackupListLine -Line 'X :: logs\'
|
||||
$b = ConvertFrom-BackupListLine -Line 'X :- logs\'
|
||||
Assert-Equal $a.ExcludePatterns[0] $b.ExcludePatterns[0]
|
||||
Assert-Equal 'logs\' $b.ExcludePatterns[0]
|
||||
}
|
||||
|
||||
Test-Case ':+ 段里的 @标记也能被摘出' {
|
||||
$r = ConvertFrom-BackupListLine -Line 'MyApp :+ D:\a @encrypt'
|
||||
Assert-Equal 'MyApp' $r.Path
|
||||
Assert-Equal 'D:\a' $r.AddedPaths[0]
|
||||
Assert-Equal 'encrypt' $r.Flags[0]
|
||||
}
|
||||
|
||||
Test-Case '盘符里的单个冒号不被误当分隔符' {
|
||||
$r = ConvertFrom-BackupListLine -Line 'C:\Programs\Foo'
|
||||
Assert-Equal 'C:\Programs\Foo' $r.Path
|
||||
Assert-Equal 0 $r.AddedPaths.Count
|
||||
Assert-Equal 0 $r.ExcludePatterns.Count
|
||||
}
|
||||
|
||||
Test-Case '名录 Dirs 数组:一个软件多个目录' {
|
||||
$dirsSandbox = Join-Path $env:TEMP ("baknret-dirs-" + [guid]::NewGuid().ToString('N').Substring(0, 6))
|
||||
New-Item -ItemType Directory -Path (Join-Path $dirsSandbox 'App') -Force | Out-Null
|
||||
New-Item -ItemType Directory -Path (Join-Path $dirsSandbox 'Config') -Force | Out-Null
|
||||
$catPath = Join-Path $dirsSandbox 'c.psd1'
|
||||
$content = "@{`n MyApp = @{ Dirs = @('$dirsSandbox\App', '$dirsSandbox\Config') }`n}`n"
|
||||
[System.IO.File]::WriteAllText($catPath, $content, [System.Text.UTF8Encoding]::new($false))
|
||||
|
||||
$catalog = Get-SoftwareCatalog -Path $catPath
|
||||
Assert-Equal 'Multi' $catalog['MyApp'].Kind
|
||||
Assert-Equal 2 $catalog['MyApp'].Dirs.Count
|
||||
|
||||
$item = ConvertFrom-BackupListLine -Line 'MyApp'
|
||||
$resolved = Resolve-BackupEntry -Entry $item -CatalogPath $catPath
|
||||
Assert-Equal 'MyApp' $resolved.BaseName
|
||||
Assert-Equal 2 $resolved.Sources.Count '每个目录都该成为一条源'
|
||||
Assert-Equal 'App' $resolved.Sources[0].RelativePaths[0]
|
||||
Assert-Equal 'Config' $resolved.Sources[1].RelativePaths[0]
|
||||
Remove-Item -LiteralPath $dirsSandbox -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
Test-Case '清单 :+ 追加的目录叠加在名录目录之后' {
|
||||
$dirsSandbox = Join-Path $env:TEMP ("baknret-adds-" + [guid]::NewGuid().ToString('N').Substring(0, 6))
|
||||
New-Item -ItemType Directory -Path (Join-Path $dirsSandbox 'App') -Force | Out-Null
|
||||
New-Item -ItemType Directory -Path (Join-Path $dirsSandbox 'Extra') -Force | Out-Null
|
||||
$catPath = Join-Path $dirsSandbox 'c.psd1'
|
||||
$content = "@{`n MyApp = '$dirsSandbox\App'`n}`n"
|
||||
[System.IO.File]::WriteAllText($catPath, $content, [System.Text.UTF8Encoding]::new($false))
|
||||
|
||||
$item = ConvertFrom-BackupListLine -Line "MyApp :+ $dirsSandbox\Extra"
|
||||
$resolved = Resolve-BackupEntry -Entry $item -CatalogPath $catPath
|
||||
Assert-Equal 2 $resolved.Sources.Count '名录 1 个 + 追加 1 个'
|
||||
Assert-Equal 'App' $resolved.Sources[0].RelativePaths[0]
|
||||
Assert-Equal 'Extra' $resolved.Sources[1].RelativePaths[0]
|
||||
Remove-Item -LiteralPath $dirsSandbox -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
Write-Host "`n== 归档命名 ==" -ForegroundColor Cyan
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
把测试用的 Pester 5 装进仓库内的 .tools\modules(不动机器上的全局模块)。
|
||||
|
||||
.DESCRIPTION
|
||||
tests\BakNRet.Tests.ps1 需要 Pester 5.0+。本机常见的坑是:
|
||||
* Windows 自带的是 Pester 3.4.0,没有 `Should -Be`,套件会语法错误;
|
||||
* 直接 Install-Module 会把 5.x 装到全局,可能影响机器上别的、按 3.x 语法写的脚本。
|
||||
|
||||
所以这里用 Save-Module 把指定版本下载到仓库内的 .tools\modules,
|
||||
tests\Run-Pester.ps1 会自动把该目录加进 PSModulePath。
|
||||
.tools\ 已进 .gitignore,不会污染版本库。
|
||||
|
||||
.EXAMPLE
|
||||
pwsh -File .\tools\Install-TestDependencies.ps1
|
||||
|
||||
.EXAMPLE
|
||||
pwsh -File .\tools\Install-TestDependencies.ps1 -PesterVersion 5.9.1 -Force
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[version]$PesterVersion = [version]'5.9.1',
|
||||
[switch]$Force
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$ProgressPreference = 'SilentlyContinue'
|
||||
|
||||
$projectRoot = Split-Path -Parent $PSScriptRoot
|
||||
$target = Join-Path $projectRoot '.tools\modules'
|
||||
$installed = Join-Path $target ("Pester\{0}" -f $PesterVersion)
|
||||
|
||||
Write-Host ''
|
||||
Write-Host ("目标目录 : {0}" -f $target)
|
||||
Write-Host ("Pester : {0}" -f $PesterVersion)
|
||||
Write-Host ''
|
||||
|
||||
if ((Test-Path -LiteralPath $installed) -and -not $Force) {
|
||||
Write-Host "已经装好了,无需重复下载(要重装加 -Force)。" -ForegroundColor Green
|
||||
exit 0
|
||||
}
|
||||
|
||||
if ($Force -and (Test-Path -LiteralPath (Join-Path $target 'Pester'))) {
|
||||
Remove-Item -LiteralPath (Join-Path $target 'Pester') -Recurse -Force
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $target -Force | Out-Null
|
||||
|
||||
if (-not (Get-Command Save-Module -ErrorAction SilentlyContinue)) {
|
||||
Write-Error '当前环境没有 Save-Module(需要 PowerShellGet 2.x)。请改用:Install-Module Pester -Scope CurrentUser -MinimumVersion 5.0.0'
|
||||
exit 1
|
||||
}
|
||||
|
||||
try {
|
||||
Save-Module -Name Pester -RequiredVersion $PesterVersion -Path $target -Force -ErrorAction Stop
|
||||
} catch {
|
||||
Write-Error "下载失败(多半是访问不到 PSGallery):$_"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$module = Get-Module -ListAvailable Pester | Where-Object { [version]$_.Version -eq $PesterVersion }
|
||||
if (-not $module) {
|
||||
Write-Error "下载结束但找不到 Pester $PesterVersion,请检查 $target。"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host ("已安装:Pester {0} -> {1}" -f $module.Version, $module.ModuleBase) -ForegroundColor Green
|
||||
Write-Host '现在可以跑:.\tests\Run-Pester.ps1' -ForegroundColor Cyan
|
||||
Write-Host ''
|
||||
exit 0
|
||||
Reference in new issue
Block a user