refactor: 公共面补 BakNRet 前缀,产品名大小写全仓统一

16 个没有前缀的公共函数补上 BakNRet(Write-Log → Write-BakNRetLog、Resolve-BackupEntry →
Resolve-BakNRetBackupEntry、Find-ChildDirectoryByName → Find-BakNRetChildDirectoryByName 等),
另外把全仓的 Baknret 统一成 BakNRet(47 个文件、940 处、65 个定义文件重命名)。

这不是审美问题:静态分析直接拓出一条实据 —— Write-Log 与本机某个已装模块导出的命令
**重名**(PSAvoidOverwritingBuiltInCmdlets),而重名的后果是导入两个模块时有一方的命令被
静默遮蔽。补前缀正是这条规则的解法,改名后它归零。

为什么敢做这个规模:PowerShell 的函数名解析大小写不敏感,所以 Baknret → BakNRet 在功能
上是零风险;真正要验证的是 16 个补前缀的调用点,而 276 个断言几乎覆盖了每个函数。另外
"名字与文件名一致"这条不变式有断言盯着(加载器点源的文件集合 vs 磁盘)。

踩到并记下的坑:Windows 文件系统大小写不敏感,所以**只改大小写**的重命名会被 Move-Item
当成同一个文件而静默跳过 —— 同一批里同时改了名字的那 16 个文件却成功了,于是"看起来能跑"。
最后用"先移到临时名、再移到目标名"的两步走解决,判断与替换全部改用显式大小写敏感的形式
(-creplace / -cmatch)。

顺带把名录指纹缓存从 MD5 换成 SHA256(PSAvoidUsingBrokenHashAlgorithms):它只是缓存键,
没有兼容负担。

验收:test.ps1 9/9 全绿(7 与 5.1)、100 个文件两版解析零错、276 个断言全过、
构建工具仍能合回单文件(3300 行)。
This commit is contained in:
Shuery committed 2026-09-27 09:56:36 +08:00
1 parent 187d2759fd
commit 42f02d0eca
84 files changed
+1043 -1043

No files matched your search

+101 -101
View File
@@ -91,9 +91,9 @@ if (-not (Test-Path -LiteralPath $modulePath)) {
} }
Import-Module $modulePath -Force Import-Module $modulePath -Force
if ($PSBoundParameters.ContainsKey('Verbose')) { Set-BaknretDebug } if ($PSBoundParameters.ContainsKey('Verbose')) { Set-BakNRetDebug }
$script:Config = Get-BaknretConfig -Path $ConfigPath $script:Config = Get-BakNRetConfig -Path $ConfigPath
function Resolve-ConfigPath { function Resolve-ConfigPath {
param([string]$Path, [string]$Default) param([string]$Path, [string]$Default)
@@ -107,28 +107,28 @@ function Resolve-ConfigPath {
if (-not $BackupDir) { $BackupDir = Resolve-ConfigPath -Path $null -Default $script:Config.BackupDir } if (-not $BackupDir) { $BackupDir = Resolve-ConfigPath -Path $null -Default $script:Config.BackupDir }
$logDir = Resolve-ConfigPath -Path $null -Default $script:Config.LogDir $logDir = Resolve-ConfigPath -Path $null -Default $script:Config.LogDir
$snapshotDir = Resolve-ConfigPath -Path $null -Default $script:Config.SnapshotDir $snapshotDir = Resolve-ConfigPath -Path $null -Default $script:Config.SnapshotDir
$catalogPath = Resolve-CatalogPath -Configured $script:Config.SoftwareCatalog -Root $PSScriptRoot $catalogPath = Resolve-BakNRetCatalogPath -Configured $script:Config.SoftwareCatalog -Root $PSScriptRoot
$manifestPath = Join-Path $BackupDir 'manifest.json' $manifestPath = Join-Path $BackupDir 'manifest.json'
$logPath = Start-BaknretLog -Directory $logDir -Prefix 'backup' $logPath = Start-BakNRetLog -Directory $logDir -Prefix 'backup'
Write-Log "日志文件:$logPath" Write-BakNRetLog "日志文件:$logPath"
Write-Log "备份目录:$BackupDir" Write-BakNRetLog "备份目录:$BackupDir"
Write-Log ("软件名录:{0}{1}" -f $catalogPath, $(if (Test-Path -LiteralPath $catalogPath) { '' } else { '(不存在,将只支持字面路径)' })) Write-BakNRetLog ("软件名录:{0}{1}" -f $catalogPath, $(if (Test-Path -LiteralPath $catalogPath) { '' } else { '(不存在,将只支持字面路径)' }))
if (-not (Test-Administrator)) { if (-not (Test-BakNRetAdministrator)) {
Write-Log '建议以管理员身份运行以获取完整的目录访问权限' -Level WARN Write-BakNRetLog '建议以管理员身份运行以获取完整的目录访问权限' -Level WARN
} }
# 同一份备份目录同一时间只允许一个进程操作(见 BakNRet 模块的「运行锁」一节)。 # 同一份备份目录同一时间只允许一个进程操作(见 BakNRet 模块的「运行锁」一节)。
# -DryRun 不取锁:它一个字节都不写,没必要被正在跑的备份挡在外面。 # -DryRun 不取锁:它一个字节都不写,没必要被正在跑的备份挡在外面。
$runLock = $null $runLock = $null
if (-not $DryRun) { if (-not $DryRun) {
$runLock = Enter-BaknretRunLock -Directory $BackupDir $runLock = Enter-BakNRetRunLock -Directory $BackupDir
if (-not $runLock) { if (-not $runLock) {
Write-Log ("另一次运行正在进行中(锁文件:{0},里面写明了持有者)。本次不执行。" -f (Get-BaknretRunLockPath -Directory $BackupDir)) -Level ERROR Write-BakNRetLog ("另一次运行正在进行中(锁文件:{0},里面写明了持有者)。本次不执行。" -f (Get-BakNRetRunLockPath -Directory $BackupDir)) -Level ERROR
Stop-BaknretLog Stop-BakNRetLog
exit 1 exit 1
} }
Write-Log ("已取得运行锁:{0}" -f (Get-BaknretRunLockPath -Directory $BackupDir)) -Level DEBUG Write-BakNRetLog ("已取得运行锁:{0}" -f (Get-BakNRetRunLockPath -Directory $BackupDir)) -Level DEBUG
} }
# ============================================================================ # ============================================================================
@@ -137,7 +137,7 @@ if (-not $DryRun) {
if (-not (Test-Path -LiteralPath $BackupDir)) { if (-not (Test-Path -LiteralPath $BackupDir)) {
New-Item -ItemType Directory -Path $BackupDir -Force | Out-Null New-Item -ItemType Directory -Path $BackupDir -Force | Out-Null
Write-Log "创建备份目录: $BackupDir" -Level DEBUG Write-BakNRetLog "创建备份目录: $BackupDir" -Level DEBUG
} }
if (-not (Test-Path -LiteralPath $BackupListPath)) { if (-not (Test-Path -LiteralPath $BackupListPath)) {
@@ -147,15 +147,15 @@ if (-not (Test-Path -LiteralPath $BackupListPath)) {
"# %UserProfile%\.ssh :encrypt`n" + "# %UserProfile%\.ssh :encrypt`n" +
"# 完整语法见 README 与 BackupList.txt 自身的注释。`n" "# 完整语法见 README 与 BackupList.txt 自身的注释。`n"
[System.IO.File]::WriteAllText($BackupListPath, $template, [System.Text.UTF8Encoding]::new($true)) [System.IO.File]::WriteAllText($BackupListPath, $template, [System.Text.UTF8Encoding]::new($true))
Write-Log '模板 BackupList.txt 已创建,请编辑后重试。' -Level INFO Write-BakNRetLog '模板 BackupList.txt 已创建,请编辑后重试。' -Level INFO
Stop-BaknretLog Stop-BakNRetLog
exit 0 exit 0
} }
$tool = Resolve-CompressionTool $tool = Resolve-BakNRetCompressionTool
if (-not $tool) { if (-not $tool) {
Write-Log '没有找到可用的压缩工具。' -Level ERROR Write-BakNRetLog '没有找到可用的压缩工具。' -Level ERROR
Stop-BaknretLog Stop-BakNRetLog
exit 1 exit 1
} }
@@ -164,13 +164,13 @@ $toolVersion = try {
if ($info.ProductVersion) { $info.ProductVersion } elseif ($info.FileVersion) { $info.FileVersion } else { $null } if ($info.ProductVersion) { $info.ProductVersion } elseif ($info.FileVersion) { $info.FileVersion } else { $null }
} }
catch { $null } catch { $null }
Write-Log ("压缩工具:{0}{1}" -f $tool.Name, $(if ($toolVersion) { "($toolVersion)" } else { '' })) Write-BakNRetLog ("压缩工具:{0}{1}" -f $tool.Name, $(if ($toolVersion) { "($toolVersion)" } else { '' }))
$manifest = Read-BaknretManifest -Path $manifestPath $manifest = Read-BakNRetManifest -Path $manifestPath
$manifest.compressor = [pscustomobject]@{ name = $tool.Name; command = $tool.Command; extension = $tool.Extension; version = $toolVersion } $manifest.compressor = [pscustomobject]@{ name = $tool.Name; command = $tool.Command; extension = $tool.Extension; version = $toolVersion }
$passwordFile = if ($KeyFile) { $KeyFile } else { $script:Config.Encryption.PasswordFile } $passwordFile = if ($KeyFile) { $KeyFile } else { $script:Config.Encryption.PasswordFile }
$password = Get-BaknretPassword -PasswordFile $passwordFile $password = Get-BakNRetPassword -PasswordFile $passwordFile
$encryptAll = [bool]$script:Config.Encryption.Enabled $encryptAll = [bool]$script:Config.Encryption.Enabled
$showToolOutput = (-not $QuietTool) -and ($script:Config.ToolOutput -ne 'quiet') $showToolOutput = (-not $QuietTool) -and ($script:Config.ToolOutput -ne 'quiet')
$toolQuietArgument = if ($showToolOutput) { @() } else { @('-bso0', '-bsp0') } $toolQuietArgument = if ($showToolOutput) { @() } else { @('-bso0', '-bsp0') }
@@ -181,11 +181,11 @@ $processed = 0; $skipped = 0; $failed = 0; $planned = 0
$securityErrorCount = 0 # 有条目"安全描述符里有读不到的对象" $securityErrorCount = 0 # 有条目"安全描述符里有读不到的对象"
$securityFailed = 0 # 有条目"安全描述符完全没存下来" $securityFailed = 0 # 有条目"安全描述符完全没存下来"
$failures = @() $failures = @()
$freeSpaceGB = Get-BaknretFreeSpaceGB -Path $BackupDir $freeSpaceGB = Get-BakNRetFreeSpaceGB -Path $BackupDir
if ($freeSpaceGB -ge 0) { if ($freeSpaceGB -ge 0) {
Write-Log ("备份目录所在卷剩余空间:{0} GB" -f $freeSpaceGB) Write-BakNRetLog ("备份目录所在卷剩余空间:{0} GB" -f $freeSpaceGB)
if ($freeSpaceGB -lt $script:Config.MinFreeSpaceGB) { if ($freeSpaceGB -lt $script:Config.MinFreeSpaceGB) {
Write-Log ("剩余空间低于阈值 {0} GB,大条目可能失败" -f $script:Config.MinFreeSpaceGB) -Level WARN Write-BakNRetLog ("剩余空间低于阈值 {0} GB,大条目可能失败" -f $script:Config.MinFreeSpaceGB) -Level WARN
} }
} }
@@ -282,7 +282,7 @@ function Save-ItemRecord {
# 压缩 + 校验 + 原子替换;返回 @{ Ok; ExitCode; Warnings; Reason } # 压缩 + 校验 + 原子替换;返回 @{ Ok; ExitCode; Warnings; Reason }
# #
# 归档内容由调用方决定:它已经用 New-BaknretArchiveStaging 把每个归档项按"归档内的名字" # 归档内容由调用方决定:它已经用 New-BakNRetArchiveStaging 把每个归档项按"归档内的名字"
# 挂进了 $StagingRoot(目录走 junction、文件走硬链接/复制),所以这里只做三件事: # 挂进了 $StagingRoot(目录走 junction、文件走硬链接/复制),所以这里只做三件事:
# 1. 以暂存目录为工作目录调用压缩工具,把项名加进去; # 1. 以暂存目录为工作目录调用压缩工具,把项名加进去;
# 2. 用 7z t 校验,并核对多 Slot 条目的每个顶层名字都真的在包里; # 2. 用 7z t 校验,并核对多 Slot 条目的每个顶层名字都真的在包里;
@@ -376,11 +376,11 @@ function Invoke-BackupItem {
Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue
return [pscustomobject]@{ Ok = $false; ExitCode = $verifyCode; Warnings = $false; Reason = "归档校验失败(7z t 退出码 $verifyCode),已丢弃临时文件" } return [pscustomobject]@{ Ok = $false; ExitCode = $verifyCode; Warnings = $false; Reason = "归档校验失败(7z t 退出码 $verifyCode),已丢弃临时文件" }
} }
Write-Log '归档校验通过(7z t)' -Level DEBUG Write-BakNRetLog '归档校验通过(7z t)' -Level DEBUG
# 多个 Slot / 追加项时确认每一个顶层名字都真的进了归档:7z 的"警告"可能只体现在某一部分上 # 多个 Slot / 追加项时确认每一个顶层名字都真的进了归档:7z 的"警告"可能只体现在某一部分上
if ($SourceItems.Count -gt 1) { if ($SourceItems.Count -gt 1) {
$listed = @(Get-ArchiveTopLevelNames -ArchivePath $tempPath -SevenZip $tool.Command -Password $(if ($UseEncryption) { $password } else { $null })) $listed = @(Get-BakNRetArchiveTopLevelNames -ArchivePath $tempPath -SevenZip $tool.Command -Password $(if ($UseEncryption) { $password } else { $null }))
if ($listed.Count -gt 0) { if ($listed.Count -gt 0) {
$expected = @($SourceItems | ForEach-Object { [string]$_.TopName } | Select-Object -Unique) $expected = @($SourceItems | ForEach-Object { [string]$_.TopName } | Select-Object -Unique)
$absent = @($expected | Where-Object { $_ -notin $listed }) $absent = @($expected | Where-Object { $_ -notin $listed })
@@ -406,7 +406,7 @@ function Invoke-BackupItem {
} }
} }
Move-BaknretArchiveIntoPlace -TempPath $tempPath -DestinationPath $FinalPath Move-BakNRetArchiveIntoPlace -TempPath $tempPath -DestinationPath $FinalPath
return [pscustomobject]@{ Ok = $true; ExitCode = 0; Warnings = $warnings; Reason = $null } return [pscustomobject]@{ Ok = $true; ExitCode = 0; Warnings = $warnings; Reason = $null }
} }
catch { catch {
@@ -437,7 +437,7 @@ foreach ($planLine in $lines) {
if (-not $planItem) { continue } if (-not $planItem) { continue }
$planDisplayPath = $planItem.Path $planDisplayPath = $planItem.Path
$planResolved = Resolve-BackupEntry -Entry $planItem -CatalogPath $catalogPath -MaxDepth $script:Config.CatalogMaxDepth $planResolved = Resolve-BakNRetBackupEntry -Entry $planItem -CatalogPath $catalogPath -MaxDepth $script:Config.CatalogMaxDepth
if (-not $planResolved.BaseName) { continue } if (-not $planResolved.BaseName) { continue }
if (-not (Test-ItemSelected -DisplayPath $planDisplayPath -BaseName $planResolved.BaseName)) { continue } if (-not (Test-ItemSelected -DisplayPath $planDisplayPath -BaseName $planResolved.BaseName)) { continue }
if ($planResolved.Direction -eq 'restore') { continue } if ($planResolved.Direction -eq 'restore') { continue }
@@ -450,7 +450,7 @@ foreach ($planLine in $lines) {
$planSourceFiles = 0 $planSourceFiles = 0
$planLatest = $null $planLatest = $null
foreach ($planSource in $planItems) { foreach ($planSource in $planItems) {
$planSummary = Get-FolderSummary -FolderPath $planSource.RealPath $planSummary = Get-BakNRetFolderSummary -FolderPath $planSource.RealPath
$planSourceBytes += [int64]$planSummary.TotalSize $planSourceBytes += [int64]$planSummary.TotalSize
$planSourceFiles += [int]$planSummary.FileCount $planSourceFiles += [int]$planSummary.FileCount
if ($planSummary.LatestModifiedTime -and (-not $planLatest -or $planSummary.LatestModifiedTime -gt $planLatest)) { if ($planSummary.LatestModifiedTime -and (-not $planLatest -or $planSummary.LatestModifiedTime -gt $planLatest)) {
@@ -487,10 +487,10 @@ foreach ($planLine in $lines) {
} }
} }
$freeNowGB = Get-BaknretFreeSpaceGB -Path $BackupDir $freeNowGB = Get-BakNRetFreeSpaceGB -Path $BackupDir
if ($spacePlan.Count -eq 0) { if ($spacePlan.Count -eq 0) {
Write-Log '备份前空间预估:本次没有需要重打的条目(源未更新或源不存在),不会写入新归档' -Level INFO Write-BakNRetLog '备份前空间预估:本次没有需要重打的条目(源未更新或源不存在),不会写入新归档' -Level INFO
} }
else { else {
$spacePeak = [double]0 $spacePeak = [double]0
@@ -504,33 +504,33 @@ else {
$estimateGB = ((($spacePlan | Measure-Object -Property Estimate -Sum).Sum)) / 1GB $estimateGB = ((($spacePlan | Measure-Object -Property Estimate -Sum).Sum)) / 1GB
$existingGB = ((($spacePlan | Measure-Object -Property Existing -Sum).Sum)) / 1GB $existingGB = ((($spacePlan | Measure-Object -Property Existing -Sum).Sum)) / 1GB
Write-Log '==== 备份前空间预估(只读)====' -Level INFO Write-BakNRetLog '==== 备份前空间预估(只读)====' -Level INFO
Write-Log (" 目标卷可用空间:{0} GB" -f $freeNowGB) Write-BakNRetLog (" 目标卷可用空间:{0} GB" -f $freeNowGB)
Write-Log (" 本次要重打 {0} 个条目(另有 {1} 个源未更新会跳过、{2} 个源不存在)" -f $spacePlan.Count, $spaceSkipped, $spaceNoSource) Write-BakNRetLog (" 本次要重打 {0} 个条目(另有 {1} 个源未更新会跳过、{2} 个源不存在)" -f $spacePlan.Count, $spaceSkipped, $spaceNoSource)
Write-Log (" 新归档合计约 {0} GB;其中会替换掉的旧归档 {1} GB" -f [math]::Round($estimateGB, 2), [math]::Round($existingGB, 2)) Write-BakNRetLog (" 新归档合计约 {0} GB;其中会替换掉的旧归档 {1} GB" -f [math]::Round($estimateGB, 2), [math]::Round($existingGB, 2))
foreach ($plan in ($spacePlan | Sort-Object Estimate -Descending | Select-Object -First 15)) { foreach ($plan in ($spacePlan | Sort-Object Estimate -Descending | Select-Object -First 15)) {
Write-Log (" - {0,-22} 源 {1,8:N1} MB / {2,6} 文件 现有 {3,7:N1} MB 预估 {4,7:N1} MB" -f ` Write-BakNRetLog (" - {0,-22} 源 {1,8:N1} MB / {2,6} 文件 现有 {3,7:N1} MB 预估 {4,7:N1} MB" -f `
$plan.Name, ($plan.SourceBytes / 1MB), $plan.Files, ($plan.Existing / 1MB), ($plan.Estimate / 1MB)) $plan.Name, ($plan.SourceBytes / 1MB), $plan.Files, ($plan.Existing / 1MB), ($plan.Estimate / 1MB))
} }
if ($spacePlan.Count -gt 15) { if ($spacePlan.Count -gt 15) {
Write-Log (" …… 另有 {0} 个条目未逐条列出" -f ($spacePlan.Count - 15)) Write-BakNRetLog (" …… 另有 {0} 个条目未逐条列出" -f ($spacePlan.Count - 15))
} }
Write-Log (" 预计峰值新增占用:{0} GB(全程净增量 {1} GB)" -f [math]::Round($peakGB, 2), [math]::Round($netGB, 2)) Write-BakNRetLog (" 预计峰值新增占用:{0} GB(全程净增量 {1} GB)" -f [math]::Round($peakGB, 2), [math]::Round($netGB, 2))
if ($freeNowGB -lt 0) { if ($freeNowGB -lt 0) {
Write-Log ' 结论:读不到目标卷可用空间,请自行确认是否够用' -Level WARN Write-BakNRetLog ' 结论:读不到目标卷可用空间,请自行确认是否够用' -Level WARN
} }
elseif ($peakGB -le $freeNowGB) { elseif ($peakGB -le $freeNowGB) {
Write-Log (" 结论:空间足够(预计用 {0} GB / 可用 {1} GB)" -f [math]::Round($peakGB, 2), $freeNowGB) -Level INFO Write-BakNRetLog (" 结论:空间足够(预计用 {0} GB / 可用 {1} GB)" -f [math]::Round($peakGB, 2), $freeNowGB) -Level INFO
} }
else { else {
Write-Log (" 结论:空间可能不够!预计需要 {0} GB,可用只有 {1} GB,差 {2} GB" -f ` Write-BakNRetLog (" 结论:空间可能不够!预计需要 {0} GB,可用只有 {1} GB,差 {2} GB" -f `
[math]::Round($peakGB, 2), $freeNowGB, [math]::Round($peakGB - $freeNowGB, 2)) -Level WARN [math]::Round($peakGB, 2), $freeNowGB, [math]::Round($peakGB - $freeNowGB, 2)) -Level WARN
Write-Log ' 仍会继续执行:真正放不下的条目会被逐条目守卫跳过。建议先腾空间,或用 -Only / -Skip 分批备份。' -Level WARN Write-BakNRetLog ' 仍会继续执行:真正放不下的条目会被逐条目守卫跳过。建议先腾空间,或用 -Only / -Skip 分批备份。' -Level WARN
} }
Write-Log '============================' -Level INFO Write-BakNRetLog '============================' -Level INFO
} }
# ============================================================================ # ============================================================================
@@ -542,7 +542,7 @@ foreach ($line in $lines) {
if (-not $item) { continue } if (-not $item) { continue }
$displayPath = $item.Path $displayPath = $item.Path
$resolved = Resolve-BackupEntry -Entry $item -CatalogPath $catalogPath -MaxDepth $script:Config.CatalogMaxDepth $resolved = Resolve-BakNRetBackupEntry -Entry $item -CatalogPath $catalogPath -MaxDepth $script:Config.CatalogMaxDepth
if (-not $resolved.BaseName) { if (-not $resolved.BaseName) {
$record = New-ItemRecord -BaseName ('raw:' + $displayPath) -Source $displayPath -ResolvedSource $displayPath -Phase 'parse' $record = New-ItemRecord -BaseName ('raw:' + $displayPath) -Source $displayPath -ResolvedSource $displayPath -Phase 'parse'
@@ -555,7 +555,7 @@ foreach ($line in $lines) {
$sourcePath = [Environment]::ExpandEnvironmentVariables($displayPath) $sourcePath = [Environment]::ExpandEnvironmentVariables($displayPath)
if (-not (Test-ItemSelected -DisplayPath $displayPath -BaseName $baseName)) { if (-not (Test-ItemSelected -DisplayPath $displayPath -BaseName $baseName)) {
Write-Log "跳过(未选中): $displayPath" -Level DEBUG Write-BakNRetLog "跳过(未选中): $displayPath" -Level DEBUG
continue continue
} }
@@ -564,7 +564,7 @@ foreach ($line in $lines) {
# 备份列表里写重了会生成两个同名归档、互相覆盖 —— 直接报错,不猜。 # 备份列表里写重了会生成两个同名归档、互相覆盖 —— 直接报错,不猜。
if ($seenBaseNames.ContainsKey($baseName)) { if ($seenBaseNames.ContainsKey($baseName)) {
$reason = "归档名 '$baseName' 与清单中的 '$($seenBaseNames[$baseName])' 重复(由 '$displayPath' 生成),两者会互相覆盖" $reason = "归档名 '$baseName' 与清单中的 '$($seenBaseNames[$baseName])' 重复(由 '$displayPath' 生成),两者会互相覆盖"
Write-Log "失败: $displayPath,$reason" -Level ERROR Write-BakNRetLog "失败: $displayPath,$reason" -Level ERROR
$record = New-ItemRecord -BaseName $baseName -Source $displayPath -ResolvedSource $sourcePath -Phase 'backup' $record = New-ItemRecord -BaseName $baseName -Source $displayPath -ResolvedSource $sourcePath -Phase 'backup'
Save-ItemRecord -Record $record -Action 'failed' -Reason $reason | Out-Null Save-ItemRecord -Record $record -Action 'failed' -Reason $reason | Out-Null
$failed++; $failures += $displayPath $failed++; $failures += $displayPath
@@ -573,7 +573,7 @@ foreach ($line in $lines) {
$seenBaseNames[$baseName] = $displayPath $seenBaseNames[$baseName] = $displayPath
if ($resolved.Direction -eq 'restore') { if ($resolved.Direction -eq 'restore') {
Write-Log "跳过(行首 -,仅恢复): $displayPath" -Level INFO Write-BakNRetLog "跳过(行首 -,仅恢复): $displayPath" -Level INFO
continue continue
} }
@@ -584,12 +584,12 @@ foreach ($line in $lines) {
# root= 是历史标记:包内的一层目录现在由名录里的 Slot 决定,这里只告警不静默忽略。 # root= 是历史标记:包内的一层目录现在由名录里的 Slot 决定,这里只告警不静默忽略。
if (@($item.Flags | Where-Object { $_ -like 'root=*' }).Count -gt 0) { if (@($item.Flags | Where-Object { $_ -like 'root=*' }).Count -gt 0) {
Write-Log "警告: $displayPath 使用了 root= 标记。归档内的一层目录现在由 Slot 决定,该标记已废弃,本次忽略" -Level WARN Write-BakNRetLog "警告: $displayPath 使用了 root= 标记。归档内的一层目录现在由 Slot 决定,该标记已废弃,本次忽略" -Level WARN
} }
# 归档内路径冲突:明确失败,绝不把两块内容静默搅进同一棵树 # 归档内路径冲突:明确失败,绝不把两块内容静默搅进同一棵树
if ($resolved.Blocking) { if ($resolved.Blocking) {
Write-Log "失败: $displayPath,$($resolved.Blocking)" -Level ERROR Write-BakNRetLog "失败: $displayPath,$($resolved.Blocking)" -Level ERROR
Save-ItemRecord -Record $record -Action 'failed' -Reason $resolved.Blocking | Out-Null Save-ItemRecord -Record $record -Action 'failed' -Reason $resolved.Blocking | Out-Null
$failed++; $failures += $displayPath $failed++; $failures += $displayPath
continue continue
@@ -604,7 +604,7 @@ foreach ($line in $lines) {
else { else {
$planCatalogExcludes = @($resolved.Items | ForEach-Object { @($_.Exclude) } | Where-Object { $_ } | Select-Object -Unique) $planCatalogExcludes = @($resolved.Items | ForEach-Object { @($_.Exclude) } | Where-Object { $_ } | Select-Object -Unique)
} }
Write-BackupEntryPlan -Resolved $resolved -DisplayPath $displayPath ` Write-BakNRetBackupEntryPlan -Resolved $resolved -DisplayPath $displayPath `
-ListExcludes $planListExcludes -CatalogExcludes $planCatalogExcludes ` -ListExcludes $planListExcludes -CatalogExcludes $planCatalogExcludes `
-ConfigExcludes @($script:Config.DefaultExcludes) -Comment $item.Comment -ConfigExcludes @($script:Config.DefaultExcludes) -Comment $item.Comment
@@ -613,26 +613,26 @@ foreach ($line in $lines) {
# 但 Items 是给出的(恢复端要靠它把内容还原回原位),备份端由下面的存在性检查统一处理。 # 但 Items 是给出的(恢复端要靠它把内容还原回原位),备份端由下面的存在性检查统一处理。
if ($resolved.Items.Count -eq 0) { if ($resolved.Items.Count -eq 0) {
$reason = if ($resolved.Error) { $resolved.Error } else { '解析不出任何源路径' } $reason = if ($resolved.Error) { $resolved.Error } else { '解析不出任何源路径' }
Write-Log "跳过: $displayPath,$reason" -Level WARN Write-BakNRetLog "跳过: $displayPath,$reason" -Level WARN
Save-ItemRecord -Record $record -Action 'missing-source' -Reason $reason | Out-Null Save-ItemRecord -Record $record -Action 'missing-source' -Reason $reason | Out-Null
$skipped++ $skipped++
continue continue
} }
# 源存在性检查必须在 Get-FolderSummary / Get-Item 之前: # 源存在性检查必须在 Get-BakNRetFolderSummary / Get-Item 之前:
# 两者对不存在的路径要么抛异常、要么返回会误导判断的空摘要。 # 两者对不存在的路径要么抛异常、要么返回会误导判断的空摘要。
$missingItems = @($resolved.Items | Where-Object { -not (Test-Path -LiteralPath $_.RealPath) }) $missingItems = @($resolved.Items | Where-Object { -not (Test-Path -LiteralPath $_.RealPath) })
if ($missingItems.Count -ge $resolved.Items.Count) { if ($missingItems.Count -ge $resolved.Items.Count) {
$missingText = @($missingItems | ForEach-Object { $_.RealPath }) -join ';' $missingText = @($missingItems | ForEach-Object { $_.RealPath }) -join ';'
Write-Log "跳过: $displayPath,源路径不存在" -Level WARN Write-BakNRetLog "跳过: $displayPath,源路径不存在" -Level WARN
Save-ItemRecord -Record $record -Action 'missing-source' -Reason ('源路径不存在:' + $missingText) | Out-Null Save-ItemRecord -Record $record -Action 'missing-source' -Reason ('源路径不存在:' + $missingText) | Out-Null
$skipped++ $skipped++
continue continue
} }
if ($missingItems.Count -gt 0) { if ($missingItems.Count -gt 0) {
Write-Log ("警告: {0} 有 {1} 个归档项的源路径不存在,本次只打包存在的部分:{2}" -f ` Write-BakNRetLog ("警告: {0} 有 {1} 个归档项的源路径不存在,本次只打包存在的部分:{2}" -f `
$displayPath, $missingItems.Count, (@($missingItems | ForEach-Object { $_.RealPath }) -join ';')) -Level WARN $displayPath, $missingItems.Count, (@($missingItems | ForEach-Object { $_.RealPath }) -join ';')) -Level WARN
} }
@@ -640,7 +640,7 @@ foreach ($line in $lines) {
$liveItems = @($resolved.Items | Where-Object { Test-Path -LiteralPath $_.RealPath }) $liveItems = @($resolved.Items | Where-Object { Test-Path -LiteralPath $_.RealPath })
# 归档内的顶层条目名 = 真实存在的归档项在包内的第一层名字(Slot 名 / 源目录名)。 # 归档内的顶层条目名 = 真实存在的归档项在包内的第一层名字(Slot 名 / 源目录名)。
# 这里记录可核对的事实,备份成功后还会用 Get-ArchiveTopLevelNames 与归档内容对账。 # 这里记录可核对的事实,备份成功后还会用 Get-BakNRetArchiveTopLevelNames 与归档内容对账。
$record.roots = @($liveItems | ForEach-Object { $_.TopName } | Select-Object -Unique) $record.roots = @($liveItems | ForEach-Object { $_.TopName } | Select-Object -Unique)
# 恢复端要知道每个项在归档里是目录还是文件:记在 manifest 里, # 恢复端要知道每个项在归档里是目录还是文件:记在 manifest 里,
@@ -654,17 +654,17 @@ foreach ($line in $lines) {
$primarySource = $liveItems[0].RealPath $primarySource = $liveItems[0].RealPath
if ([string]::IsNullOrWhiteSpace($primarySource)) { if ([string]::IsNullOrWhiteSpace($primarySource)) {
Write-Log "跳过: $displayPath,无法确定主源路径" -Level WARN Write-BakNRetLog "跳过: $displayPath,无法确定主源路径" -Level WARN
Save-ItemRecord -Record $record -Action 'invalid-path' -Reason '归档项没有可用路径' | Out-Null Save-ItemRecord -Record $record -Action 'invalid-path' -Reason '归档项没有可用路径' | Out-Null
$skipped++ $skipped++
continue continue
} }
$summary = Get-FolderSummary -FolderPath $primarySource $summary = Get-BakNRetFolderSummary -FolderPath $primarySource
# 从第二个归档项开始累加。刻意不用 `$liveItems[1..($liveItems.Count-1)]`: # 从第二个归档项开始累加。刻意不用 `$liveItems[1..($liveItems.Count-1)]`:
# 只有一项时 `1..0` 会退化成 `1,0`,把同一份源数两遍(旧实现的隐蔽 bug)。 # 只有一项时 `1..0` 会退化成 `1,0`,把同一份源数两遍(旧实现的隐蔽 bug)。
for ($index = 1; $index -lt $liveItems.Count; $index++) { for ($index = 1; $index -lt $liveItems.Count; $index++) {
$extra = Get-FolderSummary -FolderPath $liveItems[$index].RealPath $extra = Get-BakNRetFolderSummary -FolderPath $liveItems[$index].RealPath
$summary.FileCount += $extra.FileCount $summary.FileCount += $extra.FileCount
$summary.TotalSize += $extra.TotalSize $summary.TotalSize += $extra.TotalSize
if ($extra.LatestModifiedTime -and $extra.LatestModifiedTime -gt $summary.LatestModifiedTime) { if ($extra.LatestModifiedTime -and $extra.LatestModifiedTime -gt $summary.LatestModifiedTime) {
@@ -677,9 +677,9 @@ foreach ($line in $lines) {
$archiveExists = Test-Path -LiteralPath $finalPath $archiveExists = Test-Path -LiteralPath $finalPath
$archiveItem = if ($archiveExists) { Get-Item -LiteralPath $finalPath } else { $null } $archiveItem = if ($archiveExists) { Get-Item -LiteralPath $finalPath } else { $null }
Write-Log ("开始备份: {0} -> {1}({2} 个文件,{3} MB)" -f $displayPath, $record.archive, $summary.FileCount, [math]::Round(($summary.TotalSize / 1MB), 2)) Write-BakNRetLog ("开始备份: {0} -> {1}({2} 个文件,{3} MB)" -f $displayPath, $record.archive, $summary.FileCount, [math]::Round(($summary.TotalSize / 1MB), 2))
# 空目录时 Get-FolderSummary 拿不到任何条目,回退到源自身的修改时间 # 空目录时 Get-BakNRetFolderSummary 拿不到任何条目,回退到源自身的修改时间
# (源路径上面已经确认存在,这里的 Get-Item 不会再抛异常) # (源路径上面已经确认存在,这里的 Get-Item 不会再抛异常)
$sourceLatest = $summary.LatestModifiedTime $sourceLatest = $summary.LatestModifiedTime
if (-not $sourceLatest) { if (-not $sourceLatest) {
@@ -687,7 +687,7 @@ foreach ($line in $lines) {
} }
if (-not $Force -and $archiveItem -and $sourceLatest -and $sourceLatest -le $archiveItem.LastWriteTime) { if (-not $Force -and $archiveItem -and $sourceLatest -and $sourceLatest -le $archiveItem.LastWriteTime) {
Write-Log "跳过: $displayPath,源目录未更新" -Level INFO Write-BakNRetLog "跳过: $displayPath,源目录未更新" -Level INFO
$record.archiveBytes = $archiveItem.Length $record.archiveBytes = $archiveItem.Length
Save-ItemRecord -Record $record -Action 'skip-unchanged' -Reason ('源最新修改时间 {0} 不晚于归档时间 {1}' -f $sourceLatest, $archiveItem.LastWriteTime) | Out-Null Save-ItemRecord -Record $record -Action 'skip-unchanged' -Reason ('源最新修改时间 {0} 不晚于归档时间 {1}' -f $sourceLatest, $archiveItem.LastWriteTime) | Out-Null
$skipped++ $skipped++
@@ -700,17 +700,17 @@ foreach ($line in $lines) {
$archiveGB = $archiveItem.Length / 1GB $archiveGB = $archiveItem.Length / 1GB
$estimatedGB = [math]::Min($estimatedGB, $archiveGB * 1.3) $estimatedGB = [math]::Min($estimatedGB, $archiveGB * 1.3)
} }
$freeSpaceGB = Get-BaknretFreeSpaceGB -Path $BackupDir $freeSpaceGB = Get-BakNRetFreeSpaceGB -Path $BackupDir
if ($freeSpaceGB -ge 0 -and $estimatedGB -gt 0 -and $freeSpaceGB -lt $estimatedGB) { if ($freeSpaceGB -ge 0 -and $estimatedGB -gt 0 -and $freeSpaceGB -lt $estimatedGB) {
$reason = ('剩余空间 {0} GB 不足以写入预估 {1} GB 的新归档' -f $freeSpaceGB, [math]::Round($estimatedGB, 2)) $reason = ('剩余空间 {0} GB 不足以写入预估 {1} GB 的新归档' -f $freeSpaceGB, [math]::Round($estimatedGB, 2))
Write-Log "失败: $displayPath,$reason" -Level ERROR Write-BakNRetLog "失败: $displayPath,$reason" -Level ERROR
Save-ItemRecord -Record $record -Action 'failed' -Reason $reason | Out-Null Save-ItemRecord -Record $record -Action 'failed' -Reason $reason | Out-Null
$failed++; $failures += $displayPath $failed++; $failures += $displayPath
continue continue
} }
if ($DryRun) { if ($DryRun) {
Write-Log ("[试运行] 将打包 {0} -> {1}" -f $sourcePath, $finalPath) -Level INFO Write-BakNRetLog ("[试运行] 将打包 {0} -> {1}" -f $sourcePath, $finalPath) -Level INFO
$record.reason = '试运行,未执行压缩' $record.reason = '试运行,未执行压缩'
Save-ItemRecord -Record $record -Action 'planned' -Reason '试运行,未执行压缩' | Out-Null Save-ItemRecord -Record $record -Action 'planned' -Reason '试运行,未执行压缩' | Out-Null
$planned++ $planned++
@@ -733,19 +733,19 @@ foreach ($line in $lines) {
@($resolved.Items | ForEach-Object { @($_.Exclude) } | Where-Object { $_ } | Select-Object -Unique) @($resolved.Items | ForEach-Object { @($_.Exclude) } | Where-Object { $_ } | Select-Object -Unique)
} }
$allPatterns = @($script:Config.DefaultExcludes) + $patternSource $allPatterns = @($script:Config.DefaultExcludes) + $patternSource
$scopeMap = Split-BaknretPatternScope -Items $liveItems -Patterns $allPatterns $scopeMap = Split-BakNRetPatternScope -Items $liveItems -Patterns $allPatterns
$excludeLists = @() $excludeLists = @()
$excludeError = $null $excludeError = $null
for ($index = 0; $index -lt $liveItems.Count; $index++) { for ($index = 0; $index -lt $liveItems.Count; $index++) {
$expanded = Get-BaknretExcludeArgument -Item $liveItems[$index] -Patterns @($scopeMap[$index]) $expanded = Get-BakNRetExcludeArgument -Item $liveItems[$index] -Patterns @($scopeMap[$index])
if ($expanded.Error) { $excludeError = $expanded.Error } if ($expanded.Error) { $excludeError = $expanded.Error }
$excludeLists += , @($expanded.Arguments) $excludeLists += , @($expanded.Arguments)
} }
$effectiveExcludes = @(Merge-BaknretExcludeArgument -ArgumentLists $excludeLists) $effectiveExcludes = @(Merge-BakNRetExcludeArgument -ArgumentLists $excludeLists)
if ($excludeError) { if ($excludeError) {
Write-Log "失败: $displayPath,$excludeError" -Level ERROR Write-BakNRetLog "失败: $displayPath,$excludeError" -Level ERROR
Save-ItemRecord -Record $record -Action 'failed' -Reason $excludeError | Out-Null Save-ItemRecord -Record $record -Action 'failed' -Reason $excludeError | Out-Null
$failed++; $failures += $displayPath $failed++; $failures += $displayPath
continue continue
@@ -765,7 +765,7 @@ foreach ($line in $lines) {
# 挂进暂存目录(junction / 硬链接),再让压缩工具以暂存目录为工作目录打包。 # 挂进暂存目录(junction / 硬链接),再让压缩工具以暂存目录为工作目录打包。
$stagingRoot = $null $stagingRoot = $null
try { try {
$stagingRoot = New-BaknretArchiveStaging -Items $liveItems $stagingRoot = New-BakNRetArchiveStaging -Items $liveItems
$result = Invoke-BackupItem -SourceItems $liveItems -StagingRoot $stagingRoot ` $result = Invoke-BackupItem -SourceItems $liveItems -StagingRoot $stagingRoot `
-FinalPath $finalPath -ExcludePatterns $effectiveExcludes -UseEncryption:$useEncryption ` -FinalPath $finalPath -ExcludePatterns $effectiveExcludes -UseEncryption:$useEncryption `
-ProtectPrevious:$protectPrevious -AcceptWarnings:$AcceptWarnings -ProtectPrevious:$protectPrevious -AcceptWarnings:$AcceptWarnings
@@ -774,7 +774,7 @@ foreach ($line in $lines) {
$result = [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = "准备归档内容失败:$_" } $result = [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = "准备归档内容失败:$_" }
} }
finally { finally {
Remove-BaknretArchiveStaging -Root $stagingRoot Remove-BakNRetArchiveStaging -Root $stagingRoot
} }
$record.exitCode = $result.ExitCode $record.exitCode = $result.ExitCode
@@ -783,7 +783,7 @@ foreach ($line in $lines) {
$record.durationSec = [math]::Round(((Get-Date) - $startedAt).TotalSeconds, 1) $record.durationSec = [math]::Round(((Get-Date) - $startedAt).TotalSeconds, 1)
if (-not $result.Ok) { if (-not $result.Ok) {
Write-Log "备份失败: $displayPath,$($result.Reason)" -Level ERROR Write-BakNRetLog "备份失败: $displayPath,$($result.Reason)" -Level ERROR
Save-ItemRecord -Record $record -Action 'failed' -Reason $result.Reason | Out-Null Save-ItemRecord -Record $record -Action 'failed' -Reason $result.Reason | Out-Null
$failed++; $failures += $displayPath $failed++; $failures += $displayPath
continue continue
@@ -792,11 +792,11 @@ foreach ($line in $lines) {
$written = Get-Item -LiteralPath $finalPath $written = Get-Item -LiteralPath $finalPath
$record.archiveBytes = $written.Length $record.archiveBytes = $written.Length
if ($result.Warnings) { if ($result.Warnings) {
Write-Log "备份成功(压缩工具报告了警告,可能有文件被占用而没打进归档): $displayPath" -Level WARN Write-BakNRetLog "备份成功(压缩工具报告了警告,可能有文件被占用而没打进归档): $displayPath" -Level WARN
Write-Log ' 该归档在 manifest 里标记为 warnings=true;如果以后现有归档是完整的,会拒绝被它覆盖' -Level WARN Write-BakNRetLog ' 该归档在 manifest 里标记为 warnings=true;如果以后现有归档是完整的,会拒绝被它覆盖' -Level WARN
} }
else { else {
Write-Log "备份成功: $baseName" -Level INFO Write-BakNRetLog "备份成功: $baseName" -Level INFO
} }
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -813,9 +813,9 @@ foreach ($line in $lines) {
$sidecarName = "$baseName.acl.json" $sidecarName = "$baseName.acl.json"
$sidecarPath = Join-Path $BackupDir $sidecarName $sidecarPath = Join-Path $BackupDir $sidecarName
try { try {
$capture = Get-BaknretSecurityRecords -Items $liveItems -ScopeMap $scopeMap -Mode $securityMode ` $capture = Get-BakNRetSecurityRecords -Items $liveItems -ScopeMap $scopeMap -Mode $securityMode `
-IncludeSacl:([bool]$script:Config.Security.IncludeSacl) -IncludeSacl:([bool]$script:Config.Security.IncludeSacl)
Save-BaknretSecuritySidecar -Path $sidecarPath -Records $capture.Records -Mode $securityMode ` Save-BakNRetSecuritySidecar -Path $sidecarPath -Records $capture.Records -Mode $securityMode `
-IncludeSacl:([bool]$script:Config.Security.IncludeSacl) ` -IncludeSacl:([bool]$script:Config.Security.IncludeSacl) `
-Errors $capture.Errors -Scanned $capture.Scanned | Out-Null -Errors $capture.Errors -Scanned $capture.Scanned | Out-Null
@@ -827,25 +827,25 @@ foreach ($line in $lines) {
errors = $capture.Errors errors = $capture.Errors
capturedAt = (Get-Date).ToString('o') capturedAt = (Get-Date).ToString('o')
} }
Write-Log ("安全描述符:{0} 个对象写进 {1}(扫描 {2} 个,读不到 {3} 个)" -f ` Write-BakNRetLog ("安全描述符:{0} 个对象写进 {1}(扫描 {2} 个,读不到 {3} 个)" -f `
$capture.Kept, $sidecarName, $capture.Scanned, $capture.Errors) -Level INFO $capture.Kept, $sidecarName, $capture.Scanned, $capture.Errors) -Level INFO
if ($capture.Errors -gt 0) { if ($capture.Errors -gt 0) {
$securityErrorCount++ $securityErrorCount++
$unreadable = @($capture.Records | Where-Object { $_.e } | Select-Object -First 3 -ExpandProperty p) $unreadable = @($capture.Records | Where-Object { $_.e } | Select-Object -First 3 -ExpandProperty p)
Write-Log (" {0} 个对象的安全描述符读不到(恢复后它们的属主/ACL 会是新建对象的默认值),例如:{1}" -f ` Write-BakNRetLog (" {0} 个对象的安全描述符读不到(恢复后它们的属主/ACL 会是新建对象的默认值),例如:{1}" -f `
$capture.Errors, ($unreadable -join '、')) -Level WARN $capture.Errors, ($unreadable -join '、')) -Level WARN
} }
} }
catch { catch {
$securityFailed++ $securityFailed++
Write-Log "安全描述符采集/写盘失败:$displayPath —— $_" -Level WARN Write-BakNRetLog "安全描述符采集/写盘失败:$displayPath —— $_" -Level WARN
$record.security = [ordered]@{ file = $sidecarName; error = "$_" } $record.security = [ordered]@{ file = $sidecarName; error = "$_" }
if ([bool]$script:Config.Security.FailOnError) { $securityFatal = $true } if ([bool]$script:Config.Security.FailOnError) { $securityFatal = $true }
} }
if ($securityFatal) { if ($securityFatal) {
Write-Log "失败: $displayPath,归档已替换,但安全描述符没能存下来(Security.FailOnError = \$true)" -Level ERROR Write-BakNRetLog "失败: $displayPath,归档已替换,但安全描述符没能存下来(Security.FailOnError = \$true)" -Level ERROR
Save-ItemRecord -Record $record -Action 'failed' -Reason '归档已替换,但安全描述符没能存下来' | Out-Null Save-ItemRecord -Record $record -Action 'failed' -Reason '归档已替换,但安全描述符没能存下来' | Out-Null
$failed++; $failures += $displayPath $failed++; $failures += $displayPath
continue continue
@@ -854,7 +854,7 @@ foreach ($line in $lines) {
if ($Hash -or $script:Config.ComputeHash) { if ($Hash -or $script:Config.ComputeHash) {
$record.sha256 = (Get-FileHash -LiteralPath $finalPath -Algorithm SHA256).Hash $record.sha256 = (Get-FileHash -LiteralPath $finalPath -Algorithm SHA256).Hash
Write-Log "SHA256: $($record.sha256)" -Level DEBUG Write-BakNRetLog "SHA256: $($record.sha256)" -Level DEBUG
} }
if ($Snapshot -or $script:Config.Snapshot.Enabled) { if ($Snapshot -or $script:Config.Snapshot.Enabled) {
@@ -863,7 +863,7 @@ foreach ($line in $lines) {
$targetDir = Split-Path -Parent $target $targetDir = Split-Path -Parent $target
if (-not (Test-Path -LiteralPath $targetDir)) { New-Item -ItemType Directory -Path $targetDir -Force | Out-Null } if (-not (Test-Path -LiteralPath $targetDir)) { New-Item -ItemType Directory -Path $targetDir -Force | Out-Null }
Copy-Item -LiteralPath $finalPath -Destination $target -Force Copy-Item -LiteralPath $finalPath -Destination $target -Force
Write-Log "已留存快照: $target" -Level INFO Write-BakNRetLog "已留存快照: $target" -Level INFO
} }
Save-ItemRecord -Record $record -Action 'backed-up' -Reason $null -ArchiveWarnings $result.Warnings | Out-Null Save-ItemRecord -Record $record -Action 'backed-up' -Reason $null -ArchiveWarnings $result.Warnings | Out-Null
@@ -875,17 +875,17 @@ foreach ($line in $lines) {
# ============================================================================ # ============================================================================
if ($DryRun) { if ($DryRun) {
Write-Log '试运行:manifest 与归档都不会被写入' -Level INFO Write-BakNRetLog '试运行:manifest 与归档都不会被写入' -Level INFO
} }
else { else {
# manifest 里写了 archive 的记录,磁盘上就必须真有那个文件 # manifest 里写了 archive 的记录,磁盘上就必须真有那个文件
$clearedArchiveFields = Sync-BaknretManifestArchive -Manifest $manifest -BackupDir $BackupDir $clearedArchiveFields = Sync-BakNRetManifestArchive -Manifest $manifest -BackupDir $BackupDir
if ($clearedArchiveFields.Count -gt 0) { if ($clearedArchiveFields.Count -gt 0) {
Write-Log ("已清空 {0} 条记录里指向不存在归档的 archive 字段:{1}" -f $clearedArchiveFields.Count, ($clearedArchiveFields -join '、')) -Level WARN Write-BakNRetLog ("已清空 {0} 条记录里指向不存在归档的 archive 字段:{1}" -f $clearedArchiveFields.Count, ($clearedArchiveFields -join '、')) -Level WARN
} }
Write-BaknretManifest -Path $manifestPath -Manifest $manifest | Out-Null Write-BakNRetManifest -Path $manifestPath -Manifest $manifest | Out-Null
Write-Log "manifest 已更新:$manifestPath" -Level DEBUG Write-BakNRetLog "manifest 已更新:$manifestPath" -Level DEBUG
} }
# 孤儿归档审计:磁盘上有、但**当前清单里任何条目都不指向**的归档。 # 孤儿归档审计:磁盘上有、但**当前清单里任何条目都不指向**的归档。
@@ -906,37 +906,37 @@ if (-not $DryRun -and $Only.Count -eq 0 -and $Skip.Count -eq 0) {
Where-Object { $_.Extension.ToLower() -in @('.7z', '.rar', '.zip', '.tar') -and -not $known.ContainsKey($_.BaseName) }) Where-Object { $_.Extension.ToLower() -in @('.7z', '.rar', '.zip', '.tar') -and -not $known.ContainsKey($_.BaseName) })
if ($orphanArchives.Count -gt 0) { if ($orphanArchives.Count -gt 0) {
Write-Log ("发现 {0} 个孤儿归档(当前清单里没有任何条目指向,恢复不到,注意别误删):" -f $orphanArchives.Count) -Level WARN Write-BakNRetLog ("发现 {0} 个孤儿归档(当前清单里没有任何条目指向,恢复不到,注意别误删):" -f $orphanArchives.Count) -Level WARN
foreach ($orphan in $orphanArchives) { foreach ($orphan in $orphanArchives) {
$inManifest = $manifest.items.Contains($orphan.BaseName) $inManifest = $manifest.items.Contains($orphan.BaseName)
Write-Log (" - {0}({1:N1} MB,{2}){3}" -f $orphan.Name, ($orphan.Length / 1MB), $orphan.LastWriteTime, $(if ($inManifest) { ';manifest 里还留着它的历史记录,但清单里已经没有了' } else { '' })) -Level WARN Write-BakNRetLog (" - {0}({1:N1} MB,{2}){3}" -f $orphan.Name, ($orphan.Length / 1MB), $orphan.LastWriteTime, $(if ($inManifest) { ';manifest 里还留着它的历史记录,但清单里已经没有了' } else { '' })) -Level WARN
} }
} }
else { else {
Write-Log '孤儿归档审计:没有发现(所有归档都有清单条目指向)' -Level DEBUG Write-BakNRetLog '孤儿归档审计:没有发现(所有归档都有清单条目指向)' -Level DEBUG
} }
} }
if ($failures.Count -gt 0) { if ($failures.Count -gt 0) {
Write-Log '失败条目:' -Level ERROR Write-BakNRetLog '失败条目:' -Level ERROR
foreach ($failure in $failures) { Write-Log " - $failure" -Level ERROR } foreach ($failure in $failures) { Write-BakNRetLog " - $failure" -Level ERROR }
} }
if ($securityFailed -gt 0) { if ($securityFailed -gt 0) {
Write-Log ("有 {0} 个条目的安全描述符完全没能存下来(manifest 的 security.error 里有原文)" -f $securityFailed) -Level WARN Write-BakNRetLog ("有 {0} 个条目的安全描述符完全没能存下来(manifest 的 security.error 里有原文)" -f $securityFailed) -Level WARN
} }
if ($securityErrorCount -gt 0) { if ($securityErrorCount -gt 0) {
Write-Log ("有 {0} 个条目存在'读不到安全描述符'的对象;恢复后这些对象的属主/ACL 是新建对象的默认值,可查 manifest 的 security.errors" -f $securityErrorCount) -Level WARN Write-BakNRetLog ("有 {0} 个条目存在'读不到安全描述符'的对象;恢复后这些对象的属主/ACL 是新建对象的默认值,可查 manifest 的 security.errors" -f $securityErrorCount) -Level WARN
} }
$summaryText = "备份完成。成功: $processed, 跳过: $skipped, 失败: $failed" $summaryText = "备份完成。成功: $processed, 跳过: $skipped, 失败: $failed"
if ($DryRun) { $summaryText += ", 试运行计划: $planned" } if ($DryRun) { $summaryText += ", 试运行计划: $planned" }
Write-Log $summaryText -Level INFO Write-BakNRetLog $summaryText -Level INFO
$logPath = Get-BaknretLogPath $logPath = Get-BakNRetLogPath
if ($logPath) { Write-Log "日志已写入:$logPath" -Level INFO } if ($logPath) { Write-BakNRetLog "日志已写入:$logPath" -Level INFO }
Exit-BaknretRunLock -Lock $runLock Exit-BakNRetRunLock -Lock $runLock
Stop-BaknretLog Stop-BakNRetLog
if ($failed -gt 0) { exit 1 } if ($failed -gt 0) { exit 1 }
exit 0 exit 0
+58 -58
View File
@@ -19,68 +19,68 @@
CompatiblePSEditions = @('Desktop', 'Core') CompatiblePSEditions = @('Desktop', 'Core')
FunctionsToExport = @( FunctionsToExport = @(
'Enter-BaknretRunLock', 'Enter-BakNRetRunLock',
'Exit-BaknretRunLock', 'Exit-BakNRetRunLock',
'Get-BaknretRunLockPath', 'Get-BakNRetRunLockPath',
'Set-BaknretDebug', 'Set-BakNRetDebug',
'Start-BaknretLog', 'Start-BakNRetLog',
'Stop-BaknretLog', 'Stop-BakNRetLog',
'Get-BaknretLogPath', 'Get-BakNRetLogPath',
'Write-Log', 'Write-BakNRetLog',
'Test-Administrator', 'Test-BakNRetAdministrator',
'Get-BaknretFreeSpaceGB', 'Get-BakNRetFreeSpaceGB',
'ConvertTo-NativeArgumentString', 'ConvertTo-BakNRetNativeArgumentString',
'Invoke-ExternalCommand', 'Invoke-ExternalCommand',
'Resolve-CompressionTool', 'Resolve-BakNRetCompressionTool',
'Get-Optimized7zArgument', 'Get-Optimized7zArgument',
'Split-BaknretToken', 'Split-BakNRetToken',
'Remove-BaknretQuote', 'Remove-BakNRetQuote',
'Test-BaknretMarker', 'Test-BakNRetMarker',
'ConvertFrom-BaknretPatternList', 'ConvertFrom-BakNRetPatternList',
'ConvertFrom-BackupListLine', 'ConvertFrom-BackupListLine',
'Test-LiteralPath', 'Test-BakNRetLiteralPath',
'Get-BaknretRegexExclude', 'Get-BakNRetRegexExclude',
'Get-BaknretExcludeArgument', 'Get-BakNRetExcludeArgument',
'Split-BaknretPatternScope', 'Split-BakNRetPatternScope',
'Merge-BaknretExcludeArgument', 'Merge-BakNRetExcludeArgument',
'Resolve-CatalogPath', 'Resolve-BakNRetCatalogPath',
'Get-SoftwareCatalog', 'Get-BakNRetSoftwareCatalog',
'Find-ChildDirectoryByName', 'Find-BakNRetChildDirectoryByName',
'Format-CatalogName', 'Format-BakNRetCatalogName',
'Expand-CatalogPathText', 'Expand-CatalogPathText',
'Get-ArchiveTopLevelNames', 'Get-BakNRetArchiveTopLevelNames',
'Get-BaknretArchiveTopName', 'Get-BakNRetArchiveTopName',
'New-BaknretArchiveItem', 'New-BakNRetArchiveItem',
'New-BaknretJunction', 'New-BakNRetJunction',
'Remove-BaknretJunction', 'Remove-BakNRetJunction',
'New-BaknretArchiveStaging', 'New-BakNRetArchiveStaging',
'Remove-BaknretArchiveStaging', 'Remove-BakNRetArchiveStaging',
'Get-ItemArchiveName', 'Get-BakNRetItemArchiveName',
'Resolve-BackupEntry', 'Resolve-BakNRetBackupEntry',
'Write-BackupEntryPlan', 'Write-BakNRetBackupEntryPlan',
'Get-BackupBaseName', 'Get-BakNRetBackupBaseName',
'Convert-BackupFileNameToPath', 'Convert-BakNRetBackupFileNameToPath',
'Get-FolderSummary', 'Get-BakNRetFolderSummary',
'Read-BaknretManifest', 'Read-BakNRetManifest',
'Write-BaknretManifest', 'Write-BakNRetManifest',
'Sync-BaknretManifestArchive', 'Sync-BakNRetManifestArchive',
'Move-BaknretArchiveIntoPlace', 'Move-BakNRetArchiveIntoPlace',
'Enable-BaknretPrivilege', 'Enable-BakNRetPrivilege',
'ConvertTo-BaknretWildcardPattern', 'ConvertTo-BakNRetWildcardPattern',
'Test-BaknretPathExcluded', 'Test-BakNRetPathExcluded',
'Get-BaknretAceSignatureList', 'Get-BakNRetAceSignatureList',
'Get-BaknretSecuritySddlWithStale', 'Get-BakNRetSecuritySddlWithStale',
'Get-BaknretSecurityRecord', 'Get-BakNRetSecurityRecord',
'Test-BaknretSecurityRecordNeeded', 'Test-BakNRetSecurityRecordNeeded',
'Get-BaknretSecurityRecords', 'Get-BakNRetSecurityRecords',
'Write-BaknretAtomicText', 'Write-BakNRetAtomicText',
'Save-BaknretSecuritySidecar', 'Save-BakNRetSecuritySidecar',
'Read-BaknretSecuritySidecar', 'Read-BakNRetSecuritySidecar',
'Convert-BaknretSidMap', 'Convert-BakNRetSidMap',
'Set-BaknretObjectSecurity', 'Set-BakNRetObjectSecurity',
'Restore-BaknretSecurity', 'Restore-BakNRetSecurity',
'Get-BaknretConfig', 'Get-BakNRetConfig',
'Get-BaknretPassword' 'Get-BakNRetPassword'
) )
CmdletsToExport = @() CmdletsToExport = @()
VariablesToExport = @() VariablesToExport = @()
+120 -120
View File
@@ -46,155 +46,155 @@
. (Join-Path $PSScriptRoot 'Private\State.ps1') . (Join-Path $PSScriptRoot 'Private\State.ps1')
# ---- 日志 ---- # ---- 日志 ----
. (Join-Path $PSScriptRoot 'Public\Set-BaknretDebug.ps1') . (Join-Path $PSScriptRoot 'Public\Set-BakNRetDebug.ps1')
. (Join-Path $PSScriptRoot 'Public\Start-BaknretLog.ps1') . (Join-Path $PSScriptRoot 'Public\Start-BakNRetLog.ps1')
. (Join-Path $PSScriptRoot 'Public\Stop-BaknretLog.ps1') . (Join-Path $PSScriptRoot 'Public\Stop-BakNRetLog.ps1')
. (Join-Path $PSScriptRoot 'Public\Get-BaknretLogPath.ps1') . (Join-Path $PSScriptRoot 'Public\Get-BakNRetLogPath.ps1')
. (Join-Path $PSScriptRoot 'Public\Write-Log.ps1') . (Join-Path $PSScriptRoot 'Public\Write-BakNRetLog.ps1')
# ---- 运行锁 ---- # ---- 运行锁 ----
. (Join-Path $PSScriptRoot 'Public\Get-BaknretRunLockPath.ps1') . (Join-Path $PSScriptRoot 'Public\Get-BakNRetRunLockPath.ps1')
. (Join-Path $PSScriptRoot 'Public\Enter-BaknretRunLock.ps1') . (Join-Path $PSScriptRoot 'Public\Enter-BakNRetRunLock.ps1')
. (Join-Path $PSScriptRoot 'Public\Exit-BaknretRunLock.ps1') . (Join-Path $PSScriptRoot 'Public\Exit-BakNRetRunLock.ps1')
# ---- 环境 ---- # ---- 环境 ----
. (Join-Path $PSScriptRoot 'Public\Test-Administrator.ps1') . (Join-Path $PSScriptRoot 'Public\Test-BakNRetAdministrator.ps1')
. (Join-Path $PSScriptRoot 'Public\Get-BaknretFreeSpaceGB.ps1') . (Join-Path $PSScriptRoot 'Public\Get-BakNRetFreeSpaceGB.ps1')
# ---- 外部命令 ---- # ---- 外部命令 ----
. (Join-Path $PSScriptRoot 'Public\ConvertTo-NativeArgumentString.ps1') . (Join-Path $PSScriptRoot 'Public\ConvertTo-BakNRetNativeArgumentString.ps1')
. (Join-Path $PSScriptRoot 'Public\Invoke-ExternalCommand.ps1') . (Join-Path $PSScriptRoot 'Public\Invoke-ExternalCommand.ps1')
. (Join-Path $PSScriptRoot 'Public\Resolve-CompressionTool.ps1') . (Join-Path $PSScriptRoot 'Public\Resolve-BakNRetCompressionTool.ps1')
. (Join-Path $PSScriptRoot 'Public\Get-Optimized7zArgument.ps1') . (Join-Path $PSScriptRoot 'Public\Get-Optimized7zArgument.ps1')
# ---- BackupList.txt 解析 ---- # ---- BackupList.txt 解析 ----
. (Join-Path $PSScriptRoot 'Public\Split-BaknretToken.ps1') . (Join-Path $PSScriptRoot 'Public\Split-BakNRetToken.ps1')
. (Join-Path $PSScriptRoot 'Public\Remove-BaknretQuote.ps1') . (Join-Path $PSScriptRoot 'Public\Remove-BakNRetQuote.ps1')
. (Join-Path $PSScriptRoot 'Public\Test-BaknretMarker.ps1') . (Join-Path $PSScriptRoot 'Public\Test-BakNRetMarker.ps1')
. (Join-Path $PSScriptRoot 'Public\ConvertFrom-BaknretPatternList.ps1') . (Join-Path $PSScriptRoot 'Public\ConvertFrom-BakNRetPatternList.ps1')
. (Join-Path $PSScriptRoot 'Public\ConvertFrom-BackupListLine.ps1') . (Join-Path $PSScriptRoot 'Public\ConvertFrom-BackupListLine.ps1')
. (Join-Path $PSScriptRoot 'Public\Test-LiteralPath.ps1') . (Join-Path $PSScriptRoot 'Public\Test-BakNRetLiteralPath.ps1')
. (Join-Path $PSScriptRoot 'Public\Get-BaknretRegexExclude.ps1') . (Join-Path $PSScriptRoot 'Public\Get-BakNRetRegexExclude.ps1')
. (Join-Path $PSScriptRoot 'Public\Get-BaknretExcludeArgument.ps1') . (Join-Path $PSScriptRoot 'Public\Get-BakNRetExcludeArgument.ps1')
. (Join-Path $PSScriptRoot 'Public\Split-BaknretPatternScope.ps1') . (Join-Path $PSScriptRoot 'Public\Split-BakNRetPatternScope.ps1')
. (Join-Path $PSScriptRoot 'Public\Merge-BaknretExcludeArgument.ps1') . (Join-Path $PSScriptRoot 'Public\Merge-BakNRetExcludeArgument.ps1')
# ---- 软件名录(SoftwareCatalog.psd1) ---- # ---- 软件名录(SoftwareCatalog.psd1) ----
. (Join-Path $PSScriptRoot 'Public\Resolve-CatalogPath.ps1') . (Join-Path $PSScriptRoot 'Public\Resolve-BakNRetCatalogPath.ps1')
. (Join-Path $PSScriptRoot 'Public\Format-CatalogName.ps1') . (Join-Path $PSScriptRoot 'Public\Format-BakNRetCatalogName.ps1')
. (Join-Path $PSScriptRoot 'Private\Test-BaknretMapKey.ps1') . (Join-Path $PSScriptRoot 'Private\Test-BakNRetMapKey.ps1')
. (Join-Path $PSScriptRoot 'Private\Get-BaknretMapValue.ps1') . (Join-Path $PSScriptRoot 'Private\Get-BakNRetMapValue.ps1')
. (Join-Path $PSScriptRoot 'Private\Get-BaknretMapKeys.ps1') . (Join-Path $PSScriptRoot 'Private\Get-BakNRetMapKeys.ps1')
. (Join-Path $PSScriptRoot 'Public\Expand-CatalogPathText.ps1') . (Join-Path $PSScriptRoot 'Public\Expand-CatalogPathText.ps1')
. (Join-Path $PSScriptRoot 'Private\Import-BaknretDataFile.ps1') . (Join-Path $PSScriptRoot 'Private\Import-BakNRetDataFile.ps1')
. (Join-Path $PSScriptRoot 'Public\Get-SoftwareCatalog.ps1') . (Join-Path $PSScriptRoot 'Public\Get-BakNRetSoftwareCatalog.ps1')
. (Join-Path $PSScriptRoot 'Public\Get-ArchiveTopLevelNames.ps1') . (Join-Path $PSScriptRoot 'Public\Get-BakNRetArchiveTopLevelNames.ps1')
. (Join-Path $PSScriptRoot 'Public\Find-ChildDirectoryByName.ps1') . (Join-Path $PSScriptRoot 'Public\Find-BakNRetChildDirectoryByName.ps1')
# ---- 归档命名与路径还原 ---- # ---- 归档命名与路径还原 ----
. (Join-Path $PSScriptRoot 'Public\Get-ItemArchiveName.ps1') . (Join-Path $PSScriptRoot 'Public\Get-BakNRetItemArchiveName.ps1')
. (Join-Path $PSScriptRoot 'Public\Get-BaknretArchiveTopName.ps1') . (Join-Path $PSScriptRoot 'Public\Get-BakNRetArchiveTopName.ps1')
. (Join-Path $PSScriptRoot 'Public\New-BaknretArchiveItem.ps1') . (Join-Path $PSScriptRoot 'Public\New-BakNRetArchiveItem.ps1')
. (Join-Path $PSScriptRoot 'Public\New-BaknretJunction.ps1') . (Join-Path $PSScriptRoot 'Public\New-BakNRetJunction.ps1')
. (Join-Path $PSScriptRoot 'Public\Remove-BaknretJunction.ps1') . (Join-Path $PSScriptRoot 'Public\Remove-BakNRetJunction.ps1')
. (Join-Path $PSScriptRoot 'Public\New-BaknretArchiveStaging.ps1') . (Join-Path $PSScriptRoot 'Public\New-BakNRetArchiveStaging.ps1')
. (Join-Path $PSScriptRoot 'Public\Remove-BaknretArchiveStaging.ps1') . (Join-Path $PSScriptRoot 'Public\Remove-BakNRetArchiveStaging.ps1')
. (Join-Path $PSScriptRoot 'Public\Resolve-BackupEntry.ps1') . (Join-Path $PSScriptRoot 'Public\Resolve-BakNRetBackupEntry.ps1')
. (Join-Path $PSScriptRoot 'Public\Write-BackupEntryPlan.ps1') . (Join-Path $PSScriptRoot 'Public\Write-BakNRetBackupEntryPlan.ps1')
. (Join-Path $PSScriptRoot 'Public\Get-BackupBaseName.ps1') . (Join-Path $PSScriptRoot 'Public\Get-BakNRetBackupBaseName.ps1')
. (Join-Path $PSScriptRoot 'Public\Convert-BackupFileNameToPath.ps1') . (Join-Path $PSScriptRoot 'Public\Convert-BakNRetBackupFileNameToPath.ps1')
. (Join-Path $PSScriptRoot 'Public\Get-FolderSummary.ps1') . (Join-Path $PSScriptRoot 'Public\Get-BakNRetFolderSummary.ps1')
# ---- manifest.json ---- # ---- manifest.json ----
. (Join-Path $PSScriptRoot 'Public\Read-BaknretManifest.ps1') . (Join-Path $PSScriptRoot 'Public\Read-BakNRetManifest.ps1')
. (Join-Path $PSScriptRoot 'Public\Sync-BaknretManifestArchive.ps1') . (Join-Path $PSScriptRoot 'Public\Sync-BakNRetManifestArchive.ps1')
. (Join-Path $PSScriptRoot 'Public\Write-BaknretManifest.ps1') . (Join-Path $PSScriptRoot 'Public\Write-BakNRetManifest.ps1')
# ---- 归档原子替换 ---- # ---- 归档原子替换 ----
. (Join-Path $PSScriptRoot 'Public\Move-BaknretArchiveIntoPlace.ps1') . (Join-Path $PSScriptRoot 'Public\Move-BakNRetArchiveIntoPlace.ps1')
# ---- 安全描述符(NTFS 属主 / ACL) ---- # ---- 安全描述符(NTFS 属主 / ACL) ----
. (Join-Path $PSScriptRoot 'Public\Enable-BaknretPrivilege.ps1') . (Join-Path $PSScriptRoot 'Public\Enable-BakNRetPrivilege.ps1')
. (Join-Path $PSScriptRoot 'Public\ConvertTo-BaknretWildcardPattern.ps1') . (Join-Path $PSScriptRoot 'Public\ConvertTo-BakNRetWildcardPattern.ps1')
. (Join-Path $PSScriptRoot 'Public\Test-BaknretPathExcluded.ps1') . (Join-Path $PSScriptRoot 'Public\Test-BakNRetPathExcluded.ps1')
. (Join-Path $PSScriptRoot 'Public\Get-BaknretAceSignatureList.ps1') . (Join-Path $PSScriptRoot 'Public\Get-BakNRetAceSignatureList.ps1')
. (Join-Path $PSScriptRoot 'Public\Get-BaknretSecuritySddlWithStale.ps1') . (Join-Path $PSScriptRoot 'Public\Get-BakNRetSecuritySddlWithStale.ps1')
. (Join-Path $PSScriptRoot 'Public\Get-BaknretSecurityRecord.ps1') . (Join-Path $PSScriptRoot 'Public\Get-BakNRetSecurityRecord.ps1')
. (Join-Path $PSScriptRoot 'Public\Test-BaknretSecurityRecordNeeded.ps1') . (Join-Path $PSScriptRoot 'Public\Test-BakNRetSecurityRecordNeeded.ps1')
. (Join-Path $PSScriptRoot 'Public\Get-BaknretSecurityRecords.ps1') . (Join-Path $PSScriptRoot 'Public\Get-BakNRetSecurityRecords.ps1')
. (Join-Path $PSScriptRoot 'Public\Write-BaknretAtomicText.ps1') . (Join-Path $PSScriptRoot 'Public\Write-BakNRetAtomicText.ps1')
. (Join-Path $PSScriptRoot 'Public\Save-BaknretSecuritySidecar.ps1') . (Join-Path $PSScriptRoot 'Public\Save-BakNRetSecuritySidecar.ps1')
. (Join-Path $PSScriptRoot 'Public\Read-BaknretSecuritySidecar.ps1') . (Join-Path $PSScriptRoot 'Public\Read-BakNRetSecuritySidecar.ps1')
. (Join-Path $PSScriptRoot 'Public\Convert-BaknretSidMap.ps1') . (Join-Path $PSScriptRoot 'Public\Convert-BakNRetSidMap.ps1')
. (Join-Path $PSScriptRoot 'Public\Set-BaknretObjectSecurity.ps1') . (Join-Path $PSScriptRoot 'Public\Set-BakNRetObjectSecurity.ps1')
. (Join-Path $PSScriptRoot 'Public\Restore-BaknretSecurity.ps1') . (Join-Path $PSScriptRoot 'Public\Restore-BakNRetSecurity.ps1')
# ---- 配置 ---- # ---- 配置 ----
. (Join-Path $PSScriptRoot 'Public\Get-BaknretConfig.ps1') . (Join-Path $PSScriptRoot 'Public\Get-BakNRetConfig.ps1')
. (Join-Path $PSScriptRoot 'Public\Get-BaknretPassword.ps1') . (Join-Path $PSScriptRoot 'Public\Get-BakNRetPassword.ps1')
Export-ModuleMember -Function @( Export-ModuleMember -Function @(
'Enter-BaknretRunLock', 'Enter-BakNRetRunLock',
'Exit-BaknretRunLock', 'Exit-BakNRetRunLock',
'Get-BaknretRunLockPath', 'Get-BakNRetRunLockPath',
'Set-BaknretDebug', 'Set-BakNRetDebug',
'Start-BaknretLog', 'Start-BakNRetLog',
'Stop-BaknretLog', 'Stop-BakNRetLog',
'Get-BaknretLogPath', 'Get-BakNRetLogPath',
'Write-Log', 'Write-BakNRetLog',
'Test-Administrator', 'Test-BakNRetAdministrator',
'Get-BaknretFreeSpaceGB', 'Get-BakNRetFreeSpaceGB',
'ConvertTo-NativeArgumentString', 'ConvertTo-BakNRetNativeArgumentString',
'Invoke-ExternalCommand', 'Invoke-ExternalCommand',
'Resolve-CompressionTool', 'Resolve-BakNRetCompressionTool',
'Get-Optimized7zArgument', 'Get-Optimized7zArgument',
'Split-BaknretToken', 'Split-BakNRetToken',
'Remove-BaknretQuote', 'Remove-BakNRetQuote',
'Test-BaknretMarker', 'Test-BakNRetMarker',
'ConvertFrom-BaknretPatternList', 'ConvertFrom-BakNRetPatternList',
'ConvertFrom-BackupListLine', 'ConvertFrom-BackupListLine',
'Test-LiteralPath', 'Test-BakNRetLiteralPath',
'Get-BaknretRegexExclude', 'Get-BakNRetRegexExclude',
'Get-BaknretExcludeArgument', 'Get-BakNRetExcludeArgument',
'Split-BaknretPatternScope', 'Split-BakNRetPatternScope',
'Merge-BaknretExcludeArgument', 'Merge-BakNRetExcludeArgument',
'Resolve-CatalogPath', 'Resolve-BakNRetCatalogPath',
'Get-SoftwareCatalog', 'Get-BakNRetSoftwareCatalog',
'Find-ChildDirectoryByName', 'Find-BakNRetChildDirectoryByName',
'Format-CatalogName', 'Format-BakNRetCatalogName',
'Expand-CatalogPathText', 'Expand-CatalogPathText',
'Get-ArchiveTopLevelNames', 'Get-BakNRetArchiveTopLevelNames',
'Get-BaknretArchiveTopName', 'Get-BakNRetArchiveTopName',
'New-BaknretArchiveItem', 'New-BakNRetArchiveItem',
'New-BaknretJunction', 'New-BakNRetJunction',
'Remove-BaknretJunction', 'Remove-BakNRetJunction',
'New-BaknretArchiveStaging', 'New-BakNRetArchiveStaging',
'Remove-BaknretArchiveStaging', 'Remove-BakNRetArchiveStaging',
'Get-ItemArchiveName', 'Get-BakNRetItemArchiveName',
'Resolve-BackupEntry', 'Resolve-BakNRetBackupEntry',
'Write-BackupEntryPlan', 'Write-BakNRetBackupEntryPlan',
'Get-BackupBaseName', 'Get-BakNRetBackupBaseName',
'Convert-BackupFileNameToPath', 'Convert-BakNRetBackupFileNameToPath',
'Get-FolderSummary', 'Get-BakNRetFolderSummary',
'Read-BaknretManifest', 'Read-BakNRetManifest',
'Write-BaknretManifest', 'Write-BakNRetManifest',
'Sync-BaknretManifestArchive', 'Sync-BakNRetManifestArchive',
'Move-BaknretArchiveIntoPlace', 'Move-BakNRetArchiveIntoPlace',
'Enable-BaknretPrivilege', 'Enable-BakNRetPrivilege',
'ConvertTo-BaknretWildcardPattern', 'ConvertTo-BakNRetWildcardPattern',
'Test-BaknretPathExcluded', 'Test-BakNRetPathExcluded',
'Get-BaknretAceSignatureList', 'Get-BakNRetAceSignatureList',
'Get-BaknretSecuritySddlWithStale', 'Get-BakNRetSecuritySddlWithStale',
'Get-BaknretSecurityRecord', 'Get-BakNRetSecurityRecord',
'Test-BaknretSecurityRecordNeeded', 'Test-BakNRetSecurityRecordNeeded',
'Get-BaknretSecurityRecords', 'Get-BakNRetSecurityRecords',
'Write-BaknretAtomicText', 'Write-BakNRetAtomicText',
'Save-BaknretSecuritySidecar', 'Save-BakNRetSecuritySidecar',
'Read-BaknretSecuritySidecar', 'Read-BakNRetSecuritySidecar',
'Convert-BaknretSidMap', 'Convert-BakNRetSidMap',
'Set-BaknretObjectSecurity', 'Set-BakNRetObjectSecurity',
'Restore-BaknretSecurity', 'Restore-BakNRetSecurity',
'Get-BaknretConfig', 'Get-BakNRetConfig',
'Get-BaknretPassword' 'Get-BakNRetPassword'
) )
+1 -1
View File
@@ -1,4 +1,4 @@
function Get-BaknretMapKeys { function Get-BakNRetMapKeys {
<# .SYNOPSIS 列出哈希表或 JSON 对象的全部键。 #> <# .SYNOPSIS 列出哈希表或 JSON 对象的全部键。 #>
param($Map) param($Map)
if ($null -eq $Map) { return @() } if ($null -eq $Map) { return @() }
+1 -1
View File
@@ -1,4 +1,4 @@
function Get-BaknretMapValue { function Get-BakNRetMapValue {
<# .SYNOPSIS 从哈希表或 JSON 对象里按键取值。 #> <# .SYNOPSIS 从哈希表或 JSON 对象里按键取值。 #>
param($Map, [string]$Key) param($Map, [string]$Key)
if ($null -eq $Map) { return $null } if ($null -eq $Map) { return $null }
+2 -2
View File
@@ -1,4 +1,4 @@
function Import-BaknretDataFile { function Import-BakNRetDataFile {
<# <#
.SYNOPSIS .SYNOPSIS
读取 .psd1 / .json 配置数据。 读取 .psd1 / .json 配置数据。
@@ -23,7 +23,7 @@
} }
catch { catch {
$firstLine = ([string]$_.Exception.Message) -split "`r?`n" | Select-Object -First 1 $firstLine = ([string]$_.Exception.Message) -split "`r?`n" | Select-Object -First 1
Write-Log "psd1 里有 Import-PowerShellDataFile 不接受的表达式($firstLine),改用 PowerShell 求值:$Path" -Level DEBUG Write-BakNRetLog "psd1 里有 Import-PowerShellDataFile 不接受的表达式($firstLine),改用 PowerShell 求值:$Path" -Level DEBUG
$raw = [System.IO.File]::ReadAllText($Path) $raw = [System.IO.File]::ReadAllText($Path)
return [scriptblock]::Create($raw).Invoke() return [scriptblock]::Create($raw).Invoke()
} }
+1 -1
View File
@@ -18,5 +18,5 @@ $script:CatalogCache = @{}
$script:CatalogExpressionCache = @{} $script:CatalogExpressionCache = @{}
$script:BaknretPrivilegeState = @{} $script:BakNRetPrivilegeState = @{}
+1 -1
View File
@@ -1,4 +1,4 @@
function Test-BaknretMapKey { function Test-BakNRetMapKey {
<# .SYNOPSIS 判断一个数据对象(哈希表或 JSON 对象)里有没有某个键。 #> <# .SYNOPSIS 判断一个数据对象(哈希表或 JSON 对象)里有没有某个键。 #>
param($Map, [string]$Key) param($Map, [string]$Key)
if ($null -eq $Map) { return $false } if ($null -eq $Map) { return $false }
@@ -1,4 +1,4 @@
function Convert-BackupFileNameToPath { function Convert-BakNRetBackupFileNameToPath {
<# <#
.SYNOPSIS .SYNOPSIS
把归档文件名还原成原始路径(用于没有 manifest 时的兜底)。 把归档文件名还原成原始路径(用于没有 manifest 时的兜底)。
@@ -22,11 +22,11 @@
}) })
$reconstructed = ($parts -join '\') + '\' + $folderPart $reconstructed = ($parts -join '\') + '\' + $folderPart
Write-Log "逆向解析:$FileName -> $reconstructed" -Level DEBUG Write-BakNRetLog "逆向解析:$FileName -> $reconstructed" -Level DEBUG
return $reconstructed return $reconstructed
} }
catch { catch {
Write-Log "无法解析备份文件名:$FileName" -Level WARN Write-BakNRetLog "无法解析备份文件名:$FileName" -Level WARN
return $null return $null
} }
} }
+1 -1
View File
@@ -1,4 +1,4 @@
function Convert-BaknretSidMap { function Convert-BakNRetSidMap {
<# <#
.SYNOPSIS .SYNOPSIS
按 SID 映射表改写 SDDL 里的 SID(跨机恢复用)。 按 SID 映射表改写 SDDL 里的 SID(跨机恢复用)。
+22 -22
View File
@@ -42,7 +42,7 @@
if ([string]::IsNullOrEmpty($content)) { return $null } if ([string]::IsNullOrEmpty($content)) { return $null }
} }
$tokens = @(Split-BaknretToken -Text $content) $tokens = @(Split-BakNRetToken -Text $content)
if ($tokens.Count -eq 0) { return $null } if ($tokens.Count -eq 0) { return $null }
# 整行被一对引号包住是**历史写法**(`"C:\a b\CodeSpace :: X\"`)。 # 整行被一对引号包住是**历史写法**(`"C:\a b\CodeSpace :: X\"`)。
@@ -56,9 +56,9 @@
$last = $raw[$raw.Length - 1] $last = $raw[$raw.Length - 1]
if ($first -eq $last -and ($first -eq '"' -or $first -eq "'")) { if ($first -eq $last -and ($first -eq '"' -or $first -eq "'")) {
$inner = $raw.Substring(1, $raw.Length - 2) $inner = $raw.Substring(1, $raw.Length - 2)
foreach ($innerToken in @(Split-BaknretToken -Text $inner)) { foreach ($innerToken in @(Split-BakNRetToken -Text $inner)) {
if (Test-BaknretMarker -Token $innerToken) { if (Test-BakNRetMarker -Token $innerToken) {
Write-Log "整行被引号包住,引号里的修饰符不会被识别(历史写法)。请去掉外层引号,并注意现在 `:-` 才是排除、`::` 是覆盖 Path:$Line" -Level WARN Write-BakNRetLog "整行被引号包住,引号里的修饰符不会被识别(历史写法)。请去掉外层引号,并注意现在 `:-` 才是排除、`::` 是覆盖 Path:$Line" -Level WARN
break break
} }
} }
@@ -98,11 +98,11 @@
# 所以这里取"第一个修饰符记号之前的全部记号",而不是只取第一个记号。 # 所以这里取"第一个修饰符记号之前的全部记号",而不是只取第一个记号。
$firstMarker = -1 $firstMarker = -1
for ($index = 0; $index -lt $tokens.Count; $index++) { for ($index = 0; $index -lt $tokens.Count; $index++) {
if (Test-BaknretMarker -Token $tokens[$index]) { $firstMarker = $index; break } if (Test-BakNRetMarker -Token $tokens[$index]) { $firstMarker = $index; break }
} }
if ($firstMarker -eq 0) { if ($firstMarker -eq 0) {
Write-Log "清单行缺少目标,已忽略:$Line" -Level WARN Write-BakNRetLog "清单行缺少目标,已忽略:$Line" -Level WARN
return $null return $null
} }
@@ -115,7 +115,7 @@
$markerTokens = @($tokens[$firstMarker..($tokens.Count - 1)]) $markerTokens = @($tokens[$firstMarker..($tokens.Count - 1)])
} }
$target = Remove-BaknretQuote -Text $targetText $target = Remove-BakNRetQuote -Text $targetText
if ([string]::IsNullOrWhiteSpace($target)) { return $null } if ([string]::IsNullOrWhiteSpace($target)) { return $null }
$overrides = @{} $overrides = @{}
@@ -124,24 +124,24 @@
$index = 0 $index = 0
while ($index -lt $markerTokens.Count) { while ($index -lt $markerTokens.Count) {
$kind = Test-BaknretMarker -Token $markerTokens[$index] $kind = Test-BakNRetMarker -Token $markerTokens[$index]
$inline = $null $inline = $null
if ($kind -eq 'at') { $inline = $markerTokens[$index].Substring(1) } if ($kind -eq 'at') { $inline = $markerTokens[$index].Substring(1) }
$index++ $index++
$values = @() $values = @()
if (-not [string]::IsNullOrWhiteSpace($inline)) { $values += $inline } if (-not [string]::IsNullOrWhiteSpace($inline)) { $values += $inline }
while ($index -lt $markerTokens.Count -and -not (Test-BaknretMarker -Token $markerTokens[$index])) { while ($index -lt $markerTokens.Count -and -not (Test-BakNRetMarker -Token $markerTokens[$index])) {
$values += $markerTokens[$index] $values += $markerTokens[$index]
$index++ $index++
} }
switch ($kind) { switch ($kind) {
'path' { 'path' {
$value = Remove-BaknretQuote -Text ($values -join ' ') $value = Remove-BakNRetQuote -Text ($values -join ' ')
if (-not [string]::IsNullOrWhiteSpace($value)) { if (-not [string]::IsNullOrWhiteSpace($value)) {
if ($overrides.ContainsKey('Path')) { if ($overrides.ContainsKey('Path')) {
Write-Log "同一条目里给了多次路径覆盖,用最后一个:$Line" -Level WARN Write-BakNRetLog "同一条目里给了多次路径覆盖,用最后一个:$Line" -Level WARN
} }
$overrides['Path'] = $value $overrides['Path'] = $value
} }
@@ -149,31 +149,31 @@
# 同类记号可以出现多次(`Foo :- a :- b`),**累积**而不是后者覆盖前者: # 同类记号可以出现多次(`Foo :- a :- b`),**累积**而不是后者覆盖前者:
# 静默丢掉前一条排除规则正是这个工具最不该犯的错。 # 静默丢掉前一条排除规则正是这个工具最不该犯的错。
'exclude' { 'exclude' {
$parsed = @(ConvertFrom-BaknretPatternList -Values $values) $parsed = @(ConvertFrom-BakNRetPatternList -Values $values)
if ($overrides.ContainsKey('Exclude')) { $overrides['Exclude'] = @($overrides['Exclude']) + $parsed } if ($overrides.ContainsKey('Exclude')) { $overrides['Exclude'] = @($overrides['Exclude']) + $parsed }
else { $overrides['Exclude'] = $parsed } else { $overrides['Exclude'] = $parsed }
} }
'include' { 'include' {
$parsed = @(ConvertFrom-BaknretPatternList -Values $values) $parsed = @(ConvertFrom-BakNRetPatternList -Values $values)
if ($overrides.ContainsKey('Include')) { $overrides['Include'] = @($overrides['Include']) + $parsed } if ($overrides.ContainsKey('Include')) { $overrides['Include'] = @($overrides['Include']) + $parsed }
else { $overrides['Include'] = $parsed } else { $overrides['Include'] = $parsed }
} }
'encrypt' { 'encrypt' {
if ($overrides.ContainsKey('Encrypt')) { Write-Log "同一条目里给了多次加密开关,用最后一个:$Line" -Level WARN } if ($overrides.ContainsKey('Encrypt')) { Write-BakNRetLog "同一条目里给了多次加密开关,用最后一个:$Line" -Level WARN }
$overrides['Encrypt'] = $true $overrides['Encrypt'] = $true
} }
'noencrypt' { 'noencrypt' {
if ($overrides.ContainsKey('Encrypt')) { Write-Log "同一条目里给了多次加密开关,用最后一个:$Line" -Level WARN } if ($overrides.ContainsKey('Encrypt')) { Write-BakNRetLog "同一条目里给了多次加密开关,用最后一个:$Line" -Level WARN }
$overrides['Encrypt'] = $false $overrides['Encrypt'] = $false
} }
'at' { 'at' {
$text = Remove-BaknretQuote -Text ($values -join ' ') $text = Remove-BakNRetQuote -Text ($values -join ' ')
if ([string]::IsNullOrWhiteSpace($text)) { continue } if ([string]::IsNullOrWhiteSpace($text)) { continue }
$equals = $text.IndexOf('=') $equals = $text.IndexOf('=')
if ($equals -lt 0) { if ($equals -lt 0) {
# 兼容历史写法:`@encrypt` / `@!encrypt` / `@pathname` / `@root=名` # 兼容历史写法:`@encrypt` / `@!encrypt` / `@pathname` / `@root=名`
foreach ($legacy in @(ConvertFrom-BaknretPatternList -Values @($text))) { foreach ($legacy in @(ConvertFrom-BakNRetPatternList -Values @($text))) {
$name = $legacy.Trim().TrimStart('@') $name = $legacy.Trim().TrimStart('@')
if ($name -ieq 'encrypt') { $overrides['Encrypt'] = $true } if ($name -ieq 'encrypt') { $overrides['Encrypt'] = $true }
elseif ($name -ieq '!encrypt') { $overrides['Encrypt'] = $false } elseif ($name -ieq '!encrypt') { $overrides['Encrypt'] = $false }
@@ -183,16 +183,16 @@
} }
$key = $text.Substring(0, $equals).Trim() $key = $text.Substring(0, $equals).Trim()
$value = Remove-BaknretQuote -Text $text.Substring($equals + 1) $value = Remove-BakNRetQuote -Text $text.Substring($equals + 1)
switch -Regex ($key) { switch -Regex ($key) {
'(?i)^path$' { $overrides['Path'] = $value } '(?i)^path$' { $overrides['Path'] = $value }
'(?i)^exclude$' { '(?i)^exclude$' {
$parsed = @(ConvertFrom-BaknretPatternList -Values @($value)) $parsed = @(ConvertFrom-BakNRetPatternList -Values @($value))
if ($overrides.ContainsKey('Exclude')) { $overrides['Exclude'] = @($overrides['Exclude']) + $parsed } if ($overrides.ContainsKey('Exclude')) { $overrides['Exclude'] = @($overrides['Exclude']) + $parsed }
else { $overrides['Exclude'] = $parsed } else { $overrides['Exclude'] = $parsed }
} }
'(?i)^include$' { '(?i)^include$' {
$parsed = @(ConvertFrom-BaknretPatternList -Values @($value)) $parsed = @(ConvertFrom-BakNRetPatternList -Values @($value))
if ($overrides.ContainsKey('Include')) { $overrides['Include'] = @($overrides['Include']) + $parsed } if ($overrides.ContainsKey('Include')) { $overrides['Include'] = @($overrides['Include']) + $parsed }
else { $overrides['Include'] = $parsed } else { $overrides['Include'] = $parsed }
} }
@@ -205,7 +205,7 @@
} }
foreach ($unknown in $unknownKeys) { foreach ($unknown in $unknownKeys) {
Write-Log "清单里的 @ 字段 '$unknown' 不是已知字段(Path / Exclude / Include / Encrypt),已忽略:$Line" -Level WARN Write-BakNRetLog "清单里的 @ 字段 '$unknown' 不是已知字段(Path / Exclude / Include / Encrypt),已忽略:$Line" -Level WARN
} }
$resolvedExclude = @() $resolvedExclude = @()
@@ -218,7 +218,7 @@
Path = $target Path = $target
# 目录名或文件名,需要靠 SoftwareCatalog 换成真实路径; # 目录名或文件名,需要靠 SoftwareCatalog 换成真实路径;
# 带分隔符或 %变量% 的写法按字面路径处理。 # 带分隔符或 %变量% 的写法按字面路径处理。
IsName = (-not (Test-LiteralPath -Path $target)) IsName = (-not (Test-BakNRetLiteralPath -Path $target))
Overrides = $overrides Overrides = $overrides
ExcludePatterns = $resolvedExclude ExcludePatterns = $resolvedExclude
Includes = $resolvedInclude Includes = $resolvedInclude
@@ -1,4 +1,4 @@
function ConvertFrom-BaknretPatternList { function ConvertFrom-BakNRetPatternList {
<# <#
.SYNOPSIS .SYNOPSIS
把修饰符的值列表拼成字符串并按 `,` / `;` 拆成多个模式。 把修饰符的值列表拼成字符串并按 `,` / `;` 拆成多个模式。
@@ -7,7 +7,7 @@
$parts = @() $parts = @()
foreach ($value in @($Values)) { foreach ($value in @($Values)) {
$text = Remove-BaknretQuote -Text ([string]$value) $text = Remove-BakNRetQuote -Text ([string]$value)
if ([string]::IsNullOrWhiteSpace($text)) { continue } if ([string]::IsNullOrWhiteSpace($text)) { continue }
$parts += @($text -split '[,;]' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) $parts += @($text -split '[,;]' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
} }
@@ -1,4 +1,4 @@
function ConvertTo-NativeArgumentString { function ConvertTo-BakNRetNativeArgumentString {
<# <#
.SYNOPSIS .SYNOPSIS
按 Windows 的命令行引用规则,把参数数组拼成单个命令行字符串。 按 Windows 的命令行引用规则,把参数数组拼成单个命令行字符串。
@@ -1,10 +1,10 @@
function ConvertTo-BaknretWildcardPattern { function ConvertTo-BakNRetWildcardPattern {
<# <#
.SYNOPSIS .SYNOPSIS
把 7z 风格的通配符(* 与 ?)转成正则片段。 把 7z 风格的通配符(* 与 ?)转成正则片段。
.DESCRIPTION .DESCRIPTION
与 Get-BaknretExcludeArgument 保持一致:模式里的空格先转成 `?`(7z 的 与 Get-BakNRetExcludeArgument 保持一致:模式里的空格先转成 `?`(7z 的
`-x!` 不接受带空格的模式)。`*` 转 `.*`,跨过路径分隔符, `-x!` 不接受带空格的模式)。`*` 转 `.*`,跨过路径分隔符,
这样锚定模式 `Default\*` 才能命中 `Default\a\b`。 这样锚定模式 `Default\*` 才能命中 `Default\a\b`。
#> #>
+10 -10
View File
@@ -1,4 +1,4 @@
function Enable-BaknretPrivilege { function Enable-BakNRetPrivilege {
<# <#
.SYNOPSIS .SYNOPSIS
在当前进程令牌里启用指定特权,返回哪些没能启用。 在当前进程令牌里启用指定特权,返回哪些没能启用。
@@ -26,9 +26,9 @@
Failed = @() Failed = @()
} }
if (-not ('Baknret.Privileges' -as [type])) { if (-not ('BakNRet.Privileges' -as [type])) {
try { try {
Add-Type -Namespace Baknret -Name Privileges -MemberDefinition @' Add-Type -Namespace BakNRet -Name Privileges -MemberDefinition @'
[DllImport("advapi32.dll", SetLastError = true)] [DllImport("advapi32.dll", SetLastError = true)]
static extern bool OpenProcessToken(IntPtr h, int acc, out IntPtr phtok); static extern bool OpenProcessToken(IntPtr h, int acc, out IntPtr phtok);
[DllImport("advapi32.dll", SetLastError = true)] [DllImport("advapi32.dll", SetLastError = true)]
@@ -61,7 +61,7 @@ public static int Enable(string name) {
'@ '@
} }
catch { catch {
Write-Log "特权启用代码编译失败(本次不启用任何特权):$($_.Exception.Message)" -Level WARN Write-BakNRetLog "特权启用代码编译失败(本次不启用任何特权):$($_.Exception.Message)" -Level WARN
$result.Failed = @($Name) $result.Failed = @($Name)
return $result return $result
} }
@@ -70,12 +70,12 @@ public static int Enable(string name) {
$enabled = @(); $missing = @(); $failed = @() $enabled = @(); $missing = @(); $failed = @()
foreach ($privilege in @($Name)) { foreach ($privilege in @($Name)) {
$cacheKey = $privilege $cacheKey = $privilege
if ($script:BaknretPrivilegeState.ContainsKey($cacheKey)) { if ($script:BakNRetPrivilegeState.ContainsKey($cacheKey)) {
$state = $script:BaknretPrivilegeState[$cacheKey] $state = $script:BakNRetPrivilegeState[$cacheKey]
} }
else { else {
$state = [Baknret.Privileges]::Enable($privilege) $state = [BakNRet.Privileges]::Enable($privilege)
$script:BaknretPrivilegeState[$cacheKey] = $state $script:BakNRetPrivilegeState[$cacheKey] = $state
} }
switch ($state) { switch ($state) {
0 { $enabled += $privilege } 0 { $enabled += $privilege }
@@ -85,10 +85,10 @@ public static int Enable(string name) {
} }
if ($missing.Count -gt 0) { if ($missing.Count -gt 0) {
Write-Log ("这些特权不在当前令牌里(需要管理员或 SYSTEM):{0} —— 属主将无法改成别的账户,只能恢复 DACL" -f ($missing -join '、')) -Level WARN Write-BakNRetLog ("这些特权不在当前令牌里(需要管理员或 SYSTEM):{0} —— 属主将无法改成别的账户,只能恢复 DACL" -f ($missing -join '、')) -Level WARN
} }
if ($failed.Count -gt 0) { if ($failed.Count -gt 0) {
Write-Log ("这些特权启用失败:{0}" -f ($failed -join '、')) -Level WARN Write-BakNRetLog ("这些特权启用失败:{0}" -f ($failed -join '、')) -Level WARN
} }
$result.Enabled = @($enabled) $result.Enabled = @($enabled)
+4 -4
View File
@@ -1,4 +1,4 @@
function Enter-BaknretRunLock { function Enter-BakNRetRunLock {
<# <#
.SYNOPSIS .SYNOPSIS
取得"同一份备份目录同一时间只允许一个进程操作"的锁;拿不到时返回 $null。 取得"同一份备份目录同一时间只允许一个进程操作"的锁;拿不到时返回 $null。
@@ -8,7 +8,7 @@
"等它跑完"对用户来说和挂住没区别,不如直接说清楚是谁占着。 "等它跑完"对用户来说和挂住没区别,不如直接说清楚是谁占着。
返回的是一个已打开的文件流。**不需要刻意释放**:进程退出(含 exit)时句柄由系统 返回的是一个已打开的文件流。**不需要刻意释放**:进程退出(含 exit)时句柄由系统
关闭,锁随之释放。调用方仍应显式调 Exit-BaknretRunLock,让锁的覆盖范围一眼可见。 关闭,锁随之释放。调用方仍应显式调 Exit-BakNRetRunLock,让锁的覆盖范围一眼可见。
#> #>
param([Parameter(Mandatory = $true)][string]$Directory) param([Parameter(Mandatory = $true)][string]$Directory)
@@ -16,7 +16,7 @@
New-Item -ItemType Directory -Path $Directory -Force | Out-Null New-Item -ItemType Directory -Path $Directory -Force | Out-Null
} }
$path = Get-BaknretRunLockPath -Directory $Directory $path = Get-BakNRetRunLockPath -Directory $Directory
try { try {
$stream = [System.IO.File]::Open( $stream = [System.IO.File]::Open(
$path, $path,
@@ -34,7 +34,7 @@
while ($inner -and $inner -isnot [System.IO.IOException]) { $inner = $inner.InnerException } while ($inner -and $inner -isnot [System.IO.IOException]) { $inner = $inner.InnerException }
if (-not $inner) { throw } if (-not $inner) { throw }
Write-Log ("运行锁不可用({0}):{1}" -f $inner.GetType().Name, $inner.Message) -Level DEBUG Write-BakNRetLog ("运行锁不可用({0}):{1}" -f $inner.GetType().Name, $inner.Message) -Level DEBUG
return $null return $null
} }
+2 -2
View File
@@ -1,4 +1,4 @@
function Exit-BaknretRunLock { function Exit-BakNRetRunLock {
<# <#
.SYNOPSIS .SYNOPSIS
释放运行锁。锁文件本身留着 —— 它的内容是最后一次持有者的线索,删不删都无所谓。 释放运行锁。锁文件本身留着 —— 它的内容是最后一次持有者的线索,删不删都无所谓。
@@ -10,6 +10,6 @@
$Lock.Dispose() $Lock.Dispose()
} }
catch { catch {
Write-Log "释放运行锁失败(进程退出时会自动释放):$_" -Level WARN Write-BakNRetLog "释放运行锁失败(进程退出时会自动释放):$_" -Level WARN
} }
} }
+1 -1
View File
@@ -48,7 +48,7 @@
if ($null -ne $evaluated) { $replacement = ([string]($evaluated)).Trim() } if ($null -ne $evaluated) { $replacement = ([string]($evaluated)).Trim() }
} }
catch { catch {
Write-Log "名录路径里的表达式求值失败:$expression —— $($_.Exception.Message)" -Level WARN Write-BakNRetLog "名录路径里的表达式求值失败:$expression —— $($_.Exception.Message)" -Level WARN
} }
$value = $value.Substring(0, $start) + $replacement + $value.Substring($end + 1) $value = $value.Substring(0, $start) + $replacement + $value.Substring($end + 1)
} }
@@ -1,4 +1,4 @@
function Find-ChildDirectoryByName { function Find-BakNRetChildDirectoryByName {
<# <#
.SYNOPSIS .SYNOPSIS
在 $Parent 下按精确名或"<名>_<后缀>"/"<名>-<后缀>"形式找目录。 在 $Parent 下按精确名或"<名>_<后缀>"/"<名>-<后缀>"形式找目录。
@@ -1,4 +1,4 @@
function Format-CatalogName { function Format-BakNRetCatalogName {
<# <#
.SYNOPSIS .SYNOPSIS
把软件名规范化成合法的归档基础名。 把软件名规范化成合法的归档基础名。
@@ -1,4 +1,4 @@
function Get-ArchiveTopLevelNames { function Get-BakNRetArchiveTopLevelNames {
<# <#
.SYNOPSIS .SYNOPSIS
列出归档内的顶层条目名(用于确认多目录打包时每个目录都真的进去了)。 列出归档内的顶层条目名(用于确认多目录打包时每个目录都真的进去了)。
@@ -36,7 +36,7 @@
Select-Object -ExpandProperty Name) Select-Object -ExpandProperty Name)
} }
catch { catch {
Write-Log "无法清点归档内容(跳过顶层名核对):$($_.Exception.Message)" -Level DEBUG Write-BakNRetLog "无法清点归档内容(跳过顶层名核对):$($_.Exception.Message)" -Level DEBUG
$names = @() $names = @()
} }
finally { finally {
@@ -1,4 +1,4 @@
function Get-BackupBaseName { function Get-BakNRetBackupBaseName {
<# <#
.SYNOPSIS .SYNOPSIS
由清单中的原始路径生成归档基础名。 由清单中的原始路径生成归档基础名。
@@ -16,7 +16,7 @@
$parts = @($normalized -split '\\' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) $parts = @($normalized -split '\\' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
if ($parts.Count -eq 0) { if ($parts.Count -eq 0) {
Write-Log "无法解析路径:$RawPath" -Level ERROR Write-BakNRetLog "无法解析路径:$RawPath" -Level ERROR
return $null return $null
} }
@@ -39,6 +39,6 @@
}) })
$baseName = $baseName -replace ':', '_' $baseName = $baseName -replace ':', '_'
Write-Log "生成文件基础名:$baseName" -Level DEBUG Write-BakNRetLog "生成文件基础名:$baseName" -Level DEBUG
return $baseName return $baseName
} }
@@ -1,4 +1,4 @@
function Get-FolderSummary { function Get-BakNRetFolderSummary {
<# <#
.SYNOPSIS .SYNOPSIS
统计目录/文件的文件数、总大小与最新修改时间。 统计目录/文件的文件数、总大小与最新修改时间。
@@ -26,7 +26,7 @@
} }
} }
catch { catch {
Write-Log "无法读取文件夹摘要:$FolderPath" -Level WARN Write-BakNRetLog "无法读取文件夹摘要:$FolderPath" -Level WARN
return [pscustomobject]@{ return [pscustomobject]@{
FileCount = 0 FileCount = 0
TotalSize = 0 TotalSize = 0
@@ -1,4 +1,4 @@
function Get-ItemArchiveName { function Get-BakNRetItemArchiveName {
<# <#
.SYNOPSIS .SYNOPSIS
决定一个条目的归档基础名(不含扩展名)。 决定一个条目的归档基础名(不含扩展名)。
@@ -14,27 +14,27 @@
# @pathname 时用"真实路径"跑路径命名算法。 # @pathname 时用"真实路径"跑路径命名算法。
# 清单里写的可能是软件名,必须先经名录换成真实路径, # 清单里写的可能是软件名,必须先经名录换成真实路径,
# 否则 Get-BackupBaseName 会对软件名本身运算,得出错误的名字。 # 否则 Get-BakNRetBackupBaseName 会对软件名本身运算,得出错误的名字。
if ($Entry.Flags -contains 'pathname') { if ($Entry.Flags -contains 'pathname') {
$nameSource = $Entry.Path $nameSource = $Entry.Path
if (-not (Test-LiteralPath -Path $Entry.Path)) { if (-not (Test-BakNRetLiteralPath -Path $Entry.Path)) {
$catalogForPath = Get-SoftwareCatalog -Path $CatalogPath -MaxDepth $MaxDepth $catalogForPath = Get-BakNRetSoftwareCatalog -Path $CatalogPath -MaxDepth $MaxDepth
if ($catalogForPath.ContainsKey($Entry.Path)) { if ($catalogForPath.ContainsKey($Entry.Path)) {
$nameSource = $catalogForPath[$Entry.Path].Path $nameSource = $catalogForPath[$Entry.Path].Path
} }
} }
return Get-BackupBaseName -RawPath $nameSource return Get-BakNRetBackupBaseName -RawPath $nameSource
} }
$looksLikePath = Test-LiteralPath -Path $Entry.Path $looksLikePath = Test-BakNRetLiteralPath -Path $Entry.Path
if (-not $looksLikePath) { if (-not $looksLikePath) {
$catalog = Get-SoftwareCatalog -Path $CatalogPath -MaxDepth $MaxDepth $catalog = Get-BakNRetSoftwareCatalog -Path $CatalogPath -MaxDepth $MaxDepth
if ($catalog.ContainsKey($Entry.Path)) { if ($catalog.ContainsKey($Entry.Path)) {
return $catalog[$Entry.Path].Name return $catalog[$Entry.Path].Name
} }
Write-Log "名录里没有 '$($Entry.Path)',按目录名处理" -Level WARN Write-BakNRetLog "名录里没有 '$($Entry.Path)',按目录名处理" -Level WARN
return (Format-CatalogName -Name $Entry.Path) return (Format-BakNRetCatalogName -Name $Entry.Path)
} }
return Get-BackupBaseName -RawPath $Entry.Path return Get-BakNRetBackupBaseName -RawPath $Entry.Path
} }
@@ -1,4 +1,4 @@
function Get-SoftwareCatalog { function Get-BakNRetSoftwareCatalog {
<# <#
.SYNOPSIS .SYNOPSIS
载入"软件名 -> Slot 组"名录。 载入"软件名 -> Slot 组"名录。
@@ -44,7 +44,7 @@
if (-not $NoCache) { if (-not $NoCache) {
try { try {
$item = Get-Item -LiteralPath $Path -ErrorAction Stop $item = Get-Item -LiteralPath $Path -ErrorAction Stop
$hash = (Get-FileHash -LiteralPath $Path -Algorithm MD5 -ErrorAction Stop).Hash $hash = (Get-FileHash -LiteralPath $Path -Algorithm SHA256 -ErrorAction Stop).Hash
$stamp = '{0}-{1}-{2}' -f $item.LastWriteTimeUtc.Ticks, $item.Length, $hash $stamp = '{0}-{1}-{2}' -f $item.LastWriteTimeUtc.Ticks, $item.Length, $hash
if ($script:CatalogCache.ContainsKey($Path) -and $script:CatalogCache[$Path].Stamp -eq $stamp) { if ($script:CatalogCache.ContainsKey($Path) -and $script:CatalogCache[$Path].Stamp -eq $stamp) {
return $script:CatalogCache[$Path].Data return $script:CatalogCache[$Path].Data
@@ -57,22 +57,22 @@
$data = $null $data = $null
try { try {
$data = Import-BaknretDataFile -Path $Path $data = Import-BakNRetDataFile -Path $Path
} }
catch { catch {
Write-Log "软件名录读取失败:$Path —— $_" -Level ERROR Write-BakNRetLog "软件名录读取失败:$Path —— $_" -Level ERROR
return $result return $result
} }
# 递归引入其它名录文件(路径相对本文件) # 递归引入其它名录文件(路径相对本文件)
$includeValue = Get-BaknretMapValue -Map $data -Key 'Includes' $includeValue = Get-BakNRetMapValue -Map $data -Key 'Includes'
if ($includeValue) { if ($includeValue) {
$baseDir = Split-Path -Parent $Path $baseDir = Split-Path -Parent $Path
foreach ($include in @($includeValue)) { foreach ($include in @($includeValue)) {
if (-not $include) { continue } if (-not $include) { continue }
$includePath = [string]$include $includePath = [string]$include
if (-not [System.IO.Path]::IsPathRooted($includePath)) { $includePath = Join-Path $baseDir $includePath } if (-not [System.IO.Path]::IsPathRooted($includePath)) { $includePath = Join-Path $baseDir $includePath }
$included = Get-SoftwareCatalog -Path $includePath -MaxDepth $MaxDepth $included = Get-BakNRetSoftwareCatalog -Path $includePath -MaxDepth $MaxDepth
foreach ($includedName in $included.Keys) { foreach ($includedName in $included.Keys) {
if ($result.ContainsKey($includedName)) { continue } if ($result.ContainsKey($includedName)) { continue }
$result[$includedName] = $included[$includedName] $result[$includedName] = $included[$includedName]
@@ -80,30 +80,30 @@
} }
} }
foreach ($key in @(Get-BaknretMapKeys -Map $data | Where-Object { $_ -ne 'Includes' })) { foreach ($key in @(Get-BakNRetMapKeys -Map $data | Where-Object { $_ -ne 'Includes' })) {
$name = Format-CatalogName -Name ([string]$key) $name = Format-BakNRetCatalogName -Name ([string]$key)
if (-not $name) { continue } if (-not $name) { continue }
$raw = Get-BaknretMapValue -Map $data -Key $key $raw = Get-BakNRetMapValue -Map $data -Key $key
if ($raw -isnot [System.Collections.IDictionary] -and $null -ne $raw -and -not ($raw -is [psobject] -and @($raw.PSObject.Properties.Name).Count -gt 0)) { if ($raw -isnot [System.Collections.IDictionary] -and $null -ne $raw -and -not ($raw -is [psobject] -and @($raw.PSObject.Properties.Name).Count -gt 0)) {
Write-Log "名录条目 '$key' 格式不对:应写成 @{ <Slot 名> = @{ Path = '...' } }" -Level ERROR Write-BakNRetLog "名录条目 '$key' 格式不对:应写成 @{ <Slot 名> = @{ Path = '...' } }" -Level ERROR
continue continue
} }
$slots = @() $slots = @()
$errors = @() $errors = @()
foreach ($slotKey in @(Get-BaknretMapKeys -Map $raw)) { foreach ($slotKey in @(Get-BakNRetMapKeys -Map $raw)) {
$slotName = ([string]$slotKey).Trim() $slotName = ([string]$slotKey).Trim()
if (-not $slotName) { continue } if (-not $slotName) { continue }
$slotRaw = Get-BaknretMapValue -Map $raw -Key $slotKey $slotRaw = Get-BakNRetMapValue -Map $raw -Key $slotKey
if ($slotRaw -isnot [System.Collections.IDictionary] -and -not ($slotRaw -is [psobject])) { if ($slotRaw -isnot [System.Collections.IDictionary] -and -not ($slotRaw -is [psobject])) {
$errors += "Slot $slotName 的写法不对,应写成 @{ Path = '...' }" $errors += "Slot $slotName 的写法不对,应写成 @{ Path = '...' }"
continue continue
} }
$declaredRaw = [string](Get-BaknretMapValue -Map $slotRaw -Key 'Path') $declaredRaw = [string](Get-BakNRetMapValue -Map $slotRaw -Key 'Path')
if ([string]::IsNullOrWhiteSpace($declaredRaw)) { if ([string]::IsNullOrWhiteSpace($declaredRaw)) {
$errors += "Slot $slotName 缺少 Path" $errors += "Slot $slotName 缺少 Path"
continue continue
@@ -125,7 +125,7 @@
$parent = Split-Path -Path $declared -Parent $parent = Split-Path -Path $declared -Parent
$leafName = Split-Path -Path $declared -Leaf $leafName = Split-Path -Path $declared -Leaf
if ($parent -and $leafName -and (Test-Path -LiteralPath $parent)) { if ($parent -and $leafName -and (Test-Path -LiteralPath $parent)) {
$candidates = @(Find-ChildDirectoryByName -Parent $parent -Name $leafName -MaxDepth $MaxDepth) $candidates = @(Find-BakNRetChildDirectoryByName -Parent $parent -Name $leafName -MaxDepth $MaxDepth)
} }
} }
@@ -144,10 +144,10 @@
if ($resolvedItem) { $isFile = -not $resolvedItem.PSIsContainer } if ($resolvedItem) { $isFile = -not $resolvedItem.PSIsContainer }
} }
$excludeText = [string](Get-BaknretMapValue -Map $slotRaw -Key 'Exclude') $excludeText = [string](Get-BakNRetMapValue -Map $slotRaw -Key 'Exclude')
$includeText = [string](Get-BaknretMapValue -Map $slotRaw -Key 'Include') $includeText = [string](Get-BakNRetMapValue -Map $slotRaw -Key 'Include')
$encryptValue = Get-BaknretMapValue -Map $slotRaw -Key 'Encrypt' $encryptValue = Get-BakNRetMapValue -Map $slotRaw -Key 'Encrypt'
$description = [string](Get-BaknretMapValue -Map $slotRaw -Key 'Description') $description = [string](Get-BakNRetMapValue -Map $slotRaw -Key 'Description')
$slots += [pscustomobject]@{ $slots += [pscustomobject]@{
Name = $slotName Name = $slotName
@@ -157,8 +157,8 @@
IsFile = $isFile IsFile = $isFile
Suffixed = $suffixed Suffixed = $suffixed
Description = $description Description = $description
Exclude = @(ConvertFrom-BaknretPatternList -Values @($excludeText)) Exclude = @(ConvertFrom-BakNRetPatternList -Values @($excludeText))
Include = @(ConvertFrom-BaknretPatternList -Values @($includeText)) Include = @(ConvertFrom-BakNRetPatternList -Values @($includeText))
Encrypt = [bool]$encryptValue Encrypt = [bool]$encryptValue
} }
} }
@@ -178,18 +178,18 @@
else { 'Single' } else { 'Single' }
if ($errors.Count -gt 0) { if ($errors.Count -gt 0) {
Write-Log ("名录条目 {0} 有问题:{1}" -f $name, ($errors -join ';')) -Level ERROR Write-BakNRetLog ("名录条目 {0} 有问题:{1}" -f $name, ($errors -join ';')) -Level ERROR
} }
elseif ($missing.Count -gt 0) { elseif ($missing.Count -gt 0) {
Write-Log ("名录:{0} 有 {1} 个 Slot 的路径当前不存在:{2}" -f $name, $missing.Count, (($missing | ForEach-Object { $_.Declared }) -join ';')) -Level DEBUG Write-BakNRetLog ("名录:{0} 有 {1} 个 Slot 的路径当前不存在:{2}" -f $name, $missing.Count, (($missing | ForEach-Object { $_.Declared }) -join ';')) -Level DEBUG
} }
if ($slots.Count -gt 0) { if ($slots.Count -gt 0) {
Write-Log ("名录:{0} -> {1} 个 Slot,其中存在 {2} 个" -f $name, $slots.Count, $existing.Count) -Level DEBUG Write-BakNRetLog ("名录:{0} -> {1} 个 Slot,其中存在 {2} 个" -f $name, $slots.Count, $existing.Count) -Level DEBUG
} }
if ($result.ContainsKey($name)) { if ($result.ContainsKey($name)) {
Write-Log ("名录里有两条规范化之后同名的条目:{0}(后者覆盖前者)" -f $name) -Level WARN Write-BakNRetLog ("名录里有两条规范化之后同名的条目:{0}(后者覆盖前者)" -f $name) -Level WARN
} }
$result[$name] = [pscustomobject]@{ $result[$name] = [pscustomobject]@{
@@ -1,4 +1,4 @@
function Get-BaknretAceSignatureList { function Get-BakNRetAceSignatureList {
<# <#
.SYNOPSIS .SYNOPSIS
把 ACE 列表压成可比对的"签名"集合(`类型|SID|掩码`)。 把 ACE 列表压成可比对的"签名"集合(`类型|SID|掩码`)。
+1 -1
View File
@@ -1,4 +1,4 @@
function Get-BaknretArchiveTopName { function Get-BakNRetArchiveTopName {
<# .SYNOPSIS 取归档内相对路径的第一段(顶层名字)。 #> <# .SYNOPSIS 取归档内相对路径的第一段(顶层名字)。 #>
param([AllowEmptyString()][string]$ArchivePath) param([AllowEmptyString()][string]$ArchivePath)
+4 -4
View File
@@ -1,4 +1,4 @@
function Get-BaknretConfig { function Get-BakNRetConfig {
<# <#
.SYNOPSIS .SYNOPSIS
读取 BackupConfig.psd1 并与内置默认值合并。 读取 BackupConfig.psd1 并与内置默认值合并。
@@ -22,7 +22,7 @@
Snapshot = @{ Enabled = $false; KeepCount = 3; KeepDays = 30 } Snapshot = @{ Enabled = $false; KeepCount = 3; KeepDays = 30 }
Encryption = @{ Enabled = $false; PasswordFile = ''; EncryptHeaders = $true } Encryption = @{ Enabled = $false; PasswordFile = ''; EncryptHeaders = $true }
# 安全描述符(属主 / ACL)的采集与回放。 # 安全描述符(属主 / ACL)的采集与回放。
# Mode Off | Roots | Smart | Full(语义见 Get-BaknretSecurityRecords) # Mode Off | Roots | Smart | Full(语义见 Get-BakNRetSecurityRecords)
# **默认 Full**:这个功能存在的意义就是不丢权限,正确性优先于体积; # **默认 Full**:这个功能存在的意义就是不丢权限,正确性优先于体积;
# Smart 是体积优化(靠继承复现的对象不落盘),已在真机上见过 # Smart 是体积优化(靠继承复现的对象不落盘),已在真机上见过
# 它需要处理的"陈旧继承 ACE",判据偏保守,但终究是启发式。 # 它需要处理的"陈旧继承 ACE",判据偏保守,但终究是启发式。
@@ -43,10 +43,10 @@
} }
try { try {
$loaded = Import-BaknretDataFile -Path $Path $loaded = Import-BakNRetDataFile -Path $Path
} }
catch { catch {
Write-Log "配置文件读取失败(改用默认值):$Path —— $_" -Level WARN Write-BakNRetLog "配置文件读取失败(改用默认值):$Path —— $_" -Level WARN
return $defaults return $defaults
} }
@@ -1,15 +1,15 @@
function Get-BaknretExcludeArgument { function Get-BakNRetExcludeArgument {
<# <#
.SYNOPSIS .SYNOPSIS
把一个归档项的模式列表翻译成 7z 的 `-x!` / `-xr!` 参数。 把一个归档项的模式列表翻译成 7z 的 `-x!` / `-xr!` 参数。
.DESCRIPTION .DESCRIPTION
传进来的模式**已经按项分配好**(见 Split-BaknretPatternScope),因此这里 传进来的模式**已经按项分配好**(见 Split-BakNRetPatternScope),因此这里
拿到的模式一律是"相对该项归档根"的: 拿到的模式一律是"相对该项归档根"的:
* `<相对路径>` -> `-x!<ArchivePath>\<相对路径>`(锚定在归档根) * `<相对路径>` -> `-x!<ArchivePath>\<相对路径>`(锚定在归档根)
* `!<通配>` -> `-xr!<通配>`(任意层级,模式里的空格自动转 `?`) * `!<通配>` -> `-xr!<通配>`(任意层级,模式里的空格自动转 `?`)
* `!re:<正则>` -> 遍历源目录翻译成若干 `-x!<完整路径>`(见 Get-BaknretRegexExclude) * `!re:<正则>` -> 遍历源目录翻译成若干 `-x!<完整路径>`(见 Get-BakNRetRegexExclude)
7z 排除语义(已实测确认): 7z 排除语义(已实测确认):
* `-x!<完整归档内路径>` 匹配对象的完整路径,所以要带上项自己的归档根名; * `-x!<完整归档内路径>` 匹配对象的完整路径,所以要带上项自己的归档根名;
@@ -33,7 +33,7 @@
if ($text.StartsWith('!re:')) { if ($text.StartsWith('!re:')) {
$regexText = $text.Substring(4).Trim() $regexText = $text.Substring(4).Trim()
if (-not $regexText) { continue } if (-not $regexText) { continue }
$expanded = Get-BaknretRegexExclude -Item $Item -Pattern $regexText -MaxMatches $MaxRegexMatches $expanded = Get-BakNRetRegexExclude -Item $Item -Pattern $regexText -MaxMatches $MaxRegexMatches
if ($expanded.Error) { $errorText = $expanded.Error; continue } if ($expanded.Error) { $errorText = $expanded.Error; continue }
$arguments += @($expanded.Arguments) $arguments += @($expanded.Arguments)
continue continue
+1 -1
View File
@@ -1,4 +1,4 @@
function Get-BaknretFreeSpaceGB { function Get-BakNRetFreeSpaceGB {
<# <#
.SYNOPSIS .SYNOPSIS
返回 $Path 所在卷的剩余空间(GB);无法确定时返回 -1。 返回 $Path 所在卷的剩余空间(GB);无法确定时返回 -1。
+1 -1
View File
@@ -1,4 +1,4 @@
function Get-BaknretLogPath { function Get-BakNRetLogPath {
<# .SYNOPSIS 返回当前日志文件路径(未启用时返回 $null)。 #> <# .SYNOPSIS 返回当前日志文件路径(未启用时返回 $null)。 #>
return $script:LogConfig.FilePath return $script:LogConfig.FilePath
} }
+3 -3
View File
@@ -1,4 +1,4 @@
function Get-BaknretPassword { function Get-BakNRetPassword {
<# <#
.SYNOPSIS .SYNOPSIS
取加密口令:命令行参数 > 环境变量 > 密码文件 > 交互式询问。 取加密口令:命令行参数 > 环境变量 > 密码文件 > 交互式询问。
@@ -34,7 +34,7 @@
try { $interactive = -not [System.Console]::IsInputRedirected } catch { $interactive = $false } try { $interactive = -not [System.Console]::IsInputRedirected } catch { $interactive = $false }
if ($interactive) { if ($interactive) {
Write-Log '需要加密口令,请在弹出的提示里输入(不会回显、不会落盘)' -Level WARN Write-BakNRetLog '需要加密口令,请在弹出的提示里输入(不会回显、不会落盘)' -Level WARN
try { try {
$secure = Read-Host -Prompt '请输入加密口令' -AsSecureString $secure = Read-Host -Prompt '请输入加密口令' -AsSecureString
$bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure) $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure)
@@ -46,7 +46,7 @@
} }
} }
catch { catch {
Write-Log "口令输入失败:$_" -Level ERROR Write-BakNRetLog "口令输入失败:$_" -Level ERROR
return $null return $null
} }
} }
+1 -1
View File
@@ -1,4 +1,4 @@
function Get-BaknretRegexExclude { function Get-BakNRetRegexExclude {
<# <#
.SYNOPSIS .SYNOPSIS
把一条 `!re:<正则>` 展开成若干 `-x!<归档内路径>` 参数。 把一条 `!re:<正则>` 展开成若干 `-x!<归档内路径>` 参数。
+1 -1
View File
@@ -1,4 +1,4 @@
function Get-BaknretRunLockPath { function Get-BakNRetRunLockPath {
<# .SYNOPSIS 运行锁文件的位置(放在备份目录里,与它保护的账本同处)。 #> <# .SYNOPSIS 运行锁文件的位置(放在备份目录里,与它保护的账本同处)。 #>
param([Parameter(Mandatory = $true)][string]$Directory) param([Parameter(Mandatory = $true)][string]$Directory)
return (Join-Path $Directory '.baknret.lock') return (Join-Path $Directory '.baknret.lock')
+5 -5
View File
@@ -1,4 +1,4 @@
function Get-BaknretSecurityRecord { function Get-BakNRetSecurityRecord {
<# <#
.SYNOPSIS .SYNOPSIS
读一个对象的安全描述符,产出可序列化的一条记录。 读一个对象的安全描述符,产出可序列化的一条记录。
@@ -58,8 +58,8 @@
} }
try { try {
# 陈旧继承 ACE 要固化成显式 ACE,否则恢复后会消失(见 Get-BaknretSecuritySddlWithStale) # 陈旧继承 ACE 要固化成显式 ACE,否则恢复后会消失(见 Get-BakNRetSecuritySddlWithStale)
$record.s = Get-BaknretSecuritySddlWithStale -Acl $acl -ParentSignatures $ParentSignatures $record.s = Get-BakNRetSecuritySddlWithStale -Acl $acl -ParentSignatures $ParentSignatures
} }
catch { catch {
$record.e = $_.Exception.Message $record.e = $_.Exception.Message
@@ -80,8 +80,8 @@
$inheritedRules = @($acl.GetAccessRules($false, $true, $sid)) $inheritedRules = @($acl.GetAccessRules($false, $true, $sid))
$record.Explicit = $explicitRules.Count $record.Explicit = $explicitRules.Count
$record.Inherited = $inheritedRules.Count $record.Inherited = $inheritedRules.Count
$record.InheritedSignatures = @(Get-BaknretAceSignatureList -Rules $inheritedRules) $record.InheritedSignatures = @(Get-BakNRetAceSignatureList -Rules $inheritedRules)
$record.AllSignatures = @(Get-BaknretAceSignatureList -Rules @($acl.GetAccessRules($true, $true, $sid))) $record.AllSignatures = @(Get-BakNRetAceSignatureList -Rules @($acl.GetAccessRules($true, $true, $sid)))
$inheritable = 0 $inheritable = 0
foreach ($rule in @($acl.GetAccessRules($true, $true, $sid))) { foreach ($rule in @($acl.GetAccessRules($true, $true, $sid))) {
@@ -1,4 +1,4 @@
function Get-BaknretSecurityRecords { function Get-BakNRetSecurityRecords {
<# <#
.SYNOPSIS .SYNOPSIS
采集一组归档项的安全描述符,键是**归档内相对路径**(`<Slot>\…`)。 采集一组归档项的安全描述符,键是**归档内相对路径**(`<Slot>\…`)。
@@ -10,7 +10,7 @@
遍历用显式栈,并且**跳过 reparse point**:PS 5.1 的 Get-ChildItem -Recurse 会 遍历用显式栈,并且**跳过 reparse point**:PS 5.1 的 Get-ChildItem -Recurse 会
跟着 junction 无限转;scoop 的 `apps\<app>\current` 就是 junction,正撞在这个坑上。 跟着 junction 无限转;scoop 的 `apps\<app>\current` 就是 junction,正撞在这个坑上。
$ScopeMap 由 Split-BaknretPatternScope 产出(项下标 -> 该相对根的模式数组), $ScopeMap 由 Split-BakNRetPatternScope 产出(项下标 -> 该相对根的模式数组),
所以这里的排除判定与真正交给 7z 的 -x! / -xr! 是同一套规则。 所以这里的排除判定与真正交给 7z 的 -x! / -xr! 是同一套规则。
Mode: Mode:
@@ -37,7 +37,7 @@
# 读不到的对象会带 e 字段落进 sidecar,而不是被静默当成"没有特殊权限"。 # 读不到的对象会带 e 字段落进 sidecar,而不是被静默当成"没有特殊权限"。
$privileges = @('SeBackupPrivilege') $privileges = @('SeBackupPrivilege')
if ($IncludeSacl) { $privileges += 'SeSecurityPrivilege' } if ($IncludeSacl) { $privileges += 'SeSecurityPrivilege' }
Enable-BaknretPrivilege -Name $privileges | Out-Null Enable-BakNRetPrivilege -Name $privileges | Out-Null
for ($index = 0; $index -lt $Items.Count; $index++) { for ($index = 0; $index -lt $Items.Count; $index++) {
$item = $Items[$index] $item = $Items[$index]
@@ -55,14 +55,14 @@
if (-not $rootItem) { continue } if (-not $rootItem) { continue }
if (-not $rootItem.PSIsContainer) { if (-not $rootItem.PSIsContainer) {
$record = Get-BaknretSecurityRecord -Path $real -Key $archiveRoot -Kind 'f' -IncludeSacl:$IncludeSacl $record = Get-BakNRetSecurityRecord -Path $real -Key $archiveRoot -Kind 'f' -IncludeSacl:$IncludeSacl
$scanned++ $scanned++
if ($record.e) { $errorCount++ } if ($record.e) { $errorCount++ }
$records.Add($record) $records.Add($record)
continue continue
} }
$rootRecord = Get-BaknretSecurityRecord -Path $real -Key $archiveRoot -Kind 'd' -IncludeSacl:$IncludeSacl $rootRecord = Get-BakNRetSecurityRecord -Path $real -Key $archiveRoot -Kind 'd' -IncludeSacl:$IncludeSacl
$scanned++ $scanned++
if ($rootRecord.e) { $errorCount++ } if ($rootRecord.e) { $errorCount++ }
$records.Add($rootRecord) $records.Add($rootRecord)
@@ -85,10 +85,10 @@
if ($child.Attributes -band [IO.FileAttributes]::ReparsePoint) { continue } if ($child.Attributes -band [IO.FileAttributes]::ReparsePoint) { continue }
$childRel = if ($frame.Rel) { $frame.Rel + '\' + $child.Name } else { $child.Name } $childRel = if ($frame.Rel) { $frame.Rel + '\' + $child.Name } else { $child.Name }
if (Test-BaknretPathExcluded -RelativePath $childRel -Patterns $patterns) { continue } if (Test-BakNRetPathExcluded -RelativePath $childRel -Patterns $patterns) { continue }
$kind = if ($child.PSIsContainer) { 'd' } else { 'f' } $kind = if ($child.PSIsContainer) { 'd' } else { 'f' }
$record = Get-BaknretSecurityRecord -Path $child.FullName -Key ($archiveRoot + '\' + $childRel) ` $record = Get-BakNRetSecurityRecord -Path $child.FullName -Key ($archiveRoot + '\' + $childRel) `
-Kind $kind -IncludeSacl:$IncludeSacl -ParentSignatures $frame.Signatures -Kind $kind -IncludeSacl:$IncludeSacl -ParentSignatures $frame.Signatures
$scanned++ $scanned++
if ($record.e) { $errorCount++ } if ($record.e) { $errorCount++ }
@@ -96,7 +96,7 @@
if ($Mode -eq 'Full') { if ($Mode -eq 'Full') {
$records.Add($record) $records.Add($record)
} }
elseif (Test-BaknretSecurityRecordNeeded -Record $record ` elseif (Test-BakNRetSecurityRecordNeeded -Record $record `
-ParentOwner $frame.Owner -ParentGroup $frame.Group ` -ParentOwner $frame.Owner -ParentGroup $frame.Group `
-ParentInheritable $frame.Inheritable -ParentSignatures $frame.Signatures) { -ParentInheritable $frame.Inheritable -ParentSignatures $frame.Signatures) {
$records.Add($record) $records.Add($record)
@@ -1,4 +1,4 @@
function Get-BaknretSecuritySddlWithStale { function Get-BakNRetSecuritySddlWithStale {
<# <#
.SYNOPSIS .SYNOPSIS
对象与父目录的继承链**不自洽**时,把整套 ACE 冻结成显式副本(并置 protected), 对象与父目录的继承链**不自洽**时,把整套 ACE 冻结成显式副本(并置 protected),
@@ -38,7 +38,7 @@
# 自洽 = 继承来的 ACE 每一条都能在父目录的 ACL 里找到出处 # 自洽 = 继承来的 ACE 每一条都能在父目录的 ACL 里找到出处
$stale = @() $stale = @()
foreach ($rule in $inherited) { foreach ($rule in $inherited) {
$signature = @(Get-BaknretAceSignatureList -Rules @($rule))[0] $signature = @(Get-BakNRetAceSignatureList -Rules @($rule))[0]
if ($ParentSignatures -notcontains $signature) { $stale += $rule } if ($ParentSignatures -notcontains $signature) { $stale += $rule }
} }
if ($stale.Count -eq 0) { return $Acl.Sddl } if ($stale.Count -eq 0) { return $Acl.Sddl }
@@ -68,6 +68,6 @@
$rebuilt.SetAccessRuleProtection($true, $false) $rebuilt.SetAccessRuleProtection($true, $false)
Write-Log ("{0} 条继承 ACE 已无出处(父目录里找不到),整套 ACE 冻结为显式并置 protected" -f $stale.Count) -Level DEBUG Write-BakNRetLog ("{0} 条继承 ACE 已无出处(父目录里找不到),整套 ACE 冻结为显式并置 protected" -f $stale.Count) -Level DEBUG
return $rebuilt.GetSecurityDescriptorSddlForm($sections) return $rebuilt.GetSecurityDescriptorSddlForm($sections)
} }
+3 -3
View File
@@ -27,12 +27,12 @@
$fileCount++ $fileCount++
$totalSize += [int64]$item.Length $totalSize += [int64]$item.Length
} }
Write-Log ("分析路径 '{0}':已累计 {1} 个文件,{2} MB" -f $path, $fileCount, [math]::Round($totalSize / 1MB, 2)) -Level DEBUG Write-BakNRetLog ("分析路径 '{0}':已累计 {1} 个文件,{2} MB" -f $path, $fileCount, [math]::Round($totalSize / 1MB, 2)) -Level DEBUG
} }
if ($null -eq $totalSize) { $totalSize = 0 } if ($null -eq $totalSize) { $totalSize = 0 }
$totalSizeMB = [math]::Round($totalSize / 1MB, 2) $totalSizeMB = [math]::Round($totalSize / 1MB, 2)
Write-Log ("合计分析:{0} 个文件,总大小 {1} MB" -f $fileCount, $totalSizeMB) -Level DEBUG Write-BakNRetLog ("合计分析:{0} 个文件,总大小 {1} MB" -f $fileCount, $totalSizeMB) -Level DEBUG
if ($totalSizeMB -gt 1024) { $dictSize = '1024m' } if ($totalSizeMB -gt 1024) { $dictSize = '1024m' }
elseif ($totalSizeMB -gt 100) { $dictSize = '256m' } elseif ($totalSizeMB -gt 100) { $dictSize = '256m' }
@@ -47,7 +47,7 @@
$threads = 2 $threads = 2
} }
Write-Log ("参数优化:字典=$dictSize, 线程=$threads, 级别=$Level") -Level DEBUG Write-BakNRetLog ("参数优化:字典=$dictSize, 线程=$threads, 级别=$Level") -Level DEBUG
return [pscustomobject]@{ return [pscustomobject]@{
# 只放压缩相关开关。输出开关(-bso0/-bsp0 或默认进度)必须由调用方 # 只放压缩相关开关。输出开关(-bso0/-bsp0 或默认进度)必须由调用方
+2 -2
View File
@@ -20,7 +20,7 @@
$startInfo = New-Object System.Diagnostics.ProcessStartInfo $startInfo = New-Object System.Diagnostics.ProcessStartInfo
$startInfo.FileName = $FilePath $startInfo.FileName = $FilePath
$startInfo.Arguments = ConvertTo-NativeArgumentString -ArgumentList $ArgumentList $startInfo.Arguments = ConvertTo-BakNRetNativeArgumentString -ArgumentList $ArgumentList
$startInfo.UseShellExecute = $false $startInfo.UseShellExecute = $false
$startInfo.CreateNoWindow = $false $startInfo.CreateNoWindow = $false
if ($WorkingDirectory) { if ($WorkingDirectory) {
@@ -37,7 +37,7 @@
$loggableArguments = @($ArgumentList | ForEach-Object { $loggableArguments = @($ArgumentList | ForEach-Object {
if ($_ -is [string] -and $_ -like '-p*') { '-p<口令已隐藏>' } else { $_ } if ($_ -is [string] -and $_ -like '-p*') { '-p<口令已隐藏>' } else { $_ }
}) })
Write-Log ('执行: {0} {1}' -f $FilePath, (ConvertTo-NativeArgumentString -ArgumentList $loggableArguments)) -Level DEBUG Write-BakNRetLog ('执行: {0} {1}' -f $FilePath, (ConvertTo-BakNRetNativeArgumentString -ArgumentList $loggableArguments)) -Level DEBUG
$process = [System.Diagnostics.Process]::Start($startInfo) $process = [System.Diagnostics.Process]::Start($startInfo)
try { try {
@@ -1,4 +1,4 @@
function Merge-BaknretExcludeArgument { function Merge-BakNRetExcludeArgument {
<# <#
.SYNOPSIS .SYNOPSIS
合并多个归档项展开出来的排除参数并去重(保序)。 合并多个归档项展开出来的排除参数并去重(保序)。
@@ -1,4 +1,4 @@
function Move-BaknretArchiveIntoPlace { function Move-BakNRetArchiveIntoPlace {
<# <#
.SYNOPSIS .SYNOPSIS
把临时归档原子地替换到最终路径。 把临时归档原子地替换到最终路径。
@@ -23,7 +23,7 @@
return return
} }
catch { catch {
Write-Log "File.Move(overwrite) 不可用(5.1 没有这个重载),改用 File.Replace:$_" -Level DEBUG Write-BakNRetLog "File.Move(overwrite) 不可用(5.1 没有这个重载),改用 File.Replace:$_" -Level DEBUG
} }
# 5.1 走的这条。以前是"先删后移"—— 中途失败会让目标文件消失(旧归档没了、新归档还在 # 5.1 走的这条。以前是"先删后移"—— 中途失败会让目标文件消失(旧归档没了、新归档还在
+2 -2
View File
@@ -1,4 +1,4 @@
function New-BaknretArchiveItem { function New-BakNRetArchiveItem {
<# <#
.SYNOPSIS .SYNOPSIS
构造一个"归档项":宿主机上的一个目录 / 文件,对应归档内的一条路径。 构造一个"归档项":宿主机上的一个目录 / 文件,对应归档内的一条路径。
@@ -27,7 +27,7 @@
$clean = ([string]$ArchivePath).Trim().Trim([char[]]@('\', '/')) $clean = ([string]$ArchivePath).Trim().Trim([char[]]@('\', '/'))
return [pscustomobject]@{ return [pscustomobject]@{
ArchivePath = $clean ArchivePath = $clean
TopName = (Get-BaknretArchiveTopName -ArchivePath $clean) TopName = (Get-BakNRetArchiveTopName -ArchivePath $clean)
RealPath = $RealPath RealPath = $RealPath
Kind = $Kind Kind = $Kind
Slot = $Slot Slot = $Slot
+8 -8
View File
@@ -1,4 +1,4 @@
function New-BaknretArchiveStaging { function New-BakNRetArchiveStaging {
<# <#
.SYNOPSIS .SYNOPSIS
建一个暂存目录,把每个归档项按"归档内的名字"挂进去,供压缩工具直接打包。 建一个暂存目录,把每个归档项按"归档内的名字"挂进去,供压缩工具直接打包。
@@ -10,7 +10,7 @@
* 目录项 -> 建 junction(不复制数据,等于零成本改名); * 目录项 -> 建 junction(不复制数据,等于零成本改名);
* 文件项 -> 先试硬链接(同卷),失败再复制(配置文件都很小)。 * 文件项 -> 先试硬链接(同卷),失败再复制(配置文件都很小)。
返回暂存目录路径;调用方用完必须调 Remove-BaknretArchiveStaging 清理。 返回暂存目录路径;调用方用完必须调 Remove-BakNRetArchiveStaging 清理。
建不出连接点时**明确抛错**,绝不悄悄退化成另一种归档布局 —— 布局一变,恢复就对不上。 建不出连接点时**明确抛错**,绝不悄悄退化成另一种归档布局 —— 布局一变,恢复就对不上。
#> #>
param( param(
@@ -26,7 +26,7 @@
# 半途失败必须在这里自己清干净,不能把责任留给调用方。 # 半途失败必须在这里自己清干净,不能把责任留给调用方。
# #
# 原因:调用方拿到的是**返回值**,而抛错时根本没有返回值 —— Backup.ps1 的 finally 里 # 原因:调用方拿到的是**返回值**,而抛错时根本没有返回值 —— Backup.ps1 的 finally 里
# `$stagingRoot` 还是 $null,而 Remove-BaknretArchiveStaging 对 $null 是直接 return。 # `$stagingRoot` 还是 $null,而 Remove-BakNRetArchiveStaging 对 $null 是直接 return。
# 结果是已经建好的 junction 与临时目录永久留在 %TEMP%,而那些 junction 指向的是真实 # 结果是已经建好的 junction 与临时目录永久留在 %TEMP%,而那些 junction 指向的是真实
# 数据;临时目录迟早会被某次 Remove-Item -Recurse 扫到,那一下就会走进真实数据。 # 数据;临时目录迟早会被某次 Remove-Item -Recurse 扫到,那一下就会走进真实数据。
try { try {
@@ -40,14 +40,14 @@
if ($parent -and -not (Test-Path -LiteralPath $parent)) { if ($parent -and -not (Test-Path -LiteralPath $parent)) {
New-Item -ItemType Directory -Path $parent -Force | Out-Null New-Item -ItemType Directory -Path $parent -Force | Out-Null
} }
if (Test-Path -LiteralPath $linkPath) { Remove-BaknretJunction -Path $linkPath } if (Test-Path -LiteralPath $linkPath) { Remove-BakNRetJunction -Path $linkPath }
if ($item.IsFile) { if ($item.IsFile) {
try { try {
New-Item -ItemType HardLink -Path $linkPath -Target $item.RealPath -ErrorAction Stop | Out-Null New-Item -ItemType HardLink -Path $linkPath -Target $item.RealPath -ErrorAction Stop | Out-Null
} }
catch { catch {
Write-Log ("暂存:硬链接不可用({0}),改为复制文件 {1}" -f $_.Exception.Message, $item.RealPath) -Level DEBUG Write-BakNRetLog ("暂存:硬链接不可用({0}),改为复制文件 {1}" -f $_.Exception.Message, $item.RealPath) -Level DEBUG
Copy-Item -LiteralPath $item.RealPath -Destination $linkPath -Force -ErrorAction Stop Copy-Item -LiteralPath $item.RealPath -Destination $linkPath -Force -ErrorAction Stop
} }
} }
@@ -55,18 +55,18 @@
New-Item -ItemType Junction -Path $linkPath -Target $item.RealPath -ErrorAction Stop | Out-Null New-Item -ItemType Junction -Path $linkPath -Target $item.RealPath -ErrorAction Stop | Out-Null
} }
Write-Log ("暂存:{0} -> {1}" -f $item.ArchivePath, $item.RealPath) -Level DEBUG Write-BakNRetLog ("暂存:{0} -> {1}" -f $item.ArchivePath, $item.RealPath) -Level DEBUG
} }
return $Root return $Root
} }
catch { catch {
try { try {
Remove-BaknretArchiveStaging -Root $Root Remove-BakNRetArchiveStaging -Root $Root
} }
catch { catch {
# 清理失败不能盖掉真正的失败原因(那才是排查需要的),所以只告警并点名残留路径 # 清理失败不能盖掉真正的失败原因(那才是排查需要的),所以只告警并点名残留路径
Write-Log ("暂存目录自清理失败,需要手工删除:{0} —— {1}" -f $Root, $_.Exception.Message) -Level WARN Write-BakNRetLog ("暂存目录自清理失败,需要手工删除:{0} —— {1}" -f $Root, $_.Exception.Message) -Level WARN
} }
throw throw
} }
+1 -1
View File
@@ -1,4 +1,4 @@
function New-BaknretJunction { function New-BakNRetJunction {
<# <#
.SYNOPSIS .SYNOPSIS
建一个 junction;失败时抛异常(调用方决定降级还是报错)。 建一个 junction;失败时抛异常(调用方决定降级还是报错)。
+2 -2
View File
@@ -1,4 +1,4 @@
function Read-BaknretManifest { function Read-BakNRetManifest {
<# <#
.SYNOPSIS .SYNOPSIS
读取 manifest.json;不存在或损坏时返回空清单。 读取 manifest.json;不存在或损坏时返回空清单。
@@ -40,7 +40,7 @@
} }
} }
catch { catch {
Write-Log "manifest 解析失败(将重新建立):$Path —— $_" -Level WARN Write-BakNRetLog "manifest 解析失败(将重新建立):$Path —— $_" -Level WARN
return $empty return $empty
} }
} }
@@ -1,4 +1,4 @@
function Read-BaknretSecuritySidecar { function Read-BakNRetSecuritySidecar {
<# <#
.SYNOPSIS .SYNOPSIS
读 sidecar;不存在或损坏时返回 $null(调用方据此打"该归档不含安全描述符"的告警)。 读 sidecar;不存在或损坏时返回 $null(调用方据此打"该归档不含安全描述符"的告警)。
@@ -27,7 +27,7 @@
} }
} }
catch { catch {
Write-Log "安全描述符文件解析失败:$Path —— $($_.Exception.Message)" -Level WARN Write-BakNRetLog "安全描述符文件解析失败:$Path —— $($_.Exception.Message)" -Level WARN
return $null return $null
} }
} }
@@ -1,4 +1,4 @@
function Remove-BaknretArchiveStaging { function Remove-BakNRetArchiveStaging {
<# <#
.SYNOPSIS .SYNOPSIS
安全拆掉暂存目录:先手工摘掉 junction,再删剩下的普通文件 / 目录。 安全拆掉暂存目录:先手工摘掉 junction,再删剩下的普通文件 / 目录。
@@ -17,7 +17,7 @@
$current = $pending.Pop() $current = $pending.Pop()
foreach ($child in @(Get-ChildItem -LiteralPath $current -Force -ErrorAction SilentlyContinue)) { foreach ($child in @(Get-ChildItem -LiteralPath $current -Force -ErrorAction SilentlyContinue)) {
if ($child.LinkType -eq 'Junction' -or $child.LinkType -eq 'SymbolicLink') { if ($child.LinkType -eq 'Junction' -or $child.LinkType -eq 'SymbolicLink') {
Remove-BaknretJunction -Path $child.FullName Remove-BakNRetJunction -Path $child.FullName
continue continue
} }
if ($child.PSIsContainer) { $pending.Push($child.FullName) } if ($child.PSIsContainer) { $pending.Push($child.FullName) }
+1 -1
View File
@@ -1,4 +1,4 @@
function Remove-BaknretJunction { function Remove-BakNRetJunction {
<# <#
.SYNOPSIS .SYNOPSIS
只删连接点本身,绝不顺着它删到目标目录里去。 只删连接点本身,绝不顺着它删到目标目录里去。
+1 -1
View File
@@ -1,4 +1,4 @@
function Remove-BaknretQuote { function Remove-BakNRetQuote {
<# <#
.SYNOPSIS .SYNOPSIS
去掉值两端成对的引号(单双都认);不成对时原样返回。 去掉值两端成对的引号(单双都认);不成对时原样返回。
@@ -1,4 +1,4 @@
function Resolve-BackupEntry { function Resolve-BakNRetBackupEntry {
<# <#
.SYNOPSIS .SYNOPSIS
把清单条目解析成"实际要打包什么、归档里长什么样"。 把清单条目解析成"实际要打包什么、归档里长什么样"。
@@ -8,7 +8,7 @@
IsName / BaseName / ArchiveFlavor / Direction IsName / BaseName / ArchiveFlavor / Direction
CatalogEntry —— 名录条目(软件名写法才有) CatalogEntry —— 名录条目(软件名写法才有)
Items —— 归档项数组(见 New-BaknretArchiveItem) Items —— 归档项数组(见 New-BakNRetArchiveItem)
Encrypt —— 该归档是否加密 Encrypt —— 该归档是否加密
ExcludePatterns / HasExcludeOverride —— 条目级 `:-` / `@ Exclude` 覆盖 ExcludePatterns / HasExcludeOverride —— 条目级 `:-` / `@ Exclude` 覆盖
Includes / HasIncludeOverride —— 条目级 `:+` / `@ Include` 覆盖 Includes / HasIncludeOverride —— 条目级 `:+` / `@ Include` 覆盖
@@ -28,9 +28,9 @@
[int]$MaxDepth = 5 [int]$MaxDepth = 5
) )
$isName = -not (Test-LiteralPath -Path $Entry.Path) $isName = -not (Test-BakNRetLiteralPath -Path $Entry.Path)
$forcePathFlavor = ($Entry.Flags -contains 'pathname') $forcePathFlavor = ($Entry.Flags -contains 'pathname')
$baseName = Get-ItemArchiveName -Entry $Entry -CatalogPath $CatalogPath -MaxDepth $MaxDepth $baseName = Get-BakNRetItemArchiveName -Entry $Entry -CatalogPath $CatalogPath -MaxDepth $MaxDepth
$overrides = $Entry.Overrides $overrides = $Entry.Overrides
if (-not $overrides) { $overrides = @{} } if (-not $overrides) { $overrides = @{} }
@@ -70,13 +70,13 @@
$errorText = "无法从路径里拆出末级名:$real" $errorText = "无法从路径里拆出末级名:$real"
} }
else { else {
$items += New-BaknretArchiveItem -ArchivePath $leaf -RealPath $real -Kind 'path' ` $items += New-BakNRetArchiveItem -ArchivePath $leaf -RealPath $real -Kind 'path' `
-Origin 'path' -Exists $exists -IsFile $isFile -Origin 'path' -Exists $exists -IsFile $isFile
} }
} }
else { else {
# ---- 写法一:软件名录里的软件名 ---- # ---- 写法一:软件名录里的软件名 ----
$catalog = Get-SoftwareCatalog -Path $CatalogPath -MaxDepth $MaxDepth $catalog = Get-BakNRetSoftwareCatalog -Path $CatalogPath -MaxDepth $MaxDepth
if (-not $catalog.ContainsKey($Entry.Path)) { if (-not $catalog.ContainsKey($Entry.Path)) {
$errorText = "软件名录里没有 '$($Entry.Path)'" $errorText = "软件名录里没有 '$($Entry.Path)'"
} }
@@ -110,7 +110,7 @@
} }
} }
$items += New-BaknretArchiveItem -ArchivePath $slot.Name -RealPath $resolvedPath -Kind 'slot' ` $items += New-BakNRetArchiveItem -ArchivePath $slot.Name -RealPath $resolvedPath -Kind 'slot' `
-Slot $slot.Name -Description $slot.Description -Origin 'catalog' ` -Slot $slot.Name -Description $slot.Description -Origin 'catalog' `
-Exists $exists -IsFile $isFile -Exclude $slot.Exclude -Exists $exists -IsFile $isFile -Exclude $slot.Exclude
} }
@@ -147,7 +147,7 @@
if ($archivePart -match '^[A-Za-z]$' -and ($hostPart.StartsWith('\') -or $hostPart.StartsWith('/'))) { if ($archivePart -match '^[A-Za-z]$' -and ($hostPart.StartsWith('\') -or $hostPart.StartsWith('/'))) {
$lastSeparator = $text.LastIndexOf(':') $lastSeparator = $text.LastIndexOf(':')
if ($lastSeparator -gt $separator) { if ($lastSeparator -gt $separator) {
Write-Log ("包含项写得像'宿主机:归档内':{0} —— 语法应为 <归档内相对路径>:<宿主机绝对路径>,已按后者解释" -f $text) -Level WARN Write-BakNRetLog ("包含项写得像'宿主机:归档内':{0} —— 语法应为 <归档内相对路径>:<宿主机绝对路径>,已按后者解释" -f $text) -Level WARN
$hostPart = $text.Substring(0, $lastSeparator).Trim() $hostPart = $text.Substring(0, $lastSeparator).Trim()
$archivePart = $text.Substring($lastSeparator + 1).Trim() $archivePart = $text.Substring($lastSeparator + 1).Trim()
} }
@@ -156,7 +156,7 @@
$hostPath = [Environment]::ExpandEnvironmentVariables($hostPart).Trim() $hostPath = [Environment]::ExpandEnvironmentVariables($hostPart).Trim()
if ([string]::IsNullOrWhiteSpace($hostPath)) { if ([string]::IsNullOrWhiteSpace($hostPath)) {
Write-Log "包含项 '$text' 里没有宿主机路径,已忽略" -Level WARN Write-BakNRetLog "包含项 '$text' 里没有宿主机路径,已忽略" -Level WARN
continue continue
} }
@@ -170,7 +170,7 @@
$archivePath = $archivePart $archivePath = $archivePart
if ([string]::IsNullOrWhiteSpace($archivePath)) { $archivePath = Split-Path -Path $hostPath -Leaf } if ([string]::IsNullOrWhiteSpace($archivePath)) { $archivePath = Split-Path -Path $hostPath -Leaf }
$items += New-BaknretArchiveItem -ArchivePath $archivePath -RealPath $hostPath -Kind 'include' ` $items += New-BakNRetArchiveItem -ArchivePath $archivePath -RealPath $hostPath -Kind 'include' `
-Origin 'include' -Exists $exists -IsFile $isFile -Origin 'include' -Exists $exists -IsFile $isFile
} }
@@ -220,7 +220,7 @@
$encryptedSlots = @($slots | Where-Object { $_.Encrypt }) $encryptedSlots = @($slots | Where-Object { $_.Encrypt })
$encrypt = $encryptedSlots.Count -gt 0 $encrypt = $encryptedSlots.Count -gt 0
if ($encryptedSlots.Count -gt 0 -and $encryptedSlots.Count -lt $slots.Count) { if ($encryptedSlots.Count -gt 0 -and $encryptedSlots.Count -lt $slots.Count) {
Write-Log ("{0}:名录里各 Slot 的 Encrypt 不一致,整个归档按加密处理" -f $Entry.Path) -Level WARN Write-BakNRetLog ("{0}:名录里各 Slot 的 Encrypt 不一致,整个归档按加密处理" -f $Entry.Path) -Level WARN
} }
} }
@@ -1,4 +1,4 @@
function Resolve-CatalogPath { function Resolve-BakNRetCatalogPath {
<# <#
.SYNOPSIS .SYNOPSIS
计算软件名录的绝对路径(优先 .psd1,找不到就退而用 .json)。 计算软件名录的绝对路径(优先 .psd1,找不到就退而用 .json)。
@@ -1,4 +1,4 @@
function Resolve-CompressionTool { function Resolve-BakNRetCompressionTool {
<# <#
.SYNOPSIS .SYNOPSIS
探测可用的压缩工具,优先 7z,其次 RAR,最后内置 ZIP。 探测可用的压缩工具,优先 7z,其次 RAR,最后内置 ZIP。
@@ -17,17 +17,17 @@
$sevenZip = $candidates | Where-Object { $_ -and (Test-Path -LiteralPath $_) } | Select-Object -First 1 $sevenZip = $candidates | Where-Object { $_ -and (Test-Path -LiteralPath $_) } | Select-Object -First 1
} }
if ($sevenZip) { if ($sevenZip) {
Write-Log '检测到 7z 压缩工具' -Level DEBUG Write-BakNRetLog '检测到 7z 压缩工具' -Level DEBUG
return [pscustomobject]@{ Name = '7z'; Command = $sevenZip; Extension = '.7z' } return [pscustomobject]@{ Name = '7z'; Command = $sevenZip; Extension = '.7z' }
} }
$rar = Get-Command rar, winrar -ErrorAction SilentlyContinue | $rar = Get-Command rar, winrar -ErrorAction SilentlyContinue |
Select-Object -First 1 -ExpandProperty Source Select-Object -First 1 -ExpandProperty Source
if ($rar) { if ($rar) {
Write-Log '检测到 RAR 压缩工具' -Level DEBUG Write-BakNRetLog '检测到 RAR 压缩工具' -Level DEBUG
return [pscustomobject]@{ Name = 'RAR'; Command = $rar; Extension = '.rar' } return [pscustomobject]@{ Name = 'RAR'; Command = $rar; Extension = '.rar' }
} }
Write-Log '使用内置 ZIP 工具' -Level DEBUG Write-BakNRetLog '使用内置 ZIP 工具' -Level DEBUG
return [pscustomobject]@{ Name = 'ZIP'; Command = 'Compress-Archive'; Extension = '.zip' } return [pscustomobject]@{ Name = 'ZIP'; Command = 'Compress-Archive'; Extension = '.zip' }
} }
+6 -6
View File
@@ -1,4 +1,4 @@
function Restore-BaknretSecurity { function Restore-BakNRetSecurity {
<# <#
.SYNOPSIS .SYNOPSIS
把 sidecar 里属于某个归档项的那部分安全描述符,回放到真实目标路径上。 把 sidecar 里属于某个归档项的那部分安全描述符,回放到真实目标路径上。
@@ -33,7 +33,7 @@
# 写属主需要 SeRestorePrivilege,而且必须**显式启用**:管理员的过滤令牌里它默认是 # 写属主需要 SeRestorePrivilege,而且必须**显式启用**:管理员的过滤令牌里它默认是
# disabled,Set-Acl / SetAccessControl 都不会替你打开。没有它,属主会写失败并静默 # disabled,Set-Acl / SetAccessControl 都不会替你打开。没有它,属主会写失败并静默
# 退化成"只恢复 DACL" —— 那恰恰丢掉了这个功能存在的理由(CREATOR OWNER 判给谁)。 # 退化成"只恢复 DACL" —— 那恰恰丢掉了这个功能存在的理由(CREATOR OWNER 判给谁)。
Enable-BaknretPrivilege -Name @('SeRestorePrivilege', 'SeBackupPrivilege') | Out-Null Enable-BakNRetPrivilege -Name @('SeRestorePrivilege', 'SeBackupPrivilege') | Out-Null
if (-not $Sidecar -or -not $Sidecar.Records) { return $result } if (-not $Sidecar -or -not $Sidecar.Records) { return $result }
@@ -79,22 +79,22 @@
if ($WhatIf) { continue } if ($WhatIf) { continue }
$sddl = Convert-BaknretSidMap -Sddl ([string]$entry.Record.s) -SidMap $SidMap $sddl = Convert-BakNRetSidMap -Sddl ([string]$entry.Record.s) -SidMap $SidMap
try { try {
Set-BaknretObjectSecurity -Item $item -Sddl $sddl -Scope All Set-BakNRetObjectSecurity -Item $item -Sddl $sddl -Scope All
$result.Applied++ $result.Applied++
} }
catch { catch {
$fullError = $_ $fullError = $_
try { try {
Set-BaknretObjectSecurity -Item $item -Sddl $sddl -Scope OwnerAndAccess Set-BakNRetObjectSecurity -Item $item -Sddl $sddl -Scope OwnerAndAccess
$result.OwnerFailed++ $result.OwnerFailed++
$result.Failures += ("{0}:属组未恢复,属主与 DACL 已恢复({1})" -f $target, $fullError.Exception.Message) $result.Failures += ("{0}:属组未恢复,属主与 DACL 已恢复({1})" -f $target, $fullError.Exception.Message)
} }
catch { catch {
try { try {
Set-BaknretObjectSecurity -Item $item -Sddl $sddl -Scope AccessOnly Set-BakNRetObjectSecurity -Item $item -Sddl $sddl -Scope AccessOnly
$result.OwnerFailed++ $result.OwnerFailed++
$result.Failures += ("{0}:属主/属组未恢复({1}),已只恢复 DACL" -f $target, $_.Exception.Message) $result.Failures += ("{0}:属主/属组未恢复({1}),已只恢复 DACL" -f $target, $_.Exception.Message)
} }
@@ -1,4 +1,4 @@
function Save-BaknretSecuritySidecar { function Save-BakNRetSecuritySidecar {
<# <#
.SYNOPSIS .SYNOPSIS
把采集结果写成 sidecar(`<归档名>.acl.json`)。 把采集结果写成 sidecar(`<归档名>.acl.json`)。
@@ -47,5 +47,5 @@
} }
$json = $payload | ConvertTo-Json -Depth 5 $json = $payload | ConvertTo-Json -Depth 5
return (Write-BaknretAtomicText -Path $Path -Text $json) return (Write-BakNRetAtomicText -Path $Path -Text $json)
} }
+1 -1
View File
@@ -1,4 +1,4 @@
function Set-BaknretDebug { function Set-BakNRetDebug {
<# .SYNOPSIS 打开 DEBUG 级别日志。 #> <# .SYNOPSIS 打开 DEBUG 级别日志。 #>
param([switch]$Enabled = $true) param([switch]$Enabled = $true)
$script:LogConfig.EnableDebug = [bool]$Enabled $script:LogConfig.EnableDebug = [bool]$Enabled
+1 -1
View File
@@ -1,4 +1,4 @@
function Set-BaknretObjectSecurity { function Set-BakNRetObjectSecurity {
<# <#
.SYNOPSIS .SYNOPSIS
把一条 SDDL 落到一个对象上。 把一条 SDDL 落到一个对象上。
+2 -2
View File
@@ -1,4 +1,4 @@
function Split-BaknretPatternScope { function Split-BakNRetPatternScope {
<# <#
.SYNOPSIS .SYNOPSIS
把条目级的模式按 `<归档项名>\` 前缀分配到各个归档项上。 把条目级的模式按 `<归档项名>\` 前缀分配到各个归档项上。
@@ -23,7 +23,7 @@
$map = @{} $map = @{}
for ($index = 0; $index -lt $Items.Count; $index++) { $map[$index] = @() } for ($index = 0; $index -lt $Items.Count; $index++) { $map[$index] = @() }
# 归档项的名字(顶层目录名)。同一个条目里不允许重名,Resolve-BackupEntry 会拦。 # 归档项的名字(顶层目录名)。同一个条目里不允许重名,Resolve-BakNRetBackupEntry 会拦。
$topIndex = @{} $topIndex = @{}
for ($index = 0; $index -lt $Items.Count; $index++) { for ($index = 0; $index -lt $Items.Count; $index++) {
$name = [string]$Items[$index].ArchivePath $name = [string]$Items[$index].ArchivePath
+1 -1
View File
@@ -1,4 +1,4 @@
function Split-BaknretToken { function Split-BakNRetToken {
<# <#
.SYNOPSIS .SYNOPSIS
把清单的一行切成空白分隔的记号;引号内的空白不切分,引号本身留在记号里。 把清单的一行切成空白分隔的记号;引号内的空白不切分,引号本身留在记号里。
+1 -1
View File
@@ -1,4 +1,4 @@
function Start-BaknretLog { function Start-BakNRetLog {
<# <#
.SYNOPSIS .SYNOPSIS
把后续日志同时写入 <Directory>/<Prefix>-<时间戳>.log,返回日志文件路径。 把后续日志同时写入 <Directory>/<Prefix>-<时间戳>.log,返回日志文件路径。
+1 -1
View File
@@ -1,4 +1,4 @@
function Stop-BaknretLog { function Stop-BakNRetLog {
<# .SYNOPSIS 停止写入日志文件。 #> <# .SYNOPSIS 停止写入日志文件。 #>
$script:LogConfig.FilePath = $null $script:LogConfig.FilePath = $null
} }
@@ -1,4 +1,4 @@
function Sync-BaknretManifestArchive { function Sync-BakNRetManifestArchive {
<# <#
.SYNOPSIS .SYNOPSIS
清空 manifest 里"指向了一个不存在的归档"的 archive 字段,返回被清空的条目名。 清空 manifest 里"指向了一个不存在的归档"的 archive 字段,返回被清空的条目名。
@@ -1,4 +1,4 @@
function Test-Administrator { function Test-BakNRetAdministrator {
<# .SYNOPSIS 当前进程是否以管理员身份运行。 #> <# .SYNOPSIS 当前进程是否以管理员身份运行。 #>
$principal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() $principal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
@@ -1,4 +1,4 @@
function Test-LiteralPath { function Test-BakNRetLiteralPath {
<# <#
.SYNOPSIS .SYNOPSIS
判断清单里的一行是不是"字面路径"(而非软件名)。 判断清单里的一行是不是"字面路径"(而非软件名)。
+1 -1
View File
@@ -1,4 +1,4 @@
function Test-BaknretMarker { function Test-BakNRetMarker {
<# <#
.SYNOPSIS .SYNOPSIS
判断一个记号是不是清单修饰符,返回它的种类;不是则返回 $null。 判断一个记号是不是清单修饰符,返回它的种类;不是则返回 $null。
+4 -4
View File
@@ -1,4 +1,4 @@
function Test-BaknretPathExcluded { function Test-BakNRetPathExcluded {
<# <#
.SYNOPSIS .SYNOPSIS
判断归档内的一个相对路径是否命中排除模式。 判断归档内的一个相对路径是否命中排除模式。
@@ -38,7 +38,7 @@
} }
} }
catch { catch {
Write-Log "排除正则非法,安全描述符采集按'不排除'处理:$regexText —— $($_.Exception.Message)" -Level WARN Write-BakNRetLog "排除正则非法,安全描述符采集按'不排除'处理:$regexText —— $($_.Exception.Message)" -Level WARN
} }
continue continue
} }
@@ -46,7 +46,7 @@
if ($text.StartsWith('!')) { if ($text.StartsWith('!')) {
$wildcard = $text.Substring(1).Trim() $wildcard = $text.Substring(1).Trim()
if (-not $wildcard) { continue } if (-not $wildcard) { continue }
$componentPattern = '^(?:' + (ConvertTo-BaknretWildcardPattern -Pattern $wildcard) + ')$' $componentPattern = '^(?:' + (ConvertTo-BakNRetWildcardPattern -Pattern $wildcard) + ')$'
foreach ($component in $components) { foreach ($component in $components) {
if ($component -match $componentPattern) { return $true } if ($component -match $componentPattern) { return $true }
} }
@@ -55,7 +55,7 @@
$anchored = ([string]$text).Trim([char[]]@('\', '/')) $anchored = ([string]$text).Trim([char[]]@('\', '/'))
if (-not $anchored) { continue } if (-not $anchored) { continue }
$anchoredPattern = '^(?:' + (ConvertTo-BaknretWildcardPattern -Pattern $anchored) + ')$' $anchoredPattern = '^(?:' + (ConvertTo-BakNRetWildcardPattern -Pattern $anchored) + ')$'
if ($relative -match $anchoredPattern) { return $true } if ($relative -match $anchoredPattern) { return $true }
} }
@@ -1,4 +1,4 @@
function Test-BaknretSecurityRecordNeeded { function Test-BakNRetSecurityRecordNeeded {
<# <#
.SYNOPSIS .SYNOPSIS
Smart 模式下判断这条记录是否必须落进 sidecar。 Smart 模式下判断这条记录是否必须落进 sidecar。
@@ -1,4 +1,4 @@
function Write-BackupEntryPlan { function Write-BakNRetBackupEntryPlan {
<# <#
.SYNOPSIS .SYNOPSIS
在动手打包之前,把"这条会打包哪些目录、归档里长什么样、排除了什么、为什么"打印出来。 在动手打包之前,把"这条会打包哪些目录、归档里长什么样、排除了什么、为什么"打印出来。
@@ -27,41 +27,41 @@
'restore' = '仅恢复(行首 -)' 'restore' = '仅恢复(行首 -)'
} }
Write-Log ("条目:{0}" -f $DisplayPath) Write-BakNRetLog ("条目:{0}" -f $DisplayPath)
Write-Log (" 归档:{0}.7z;方向:{1};加密:{2}" -f $Resolved.BaseName, Write-BakNRetLog (" 归档:{0}.7z;方向:{1};加密:{2}" -f $Resolved.BaseName,
$(if ($directionText.ContainsKey($Resolved.Direction)) { $directionText[$Resolved.Direction] } else { $Resolved.Direction }), $(if ($directionText.ContainsKey($Resolved.Direction)) { $directionText[$Resolved.Direction] } else { $Resolved.Direction }),
$(if ($Resolved.Encrypt) { '是' } else { '否' })) $(if ($Resolved.Encrypt) { '是' } else { '否' }))
if ($Comment) { Write-Log (" 说明:{0}" -f $Comment) } if ($Comment) { Write-BakNRetLog (" 说明:{0}" -f $Comment) }
if ($Resolved.Error) { Write-Log (" 提示:{0}" -f $Resolved.Error) -Level WARN } if ($Resolved.Error) { Write-BakNRetLog (" 提示:{0}" -f $Resolved.Error) -Level WARN }
$items = @($Resolved.Items) $items = @($Resolved.Items)
if ($items.Count -eq 0) { Write-Log ' 归档项:没有解析出任何目录' -Level WARN } if ($items.Count -eq 0) { Write-BakNRetLog ' 归档项:没有解析出任何目录' -Level WARN }
for ($index = 0; $index -lt $items.Count; $index++) { for ($index = 0; $index -lt $items.Count; $index++) {
$item = $items[$index] $item = $items[$index]
$exists = Test-Path -LiteralPath $item.RealPath $exists = Test-Path -LiteralPath $item.RealPath
$origin = if ($item.Origin -and $originText.ContainsKey($item.Origin)) { $originText[$item.Origin] } else { $item.Origin } $origin = if ($item.Origin -and $originText.ContainsKey($item.Origin)) { $originText[$item.Origin] } else { $item.Origin }
Write-Log (" 归档项 {0}/{1}:{2} <- {3}" -f ($index + 1), $items.Count, $item.ArchivePath, $item.RealPath) Write-BakNRetLog (" 归档项 {0}/{1}:{2} <- {3}" -f ($index + 1), $items.Count, $item.ArchivePath, $item.RealPath)
Write-Log (" 来源:{0};{1};{2}" -f $origin, Write-BakNRetLog (" 来源:{0};{1};{2}" -f $origin,
$(if ($exists) { '存在,会打包' } else { '当前不存在,本次跳过' }), $(if ($exists) { '存在,会打包' } else { '当前不存在,本次跳过' }),
$(if ($item.IsFile) { '文件' } else { '目录' })) $(if ($item.IsFile) { '文件' } else { '目录' }))
if ($item.Description) { Write-Log (" 介绍:{0}" -f $item.Description) } if ($item.Description) { Write-BakNRetLog (" 介绍:{0}" -f $item.Description) }
if (@($item.Exclude).Count -gt 0) { if (@($item.Exclude).Count -gt 0) {
Write-Log (" 名录里的排除:{0}" -f (@($item.Exclude) -join '、')) Write-BakNRetLog (" 名录里的排除:{0}" -f (@($item.Exclude) -join '、'))
} }
} }
if ($ListExcludes.Count -gt 0) { if ($ListExcludes.Count -gt 0) {
Write-Log (" 排除 {0} 条(来自清单的 :- / @ Exclude):{1}" -f $ListExcludes.Count, ($ListExcludes -join '、')) Write-BakNRetLog (" 排除 {0} 条(来自清单的 :- / @ Exclude):{1}" -f $ListExcludes.Count, ($ListExcludes -join '、'))
} }
if ($CatalogExcludes.Count -gt 0) { if ($CatalogExcludes.Count -gt 0) {
Write-Log (" 排除 {0} 条(来自名录 Slot 的 Exclude):{1}" -f $CatalogExcludes.Count, ($CatalogExcludes -join '、')) Write-BakNRetLog (" 排除 {0} 条(来自名录 Slot 的 Exclude):{1}" -f $CatalogExcludes.Count, ($CatalogExcludes -join '、'))
} }
if ($ConfigExcludes.Count -gt 0) { if ($ConfigExcludes.Count -gt 0) {
Write-Log (" 排除 {0} 条(来自 BackupConfig.psd1 的 DefaultExcludes):{1}" -f $ConfigExcludes.Count, ($ConfigExcludes -join '、')) Write-BakNRetLog (" 排除 {0} 条(来自 BackupConfig.psd1 的 DefaultExcludes):{1}" -f $ConfigExcludes.Count, ($ConfigExcludes -join '、'))
} }
if ($ListExcludes.Count -eq 0 -and $CatalogExcludes.Count -eq 0 -and $ConfigExcludes.Count -eq 0) { if ($ListExcludes.Count -eq 0 -and $CatalogExcludes.Count -eq 0 -and $ConfigExcludes.Count -eq 0) {
Write-Log ' 排除:无(整包收下)' Write-BakNRetLog ' 排除:无(整包收下)'
} }
} }
@@ -1,4 +1,4 @@
function Write-Log { function Write-BakNRetLog {
<# <#
.SYNOPSIS .SYNOPSIS
写一条日志到控制台,并在启用日志文件时落盘。 写一条日志到控制台,并在启用日志文件时落盘。
+2 -2
View File
@@ -1,4 +1,4 @@
function Write-BaknretAtomicText { function Write-BakNRetAtomicText {
<# <#
.SYNOPSIS .SYNOPSIS
原子写一个文本文件(先写 .tmp,再替换)。 原子写一个文本文件(先写 .tmp,再替换)。
@@ -17,7 +17,7 @@
[System.IO.File]::WriteAllText($temp, $Text, $script:LogEncoding) [System.IO.File]::WriteAllText($temp, $Text, $script:LogEncoding)
if (-not (Test-Path -LiteralPath $Path)) { if (-not (Test-Path -LiteralPath $Path)) {
Move-BaknretArchiveIntoPlace -TempPath $temp -DestinationPath $Path Move-BakNRetArchiveIntoPlace -TempPath $temp -DestinationPath $Path
return $Path return $Path
} }
+2 -2
View File
@@ -1,4 +1,4 @@
function Write-BaknretManifest { function Write-BakNRetManifest {
<# <#
.SYNOPSIS .SYNOPSIS
原子写入 manifest.json(UTF-8 无 BOM)。 原子写入 manifest.json(UTF-8 无 BOM)。
@@ -18,6 +18,6 @@
# 复用原子写,而不是自己"删旧再改名":后者一旦在中途失败,旧 manifest 已经没了 —— # 复用原子写,而不是自己"删旧再改名":后者一旦在中途失败,旧 manifest 已经没了 ——
# 而 manifest 是"这块归档是谁的"的唯一账本,丢了它只能靠文件名反推。 # 而 manifest 是"这块归档是谁的"的唯一账本,丢了它只能靠文件名反推。
Write-BaknretAtomicText -Path $Path -Text $json Write-BakNRetAtomicText -Path $Path -Text $json
return $Path return $Path
} }
+2 -2
View File
@@ -1,4 +1,4 @@
# BakNRet # BakNRet
一个 Windows 备份 / 恢复工具:把机器上指定的软件与目录收进归档,并能把它们放回原位。本文件是本项目的术语表——「要处理什么」「东西放在哪」这些概念在本仓库里只有一个叫法,写作与命名都照这里的词来。 一个 Windows 备份 / 恢复工具:把机器上指定的软件与目录收进归档,并能把它们放回原位。本文件是本项目的术语表——「要处理什么」「东西放在哪」这些概念在本仓库里只有一个叫法,写作与命名都照这里的词来。
@@ -70,4 +70,4 @@ _Avoid_: 无用归档、残留、垃圾文件
**BakNRet**: **BakNRet**:
本工具的名称,任何场合(文档、文件名、标识符)都一律写作 `BakNRet`。 本工具的名称,任何场合(文档、文件名、标识符)都一律写作 `BakNRet`。
_Avoid_: Baknret、baknret、BakRet、BaknRet 备份工具 _Avoid_: BakNRet、baknret、BakRet、BaknRet 备份工具
+1 -1
View File
@@ -14,7 +14,7 @@
逐条决策(都在本次改造里确认过,理由另见 docs/adr/): 逐条决策(都在本次改造里确认过,理由另见 docs/adr/):
* PSAvoidUsingWriteHost 全局排除 —— Write-Log 的彩色控制台输出是这份工具的**刻意设计**, * PSAvoidUsingWriteHost 全局排除 —— Write-BakNRetLog 的彩色控制台输出是这份工具的**刻意设计**,
不是疏忽。这是架构性选择,所以在配置里排除掉,而不是逐处 Suppress 假装它是例外。 不是疏忽。这是架构性选择,所以在配置里排除掉,而不是逐处 Suppress 假装它是例外。
* PSUseSingularNouns 放行 SecurityRecords / MapKeys / ArchiveTopLevelNames —— * PSUseSingularNouns 放行 SecurityRecords / MapKeys / ArchiveTopLevelNames ——
单数名 Get-BakNretSecurityRecord 已被"单对象版本"占用,为了规则把两个语义搅在一起 单数名 Get-BakNretSecurityRecord 已被"单对象版本"占用,为了规则把两个语义搅在一起
+89 -89
View File
@@ -81,9 +81,9 @@ if (-not (Test-Path -LiteralPath $modulePath)) {
} }
Import-Module $modulePath -Force Import-Module $modulePath -Force
if ($PSBoundParameters.ContainsKey('Verbose')) { Set-BaknretDebug } if ($PSBoundParameters.ContainsKey('Verbose')) { Set-BakNRetDebug }
$script:Config = Get-BaknretConfig -Path $ConfigPath $script:Config = Get-BakNRetConfig -Path $ConfigPath
$SupportedFormats = @('.7z', '.rar', '.zip', '.tar') $SupportedFormats = @('.7z', '.rar', '.zip', '.tar')
function Resolve-ConfigPath { function Resolve-ConfigPath {
@@ -97,33 +97,33 @@ function Resolve-ConfigPath {
if (-not $BackupDir) { $BackupDir = Resolve-ConfigPath -Path $null -Default $script:Config.BackupDir } if (-not $BackupDir) { $BackupDir = Resolve-ConfigPath -Path $null -Default $script:Config.BackupDir }
$logDir = Resolve-ConfigPath -Path $null -Default $script:Config.LogDir $logDir = Resolve-ConfigPath -Path $null -Default $script:Config.LogDir
$catalogPath = Resolve-CatalogPath -Configured $script:Config.SoftwareCatalog -Root $PSScriptRoot $catalogPath = Resolve-BakNRetCatalogPath -Configured $script:Config.SoftwareCatalog -Root $PSScriptRoot
$manifestPath = Join-Path $BackupDir 'manifest.json' $manifestPath = Join-Path $BackupDir 'manifest.json'
$logPath = Start-BaknretLog -Directory $logDir -Prefix 'restore' $logPath = Start-BakNRetLog -Directory $logDir -Prefix 'restore'
Write-Log "日志文件:$logPath" Write-BakNRetLog "日志文件:$logPath"
Write-Log "备份目录:$BackupDir" Write-BakNRetLog "备份目录:$BackupDir"
Write-Log ("软件名录:{0}{1}" -f $catalogPath, $(if (Test-Path -LiteralPath $catalogPath) { '' } else { '(不存在,将只支持字面路径)' })) Write-BakNRetLog ("软件名录:{0}{1}" -f $catalogPath, $(if (Test-Path -LiteralPath $catalogPath) { '' } else { '(不存在,将只支持字面路径)' }))
if ($WhatIfPreference) { Write-Log '试运行模式(-WhatIf / -DryRun):不会写入任何文件' -Level WARN } if ($WhatIfPreference) { Write-BakNRetLog '试运行模式(-WhatIf / -DryRun):不会写入任何文件' -Level WARN }
if (-not (Test-Administrator)) { if (-not (Test-BakNRetAdministrator)) {
Write-Log '建议以管理员身份运行以获取完整的目录访问权限' -Level WARN Write-BakNRetLog '建议以管理员身份运行以获取完整的目录访问权限' -Level WARN
} }
# 同一份备份目录同一时间只允许一个进程操作(见 BakNRet 模块的「运行锁」一节)。 # 同一份备份目录同一时间只允许一个进程操作(见 BakNRet 模块的「运行锁」一节)。
# 三种只读模式不取锁:它们一个字节都不写,没必要被正在跑的备份挡在外面。 # 三种只读模式不取锁:它们一个字节都不写,没必要被正在跑的备份挡在外面。
$runLock = $null $runLock = $null
if (-not $WhatIfPreference -and -not $VerifyOnly) { if (-not $WhatIfPreference -and -not $VerifyOnly) {
$runLock = Enter-BaknretRunLock -Directory $BackupDir $runLock = Enter-BakNRetRunLock -Directory $BackupDir
if (-not $runLock) { if (-not $runLock) {
Write-Log ("另一次运行正在进行中(锁文件:{0},里面写明了持有者)。本次不执行。" -f (Get-BaknretRunLockPath -Directory $BackupDir)) -Level ERROR Write-BakNRetLog ("另一次运行正在进行中(锁文件:{0},里面写明了持有者)。本次不执行。" -f (Get-BakNRetRunLockPath -Directory $BackupDir)) -Level ERROR
Stop-BaknretLog Stop-BakNRetLog
exit 1 exit 1
} }
Write-Log ("已取得运行锁:{0}" -f (Get-BaknretRunLockPath -Directory $BackupDir)) -Level DEBUG Write-BakNRetLog ("已取得运行锁:{0}" -f (Get-BakNRetRunLockPath -Directory $BackupDir)) -Level DEBUG
} }
$passwordFile = if ($KeyFile) { $KeyFile } else { $script:Config.Encryption.PasswordFile } $passwordFile = if ($KeyFile) { $KeyFile } else { $script:Config.Encryption.PasswordFile }
$password = Get-BaknretPassword -PasswordFile $passwordFile $password = Get-BakNRetPassword -PasswordFile $passwordFile
# ============================================================================ # ============================================================================
# 归档查找 # 归档查找
@@ -157,7 +157,7 @@ function Get-ArchiveForEntry {
if (Test-Path -LiteralPath $path) { if (Test-Path -LiteralPath $path) {
return [pscustomobject]@{ File = (Get-Item -LiteralPath $path); Source = 'manifest'; Record = $record } return [pscustomobject]@{ File = (Get-Item -LiteralPath $path); Source = 'manifest'; Record = $record }
} }
Write-Log "manifest 记录的归档不存在,回退按文件名查找:$archiveName" -Level WARN Write-BakNRetLog "manifest 记录的归档不存在,回退按文件名查找:$archiveName" -Level WARN
} }
} }
@@ -207,7 +207,7 @@ function Invoke-ExtractionRaw {
$sevenZip = Get-7zExecutable $sevenZip = Get-7zExecutable
if ($sevenZip) { if ($sevenZip) {
Write-Log '使用 7z 解压' -Level DEBUG Write-BakNRetLog '使用 7z 解压' -Level DEBUG
$argument = @('x', '-bsp2', '-y', "-o$Destination") $argument = @('x', '-bsp2', '-y', "-o$Destination")
if ($Password) { $argument += "-p$Password" } if ($Password) { $argument += "-p$Password" }
$argument += $ArchiveFile.FullName $argument += $ArchiveFile.FullName
@@ -222,23 +222,23 @@ function Invoke-ExtractionRaw {
'.rar' { '.rar' {
$rarExe = Get-Command rar, unrar -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source $rarExe = Get-Command rar, unrar -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source
if (-not $rarExe) { throw '未找到 RAR 工具' } if (-not $rarExe) { throw '未找到 RAR 工具' }
Write-Log '使用 RAR 解压' -Level DEBUG Write-BakNRetLog '使用 RAR 解压' -Level DEBUG
$argument = @('x', '-idp', '-idn', '-y', $ArchiveFile.FullName, "$Destination\") $argument = @('x', '-idp', '-idn', '-y', $ArchiveFile.FullName, "$Destination\")
if ($RelativePath) { $argument += $RelativePath } if ($RelativePath) { $argument += $RelativePath }
$exitCode = Invoke-ExternalCommand -FilePath $rarExe -ArgumentList $argument $exitCode = Invoke-ExternalCommand -FilePath $rarExe -ArgumentList $argument
if ($exitCode -ne 0) { throw "RAR 解压失败(退出码:$exitCode)" } if ($exitCode -ne 0) { throw "RAR 解压失败(退出码:$exitCode)" }
} }
'.zip' { '.zip' {
Write-Log '使用内置 ZIP 解压' -Level DEBUG Write-BakNRetLog '使用内置 ZIP 解压' -Level DEBUG
if ($RelativePath) { if ($RelativePath) {
Write-Log "内置 ZIP 不支持只解子树,将整包解压($RelativePath)" -Level WARN Write-BakNRetLog "内置 ZIP 不支持只解子树,将整包解压($RelativePath)" -Level WARN
} }
Expand-Archive -LiteralPath $ArchiveFile.FullName -DestinationPath $Destination -Force Expand-Archive -LiteralPath $ArchiveFile.FullName -DestinationPath $Destination -Force
} }
'.tar' { '.tar' {
$tarExe = Get-Command tar -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source $tarExe = Get-Command tar -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source
if (-not $tarExe) { throw '未找到 TAR 工具' } if (-not $tarExe) { throw '未找到 TAR 工具' }
Write-Log '使用 TAR 解压' -Level DEBUG Write-BakNRetLog '使用 TAR 解压' -Level DEBUG
$argument = @('-xf', $ArchiveFile.FullName, '-C', $Destination) $argument = @('-xf', $ArchiveFile.FullName, '-C', $Destination)
if ($RelativePath) { $argument += $RelativePath } if ($RelativePath) { $argument += $RelativePath }
$exitCode = Invoke-ExternalCommand -FilePath $tarExe -ArgumentList $argument $exitCode = Invoke-ExternalCommand -FilePath $tarExe -ArgumentList $argument
@@ -308,18 +308,18 @@ function Invoke-ExtractionByLayout {
New-Item -ItemType Directory -Path $destPath -Force | Out-Null New-Item -ItemType Directory -Path $destPath -Force | Out-Null
} }
$anchorName = Get-BaknretArchiveTopName -ArchivePath $archivePath $anchorName = Get-BakNRetArchiveTopName -ArchivePath $archivePath
$anchorPath = if ($anchorName) { Join-Path $destParent $anchorName } else { $null } $anchorPath = if ($anchorName) { Join-Path $destParent $anchorName } else { $null }
$junctionCreated = $false $junctionCreated = $false
if ($anchorPath -and -not (Test-Path -LiteralPath $anchorPath)) { if ($anchorPath -and -not (Test-Path -LiteralPath $anchorPath)) {
try { try {
New-BaknretJunction -Path $anchorPath -Target $destPath | Out-Null New-BakNRetJunction -Path $anchorPath -Target $destPath | Out-Null
$junctionCreated = $true $junctionCreated = $true
Write-Log ("落地:{0} -> {1}(经连接点 {2})" -f $archivePath, $destPath, $anchorPath) -Level DEBUG Write-BakNRetLog ("落地:{0} -> {1}(经连接点 {2})" -f $archivePath, $destPath, $anchorPath) -Level DEBUG
} }
catch { catch {
Write-Log "无法建连接点($($_.Exception.Message)),改为先解到临时目录再合并" -Level WARN Write-BakNRetLog "无法建连接点($($_.Exception.Message)),改为先解到临时目录再合并" -Level WARN
} }
} }
@@ -328,11 +328,11 @@ function Invoke-ExtractionByLayout {
return (Invoke-ExtractionRaw -ArchiveFile $ArchiveFile -Destination $destParent -RelativePath $archivePath -Password $Password) return (Invoke-ExtractionRaw -ArchiveFile $ArchiveFile -Destination $destParent -RelativePath $archivePath -Password $Password)
} }
finally { finally {
Remove-BaknretJunction -Path $anchorPath Remove-BakNRetJunction -Path $anchorPath
} }
} }
Write-Log ("落地:{0} -> {1}(先解到临时目录再合并)" -f $archivePath, $destPath) -Level WARN Write-BakNRetLog ("落地:{0} -> {1}(先解到临时目录再合并)" -f $archivePath, $destPath) -Level WARN
$temp = Join-Path $env:TEMP ('bnr-merge-' + [guid]::NewGuid().ToString('N')) $temp = Join-Path $env:TEMP ('bnr-merge-' + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $temp -Force | Out-Null New-Item -ItemType Directory -Path $temp -Force | Out-Null
try { try {
@@ -353,7 +353,7 @@ function Invoke-ExtractionByLayout {
return $true return $true
} }
function Test-BaknretArchivePath { function Test-BakNRetArchivePath {
<# <#
.SYNOPSIS .SYNOPSIS
归档里有没有这条路径。 归档里有没有这条路径。
@@ -394,14 +394,14 @@ function Test-BaknretArchivePath {
$argument += $item $argument += $item
$null = Start-Process -FilePath $sevenZip ` $null = Start-Process -FilePath $sevenZip `
-ArgumentList (ConvertTo-NativeArgumentString -ArgumentList $argument) ` -ArgumentList (ConvertTo-BakNRetNativeArgumentString -ArgumentList $argument) `
-RedirectStandardOutput $outFile -RedirectStandardError $errFile ` -RedirectStandardOutput $outFile -RedirectStandardError $errFile `
-NoNewWindow -Wait -PassThru -NoNewWindow -Wait -PassThru
$lines = @(Get-Content -LiteralPath $outFile -Encoding UTF8 -ErrorAction SilentlyContinue) $lines = @(Get-Content -LiteralPath $outFile -Encoding UTF8 -ErrorAction SilentlyContinue)
} }
catch { catch {
Write-Log "无法列出归档内容(跳过预判):$($_.Exception.Message)" -Level DEBUG Write-BakNRetLog "无法列出归档内容(跳过预判):$($_.Exception.Message)" -Level DEBUG
return $true return $true
} }
finally { finally {
@@ -443,13 +443,13 @@ function Invoke-Extraction {
$destPath = [string]$Item.RealPath $destPath = [string]$Item.RealPath
$legacyName = Split-Path -Path $destPath -Leaf $legacyName = Split-Path -Path $destPath -Leaf
if (Test-BaknretArchivePath -ArchiveFile $ArchiveFile -RelativePath $archivePath -Password $Password) { if (Test-BakNRetArchivePath -ArchiveFile $ArchiveFile -RelativePath $archivePath -Password $Password) {
return (Invoke-ExtractionByLayout -ArchiveFile $ArchiveFile -Item $Item -Password $Password) return (Invoke-ExtractionByLayout -ArchiveFile $ArchiveFile -Item $Item -Password $Password)
} }
if ($legacyName -and ($legacyName -ine $archivePath) -and if ($legacyName -and ($legacyName -ine $archivePath) -and
(Test-BaknretArchivePath -ArchiveFile $ArchiveFile -RelativePath $legacyName -Password $Password)) { (Test-BakNRetArchivePath -ArchiveFile $ArchiveFile -RelativePath $legacyName -Password $Password)) {
Write-Log ("归档里没有 '{0}'(可能是重构前的旧归档),按旧布局回退为 '{1}'" -f $archivePath, $legacyName) -Level WARN Write-BakNRetLog ("归档里没有 '{0}'(可能是重构前的旧归档),按旧布局回退为 '{1}'" -f $archivePath, $legacyName) -Level WARN
$parent = Split-Path -Path $destPath -Parent $parent = Split-Path -Path $destPath -Parent
if (-not (Test-Path -LiteralPath $parent)) { New-Item -ItemType Directory -Path $parent -Force | Out-Null } if (-not (Test-Path -LiteralPath $parent)) { New-Item -ItemType Directory -Path $parent -Force | Out-Null }
return (Invoke-ExtractionRaw -ArchiveFile $ArchiveFile -Destination $parent -RelativePath $legacyName -Password $Password) return (Invoke-ExtractionRaw -ArchiveFile $ArchiveFile -Destination $parent -RelativePath $legacyName -Password $Password)
@@ -464,15 +464,15 @@ function Invoke-Extraction {
# ============================================================================ # ============================================================================
if (-not (Test-Path -LiteralPath $BackupDir)) { if (-not (Test-Path -LiteralPath $BackupDir)) {
Write-Log "备份目录不存在: $BackupDir" -Level ERROR Write-BakNRetLog "备份目录不存在: $BackupDir" -Level ERROR
Stop-BaknretLog Stop-BakNRetLog
exit 1 exit 1
} }
$manifest = Read-BaknretManifest -Path $manifestPath $manifest = Read-BakNRetManifest -Path $manifestPath
if (-not (Test-Path -LiteralPath $BackupListPath)) { if (-not (Test-Path -LiteralPath $BackupListPath)) {
Write-Log '未找到配置文件,正在从备份内容生成...' -Level INFO Write-BakNRetLog '未找到配置文件,正在从备份内容生成...' -Level INFO
$paths = @() $paths = @()
@@ -489,22 +489,22 @@ if (-not (Test-Path -LiteralPath $BackupListPath)) {
$backupFiles = Get-ChildItem -LiteralPath $BackupDir -File -Force -ErrorAction SilentlyContinue | $backupFiles = Get-ChildItem -LiteralPath $BackupDir -File -Force -ErrorAction SilentlyContinue |
Where-Object { $_.Extension.ToLower() -in $SupportedFormats -and $_.BaseName -match '_from_' } Where-Object { $_.Extension.ToLower() -in $SupportedFormats -and $_.BaseName -match '_from_' }
foreach ($file in $backupFiles) { foreach ($file in $backupFiles) {
$original = Convert-BackupFileNameToPath -FileName $file.Name $original = Convert-BakNRetBackupFileNameToPath -FileName $file.Name
if ($original) { $paths += $original } if ($original) { $paths += $original }
} }
} }
$paths = @($paths | Sort-Object -Unique) $paths = @($paths | Sort-Object -Unique)
if ($paths.Count -eq 0) { if ($paths.Count -eq 0) {
Write-Log '无法从备份内容还原出任何路径。' -Level ERROR Write-BakNRetLog '无法从备份内容还原出任何路径。' -Level ERROR
Stop-BaknretLog Stop-BakNRetLog
exit 1 exit 1
} }
$content = "# BackupList.txt(自动生成,排除规则需要手工补回)`n" + (($paths -join [Environment]::NewLine) + [Environment]::NewLine) $content = "# BackupList.txt(自动生成,排除规则需要手工补回)`n" + (($paths -join [Environment]::NewLine) + [Environment]::NewLine)
[System.IO.File]::WriteAllText($BackupListPath, $content, [System.Text.UTF8Encoding]::new($true)) [System.IO.File]::WriteAllText($BackupListPath, $content, [System.Text.UTF8Encoding]::new($true))
Write-Log "已生成配置,包含 $($paths.Count) 个项目,请检查后重新运行" -Level INFO Write-BakNRetLog "已生成配置,包含 $($paths.Count) 个项目,请检查后重新运行" -Level INFO
Stop-BaknretLog Stop-BakNRetLog
exit 0 exit 0
} }
@@ -539,14 +539,14 @@ $referencedArchives = @()
# "试运行不会写入任何文件" 的承诺(已用 manifest 的 SHA256 复现)。 # "试运行不会写入任何文件" 的承诺(已用 manifest 的 SHA256 复现)。
$manifestDirty = $false $manifestDirty = $false
Write-Log '开始执行恢复' -Level INFO Write-BakNRetLog '开始执行恢复' -Level INFO
foreach ($line in $lines) { foreach ($line in $lines) {
$item = ConvertFrom-BackupListLine -Line $line $item = ConvertFrom-BackupListLine -Line $line
if (-not $item) { continue } if (-not $item) { continue }
$displayPath = $item.Path $displayPath = $item.Path
$resolved = Resolve-BackupEntry -Entry $item -CatalogPath $catalogPath -MaxDepth $script:Config.CatalogMaxDepth $resolved = Resolve-BakNRetBackupEntry -Entry $item -CatalogPath $catalogPath -MaxDepth $script:Config.CatalogMaxDepth
$baseName = $resolved.BaseName $baseName = $resolved.BaseName
if (-not $baseName) { $stats.skipped++; continue } if (-not $baseName) { $stats.skipped++; continue }
@@ -556,14 +556,14 @@ foreach ($line in $lines) {
# 仅备份的条目照样要登记归档名:审计要能看出"这个归档是有主的", # 仅备份的条目照样要登记归档名:审计要能看出"这个归档是有主的",
# 否则它会被误报成孤儿(只是它本来就恢复不到,因为行首写了 +)。 # 否则它会被误报成孤儿(只是它本来就恢复不到,因为行首写了 +)。
$referencedArchives += $baseName $referencedArchives += $baseName
Write-Log "跳过(行首 +,仅备份): $displayPath" -Level DEBUG Write-BakNRetLog "跳过(行首 +,仅备份): $displayPath" -Level DEBUG
continue continue
} }
# 解析阶段就定死的结构性错误(名录条目有问题、归档内路径冲突): # 解析阶段就定死的结构性错误(名录条目有问题、归档内路径冲突):
# 恢复一半比明确失败更危险,所以整条失败。 # 恢复一半比明确失败更危险,所以整条失败。
if ($resolved.Blocking) { if ($resolved.Blocking) {
Write-Log "失败: $displayPath,$($resolved.Blocking)" -Level ERROR Write-BakNRetLog "失败: $displayPath,$($resolved.Blocking)" -Level ERROR
$stats.failed++ $stats.failed++
$failures += $displayPath $failures += $displayPath
continue continue
@@ -571,7 +571,7 @@ foreach ($line in $lines) {
$found = Get-ArchiveForEntry -Entry ([pscustomobject]@{ baseName = $baseName }) -Manifest $manifest $found = Get-ArchiveForEntry -Entry ([pscustomobject]@{ baseName = $baseName }) -Manifest $manifest
if (-not $found) { if (-not $found) {
Write-Log "跳过: $displayPath,未找到归档 $baseName" -Level WARN Write-BakNRetLog "跳过: $displayPath,未找到归档 $baseName" -Level WARN
$stats.skipped++ $stats.skipped++
continue continue
} }
@@ -639,7 +639,7 @@ foreach ($line in $lines) {
$targets = @($targets | Where-Object { $_.DestPath -and -not [string]::IsNullOrWhiteSpace($_.DestPath) }) $targets = @($targets | Where-Object { $_.DestPath -and -not [string]::IsNullOrWhiteSpace($_.DestPath) })
if ($targets.Count -eq 0) { if ($targets.Count -eq 0) {
$reason = "无法确定恢复目的地(清单条目 '$displayPath' 解析不出任何归档项)" $reason = "无法确定恢复目的地(清单条目 '$displayPath' 解析不出任何归档项)"
Write-Log "失败: $displayPath,$reason" -Level ERROR Write-BakNRetLog "失败: $displayPath,$reason" -Level ERROR
$stats.failed++ $stats.failed++
$failures += $displayPath $failures += $displayPath
continue continue
@@ -657,7 +657,7 @@ foreach ($line in $lines) {
$isEncrypted = [bool]$found.Record.encrypted $isEncrypted = [bool]$found.Record.encrypted
} }
if ($isEncrypted -and -not $password) { if ($isEncrypted -and -not $password) {
Write-Log "失败: $displayPath,归档已加密但取不到口令(设置 BAKNRET_PASSWORD 或用 -KeyFile 指定密码文件)" -Level ERROR Write-BakNRetLog "失败: $displayPath,归档已加密但取不到口令(设置 BAKNRET_PASSWORD 或用 -KeyFile 指定密码文件)" -Level ERROR
$stats.failed++ $stats.failed++
$failures += $displayPath $failures += $displayPath
continue continue
@@ -665,12 +665,12 @@ foreach ($line in $lines) {
if ($VerifyOnly) { if ($VerifyOnly) {
if ($archiveFile.Extension.ToLower() -ne '.7z') { if ($archiveFile.Extension.ToLower() -ne '.7z') {
Write-Log "跳过校验(非 7z): $($archiveFile.Name)" -Level DEBUG Write-BakNRetLog "跳过校验(非 7z): $($archiveFile.Name)" -Level DEBUG
continue continue
} }
$verifyTool = Get-7zExecutable $verifyTool = Get-7zExecutable
if (-not $verifyTool) { if (-not $verifyTool) {
Write-Log '未找到 7z,无法校验' -Level ERROR Write-BakNRetLog '未找到 7z,无法校验' -Level ERROR
$stats.failed++ $stats.failed++
$failures += $displayPath $failures += $displayPath
continue continue
@@ -680,45 +680,45 @@ foreach ($line in $lines) {
$verifyArgument += $archiveFile.FullName $verifyArgument += $archiveFile.FullName
$verifyCode = Invoke-ExternalCommand -FilePath $verifyTool -ArgumentList $verifyArgument $verifyCode = Invoke-ExternalCommand -FilePath $verifyTool -ArgumentList $verifyArgument
if ($verifyCode -eq 0) { if ($verifyCode -eq 0) {
Write-Log "校验通过: $($archiveFile.Name)" -Level INFO Write-BakNRetLog "校验通过: $($archiveFile.Name)" -Level INFO
$stats.verified++ $stats.verified++
} }
else { else {
Write-Log "校验失败: $($archiveFile.Name)(退出码 $verifyCode)" -Level ERROR Write-BakNRetLog "校验失败: $($archiveFile.Name)(退出码 $verifyCode)" -Level ERROR
$stats.failed++ $stats.failed++
$failures += $displayPath $failures += $displayPath
} }
continue continue
} }
Write-Log "准备恢复: $displayPath <- $($archiveFile.Name)(来源:$($found.Source))" -Level INFO Write-BakNRetLog "准备恢复: $displayPath <- $($archiveFile.Name)(来源:$($found.Source))" -Level INFO
if ((Test-Path -LiteralPath $destPath) -and -not $Force) { if ((Test-Path -LiteralPath $destPath) -and -not $Force) {
try { try {
$destSummary = Get-FolderSummary -FolderPath $destPath $destSummary = Get-BakNRetFolderSummary -FolderPath $destPath
$archiveTime = $archiveFile.LastWriteTime $archiveTime = $archiveFile.LastWriteTime
if ($destSummary.LatestModifiedTime -and $destSummary.LatestModifiedTime -gt $archiveTime) { if ($destSummary.LatestModifiedTime -and $destSummary.LatestModifiedTime -gt $archiveTime) {
Write-Log "跳过: $displayPath,目标目录比归档新(用 -Force 覆盖)" -Level WARN Write-BakNRetLog "跳过: $displayPath,目标目录比归档新(用 -Force 覆盖)" -Level WARN
$stats.skipped++ $stats.skipped++
continue continue
} }
} }
catch { catch {
Write-Log "目标目录摘要读取失败,继续恢复:$_" -Level DEBUG Write-BakNRetLog "目标目录摘要读取失败,继续恢复:$_" -Level DEBUG
} }
} }
$plannedTargets = @($targets | Where-Object { $_.DestPath }) $plannedTargets = @($targets | Where-Object { $_.DestPath })
# 说清楚"这条会把哪些目录还原到哪儿、为什么" # 说清楚"这条会把哪些目录还原到哪儿、为什么"
Write-Log ("恢复计划:{0}(归档 {1})" -f $displayPath, $archiveFile.Name) Write-BakNRetLog ("恢复计划:{0}(归档 {1})" -f $displayPath, $archiveFile.Name)
foreach ($target in $plannedTargets) { foreach ($target in $plannedTargets) {
$targetExists = Test-Path -LiteralPath $target.DestPath $targetExists = Test-Path -LiteralPath $target.DestPath
Write-Log (" 目标:{0}" -f $target.DestPath) Write-BakNRetLog (" 目标:{0}" -f $target.DestPath)
Write-Log (" 归档内路径:{0}({1});{2}" -f $target.ArchivePath, Write-BakNRetLog (" 归档内路径:{0}({1});{2}" -f $target.ArchivePath,
$(if ($target.IsFile) { '文件' } else { '目录' }), $(if ($target.IsFile) { '文件' } else { '目录' }),
$(if ($targetExists) { '已存在,将覆盖同名文件' } else { '不存在,将新建' })) $(if ($targetExists) { '已存在,将覆盖同名文件' } else { '不存在,将新建' }))
if ($target.Description) { Write-Log (" 介绍:{0}" -f $target.Description) } if ($target.Description) { Write-BakNRetLog (" 介绍:{0}" -f $target.Description) }
} }
foreach ($target in $plannedTargets) { foreach ($target in $plannedTargets) {
@@ -726,10 +726,10 @@ foreach ($line in $lines) {
# Split-Path -Parent 对根路径(如 "E:\")返回空串,此时无父目录可建 # Split-Path -Parent 对根路径(如 "E:\")返回空串,此时无父目录可建
$targetParent = Split-Path -Path $target.DestPath -Parent $targetParent = Split-Path -Path $target.DestPath -Parent
if ($targetParent) { if ($targetParent) {
Write-Log "提示: 目标不存在,将新建 $targetParent" -Level DEBUG Write-BakNRetLog "提示: 目标不存在,将新建 $targetParent" -Level DEBUG
} }
else { else {
Write-Log "提示: 目标不存在,且没有可创建的父目录:$($target.DestPath)" -Level DEBUG Write-BakNRetLog "提示: 目标不存在,且没有可创建的父目录:$($target.DestPath)" -Level DEBUG
} }
} }
@@ -740,7 +740,7 @@ foreach ($line in $lines) {
if (-not $shouldRun) { if (-not $shouldRun) {
foreach ($target in $plannedTargets) { foreach ($target in $plannedTargets) {
Write-Log "[试运行] 将解压 $($archiveFile.Name) -> $($target.DestPath)" -Level INFO Write-BakNRetLog "[试运行] 将解压 $($archiveFile.Name) -> $($target.DestPath)" -Level INFO
} }
$stats.planned++ $stats.planned++
continue continue
@@ -764,10 +764,10 @@ foreach ($line in $lines) {
# CREATOR OWNER 把全权给"对象的属主",属主一变,原程序就没了权限。 # CREATOR OWNER 把全权给"对象的属主",属主一变,原程序就没了权限。
# 必须在解压**之后**、对真实目标路径做(连接点在 Invoke-Extraction 里已经拆掉了)。 # 必须在解压**之后**、对真实目标路径做(连接点在 Invoke-Extraction 里已经拆掉了)。
if ($SkipSecurity) { if ($SkipSecurity) {
Write-Log '按 -SkipSecurity 跳过了安全描述符恢复' -Level DEBUG Write-BakNRetLog '按 -SkipSecurity 跳过了安全描述符恢复' -Level DEBUG
} }
elseif (([string]$script:Config.Security.Mode) -eq 'Off') { elseif (([string]$script:Config.Security.Mode) -eq 'Off') {
Write-Log '配置里 Security.Mode = Off,跳过安全描述符恢复' -Level DEBUG Write-BakNRetLog '配置里 Security.Mode = Off,跳过安全描述符恢复' -Level DEBUG
} }
else { else {
$sidecarName = $null $sidecarName = $null
@@ -776,9 +776,9 @@ foreach ($line in $lines) {
} }
if (-not $sidecarName) { $sidecarName = "$baseName.acl.json" } if (-not $sidecarName) { $sidecarName = "$baseName.acl.json" }
$sidecar = Read-BaknretSecuritySidecar -Path (Join-Path $BackupDir $sidecarName) $sidecar = Read-BakNRetSecuritySidecar -Path (Join-Path $BackupDir $sidecarName)
if (-not $sidecar) { if (-not $sidecar) {
Write-Log ("这个归档没有安全描述符旁挂文件({0}):恢复出来的属主/ACL 是新建对象的默认值 —— 原程序若依赖特殊权限(ProgramData 下的 CREATOR OWNER 最典型),会报无读写权限" -f $sidecarName) -Level WARN Write-BakNRetLog ("这个归档没有安全描述符旁挂文件({0}):恢复出来的属主/ACL 是新建对象的默认值 —— 原程序若依赖特殊权限(ProgramData 下的 CREATOR OWNER 最典型),会报无读写权限" -f $sidecarName) -Level WARN
} }
else { else {
$sidMap = @{} $sidMap = @{}
@@ -787,7 +787,7 @@ foreach ($line in $lines) {
$secTotal = 0; $secApplied = 0; $secOwnerFailed = 0; $secSkipped = 0; $secFailed = 0 $secTotal = 0; $secApplied = 0; $secOwnerFailed = 0; $secSkipped = 0; $secFailed = 0
$secMessages = @() $secMessages = @()
foreach ($target in $plannedTargets) { foreach ($target in $plannedTargets) {
$sec = Restore-BaknretSecurity -Sidecar $sidecar -ArchiveRoot $target.ArchivePath ` $sec = Restore-BakNRetSecurity -Sidecar $sidecar -ArchiveRoot $target.ArchivePath `
-TargetPath $target.DestPath -SidMap $sidMap -TargetPath $target.DestPath -SidMap $sidMap
$secTotal += $sec.Total $secTotal += $sec.Total
$secApplied += $sec.Applied $secApplied += $sec.Applied
@@ -798,20 +798,20 @@ foreach ($line in $lines) {
} }
$securityApplied += $secApplied $securityApplied += $secApplied
Write-Log ("安全描述符:回放 {0}/{1} 个对象(属主/属组未恢复 {2},跳过 {3},失败 {4})" -f ` Write-BakNRetLog ("安全描述符:回放 {0}/{1} 个对象(属主/属组未恢复 {2},跳过 {3},失败 {4})" -f `
$secApplied, $secTotal, $secOwnerFailed, $secSkipped, $secFailed) -Level INFO $secApplied, $secTotal, $secOwnerFailed, $secSkipped, $secFailed) -Level INFO
foreach ($message in @($secMessages | Select-Object -First 5)) { foreach ($message in @($secMessages | Select-Object -First 5)) {
Write-Log (" ! {0}" -f $message) -Level WARN Write-BakNRetLog (" ! {0}" -f $message) -Level WARN
} }
if ($secFailed -gt 0) { if ($secFailed -gt 0) {
Write-Log ("恢复成功但安全描述符有 {0} 个对象失败,已计入失败条目(退出码 1)" -f $secFailed) -Level ERROR Write-BakNRetLog ("恢复成功但安全描述符有 {0} 个对象失败,已计入失败条目(退出码 1)" -f $secFailed) -Level ERROR
$failures += $displayPath $failures += $displayPath
} }
} }
} }
$stats.restored++ $stats.restored++
Write-Log "恢复成功: $baseName" -Level INFO Write-BakNRetLog "恢复成功: $baseName" -Level INFO
if ($manifest.items.Contains($baseName)) { if ($manifest.items.Contains($baseName)) {
$record = $manifest.items[$baseName] $record = $manifest.items[$baseName]
@@ -830,7 +830,7 @@ foreach ($line in $lines) {
} }
} }
catch { catch {
Write-Log "恢复失败: $displayPath,$_" -Level ERROR Write-BakNRetLog "恢复失败: $displayPath,$_" -Level ERROR
$stats.failed++ $stats.failed++
$failures += $displayPath $failures += $displayPath
} }
@@ -845,48 +845,48 @@ if (-not $VerifyOnly -and $Only.Count -eq 0 -and $Skip.Count -eq 0) {
Where-Object { $_.Extension.ToLower() -in $SupportedFormats -and $_.BaseName -notin $referencedArchives }) Where-Object { $_.Extension.ToLower() -in $SupportedFormats -and $_.BaseName -notin $referencedArchives })
if ($orphans.Count -gt 0) { if ($orphans.Count -gt 0) {
Write-Log '以下归档没有任何清单条目指向(恢复不到,注意别误删):' -Level WARN Write-BakNRetLog '以下归档没有任何清单条目指向(恢复不到,注意别误删):' -Level WARN
foreach ($orphan in $orphans) { foreach ($orphan in $orphans) {
Write-Log (" - {0}({1} MB,{2})" -f $orphan.Name, [math]::Round($orphan.Length / 1MB, 2), $orphan.LastWriteTime) -Level WARN Write-BakNRetLog (" - {0}({1} MB,{2})" -f $orphan.Name, [math]::Round($orphan.Length / 1MB, 2), $orphan.LastWriteTime) -Level WARN
} }
} }
} }
elseif (-not $VerifyOnly) { elseif (-not $VerifyOnly) {
# 带 -Only/-Skip 时只有被选中的条目会被处理,其余归档都不在 $referencedArchives 里, # 带 -Only/-Skip 时只有被选中的条目会被处理,其余归档都不在 $referencedArchives 里,
# 按上面的算法报出来全是假孤儿(还会吓唬人说"注意别误删"),所以整段跳过。 # 按上面的算法报出来全是假孤儿(还会吓唬人说"注意别误删"),所以整段跳过。
Write-Log '本次只恢复了部分条目,跳过孤儿归档审计(避免把未选中的归档误报成孤儿)' -Level DEBUG Write-BakNRetLog '本次只恢复了部分条目,跳过孤儿归档审计(避免把未选中的归档误报成孤儿)' -Level DEBUG
} }
try { try {
if ($manifestDirty) { if ($manifestDirty) {
# 顺手维持"manifest 写了 archive,磁盘上就真有那个文件"这条不变式 # 顺手维持"manifest 写了 archive,磁盘上就真有那个文件"这条不变式
$null = Sync-BaknretManifestArchive -Manifest $manifest -BackupDir $BackupDir $null = Sync-BakNRetManifestArchive -Manifest $manifest -BackupDir $BackupDir
Write-BaknretManifest -Path $manifestPath -Manifest $manifest | Out-Null Write-BakNRetManifest -Path $manifestPath -Manifest $manifest | Out-Null
Write-Log 'manifest 已更新(记下本次恢复时间)' -Level DEBUG Write-BakNRetLog 'manifest 已更新(记下本次恢复时间)' -Level DEBUG
} }
else { else {
Write-Log 'manifest 无需更新:本次没有实际恢复任何条目' -Level DEBUG Write-BakNRetLog 'manifest 无需更新:本次没有实际恢复任何条目' -Level DEBUG
} }
} }
catch { catch {
Write-Log "manifest 写回失败(不影响本次恢复):$_" -Level WARN Write-BakNRetLog "manifest 写回失败(不影响本次恢复):$_" -Level WARN
} }
if ($failures.Count -gt 0) { if ($failures.Count -gt 0) {
Write-Log '失败条目:' -Level ERROR Write-BakNRetLog '失败条目:' -Level ERROR
foreach ($failure in $failures) { Write-Log " - $failure" -Level ERROR } foreach ($failure in $failures) { Write-BakNRetLog " - $failure" -Level ERROR }
} }
$summaryText = "恢复完成 - 成功:$($stats.restored),跳过:$($stats.skipped),失败:$($stats.failed)" $summaryText = "恢复完成 - 成功:$($stats.restored),跳过:$($stats.skipped),失败:$($stats.failed)"
if ($securityApplied -gt 0) { $summaryText += ",安全描述符:$securityApplied 个对象" } if ($securityApplied -gt 0) { $summaryText += ",安全描述符:$securityApplied 个对象" }
if ($VerifyOnly) { $summaryText = "校验完成 - 通过:$($stats.verified),失败:$($stats.failed)" } if ($VerifyOnly) { $summaryText = "校验完成 - 通过:$($stats.verified),失败:$($stats.failed)" }
if ($stats.planned -gt 0) { $summaryText += ",试运行计划:$($stats.planned)" } if ($stats.planned -gt 0) { $summaryText += ",试运行计划:$($stats.planned)" }
Write-Log $summaryText -Level INFO Write-BakNRetLog $summaryText -Level INFO
$logPath = Get-BaknretLogPath $logPath = Get-BakNRetLogPath
if ($logPath) { Write-Log "日志已写入:$logPath" -Level INFO } if ($logPath) { Write-BakNRetLog "日志已写入:$logPath" -Level INFO }
Exit-BaknretRunLock -Lock $runLock Exit-BakNRetRunLock -Lock $runLock
Stop-BaknretLog Stop-BakNRetLog
if ($stats.failed -gt 0) { exit 1 } if ($stats.failed -gt 0) { exit 1 }
exit 0 exit 0
+40 -40
View File
@@ -37,7 +37,7 @@ BeforeAll {
# 见 BakNRet.Tests.ps1 里的说明:本机沙箱禁止 PowerShell 为捕获原生子进程输出建管道, # 见 BakNRet.Tests.ps1 里的说明:本机沙箱禁止 PowerShell 为捕获原生子进程输出建管道,
# 所以走"临时 .cmd + 文件重定向 + Invoke-ExternalCommand(继承 stdio)"这条路。 # 所以走"临时 .cmd + 文件重定向 + Invoke-ExternalCommand(继承 stdio)"这条路。
function Invoke-BaknretScript { function Invoke-BakNRetScript {
param( param(
[Parameter(Mandatory = $true)][string]$Script, [Parameter(Mandatory = $true)][string]$Script,
[hashtable]$Parameters = @{} [hashtable]$Parameters = @{}
@@ -138,7 +138,7 @@ Describe '软件名录:Slot 形状(新契约)' {
} }
It '一个软件多个 Slot:Kind=Multi,Slot 按名排序且说明被保留' { It '一个软件多个 Slot:Kind=Multi,Slot 按名排序且说明被保留' {
$catalog = Get-SoftwareCatalog -Path $script:FormatCatalog -MaxDepth 3 -NoCache $catalog = Get-BakNRetSoftwareCatalog -Path $script:FormatCatalog -MaxDepth 3 -NoCache
$catalog['pair'].Kind | Should -Be 'Multi' $catalog['pair'].Kind | Should -Be 'Multi'
@($catalog['pair'].Slots).Count | Should -Be 2 @($catalog['pair'].Slots).Count | Should -Be 2
(@($catalog['pair'].Slots | ForEach-Object { $_.Name }) -join ',') | Should -Be 'A,B' (@($catalog['pair'].Slots | ForEach-Object { $_.Name }) -join ',') | Should -Be 'A,B'
@@ -150,7 +150,7 @@ Describe '软件名录:Slot 形状(新契约)' {
It '每个 Slot 都是一个独立的归档项来源(Kind=slot / Origin=catalog)' { It '每个 Slot 都是一个独立的归档项来源(Kind=slot / Origin=catalog)' {
$entry = ConvertFrom-BackupListLine -Line 'pair' $entry = ConvertFrom-BackupListLine -Line 'pair'
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3 $resolved = Resolve-BakNRetBackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3
@($resolved.Items).Count | Should -Be 2 @($resolved.Items).Count | Should -Be 2
(@($resolved.Items | ForEach-Object { $_.ArchivePath }) -join ',') | Should -Be 'A,B' (@($resolved.Items | ForEach-Object { $_.ArchivePath }) -join ',') | Should -Be 'A,B'
(@($resolved.Items | ForEach-Object { $_.Kind }) -join ',') | Should -Be 'slot,slot' (@($resolved.Items | ForEach-Object { $_.Kind }) -join ',') | Should -Be 'slot,slot'
@@ -161,22 +161,22 @@ Describe '软件名录:Slot 形状(新契约)' {
} }
It 'Slot 级排除写在 Slot 自己身上(相对本 Slot 的归档根)' { It 'Slot 级排除写在 Slot 自己身上(相对本 Slot 的归档根)' {
$catalog = Get-SoftwareCatalog -Path $script:FormatCatalog -MaxDepth 3 -NoCache $catalog = Get-BakNRetSoftwareCatalog -Path $script:FormatCatalog -MaxDepth 3 -NoCache
(@($catalog['slotex'].Slots[0].Exclude) -join '|') | Should -Be '!*Cache|logs\' (@($catalog['slotex'].Slots[0].Exclude) -join '|') | Should -Be '!*Cache|logs\'
$entry = ConvertFrom-BackupListLine -Line 'slotex' $entry = ConvertFrom-BackupListLine -Line 'slotex'
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3 $resolved = Resolve-BakNRetBackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3
(@($resolved.Items[0].Exclude) -join '|') | Should -Be '!*Cache|logs\' (@($resolved.Items[0].Exclude) -join '|') | Should -Be '!*Cache|logs\'
$resolved.HasExcludeOverride | Should -BeFalse $resolved.HasExcludeOverride | Should -BeFalse
} }
It '数组里"当前不存在"的 Slot 仍然产出归档项(恢复要靠它还原回原位)' { It '数组里"当前不存在"的 Slot 仍然产出归档项(恢复要靠它还原回原位)' {
$catalog = Get-SoftwareCatalog -Path $script:FormatCatalog -MaxDepth 3 -NoCache $catalog = Get-BakNRetSoftwareCatalog -Path $script:FormatCatalog -MaxDepth 3 -NoCache
$catalog['partial'].Kind | Should -Be 'Partial' $catalog['partial'].Kind | Should -Be 'Partial'
@($catalog['partial'].Missing) | Should -Contain $script:MissingDir @($catalog['partial'].Missing) | Should -Contain $script:MissingDir
$entry = ConvertFrom-BackupListLine -Line 'partial' $entry = ConvertFrom-BackupListLine -Line 'partial'
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3 $resolved = Resolve-BakNRetBackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3
@($resolved.Items).Count | Should -Be 2 @($resolved.Items).Count | Should -Be 2
@($resolved.Items | ForEach-Object { $_.RealPath }) | Should -Contain $script:MissingDir @($resolved.Items | ForEach-Object { $_.RealPath }) | Should -Contain $script:MissingDir
$resolved.Blocking | Should -BeNullOrEmpty # 源不存在不是致命错误 $resolved.Blocking | Should -BeNullOrEmpty # 源不存在不是致命错误
@@ -184,7 +184,7 @@ Describe '软件名录:Slot 形状(新契约)' {
It '文件 Slot:归档项是文件项(归档里就是名为 Slot 的文件)' { It '文件 Slot:归档项是文件项(归档里就是名为 Slot 的文件)' {
$entry = ConvertFrom-BackupListLine -Line 'fileapp' $entry = ConvertFrom-BackupListLine -Line 'fileapp'
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3 $resolved = Resolve-BakNRetBackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3
@($resolved.Items).Count | Should -Be 1 @($resolved.Items).Count | Should -Be 1
$resolved.Items[0].IsFile | Should -BeTrue $resolved.Items[0].IsFile | Should -BeTrue
$resolved.Items[0].ArchivePath | Should -Be 'Cfg' $resolved.Items[0].ArchivePath | Should -Be 'Cfg'
@@ -193,13 +193,13 @@ Describe '软件名录:Slot 形状(新契约)' {
} }
It '旧的裸字符串 / 字符串数组写法被拒绝(ERROR + 跳过)' { It '旧的裸字符串 / 字符串数组写法被拒绝(ERROR + 跳过)' {
$catalog = Get-SoftwareCatalog -Path $script:FormatCatalog -MaxDepth 3 -NoCache $catalog = Get-BakNRetSoftwareCatalog -Path $script:FormatCatalog -MaxDepth 3 -NoCache
$catalog.ContainsKey('legacystr') | Should -BeFalse $catalog.ContainsKey('legacystr') | Should -BeFalse
$catalog.ContainsKey('legacyarr') | Should -BeFalse $catalog.ContainsKey('legacyarr') | Should -BeFalse
} }
It '旧的 @{ Dirs = @(...) } 写法不再展开,留下 Invalid 与原因' { It '旧的 @{ Dirs = @(...) } 写法不再展开,留下 Invalid 与原因' {
$catalog = Get-SoftwareCatalog -Path $script:FormatCatalog -MaxDepth 3 -NoCache $catalog = Get-BakNRetSoftwareCatalog -Path $script:FormatCatalog -MaxDepth 3 -NoCache
$catalog.ContainsKey('legacydirs') | Should -BeTrue $catalog.ContainsKey('legacydirs') | Should -BeTrue
$catalog['legacydirs'].Kind | Should -Be 'Invalid' $catalog['legacydirs'].Kind | Should -Be 'Invalid'
$catalog['legacydirs'].Error | Should -Not -BeNullOrEmpty $catalog['legacydirs'].Error | Should -Not -BeNullOrEmpty
@@ -208,7 +208,7 @@ Describe '软件名录:Slot 形状(新契约)' {
It '多 Slot 的名录条目在清单里仍然按软件名命名归档' { It '多 Slot 的名录条目在清单里仍然按软件名命名归档' {
$entry = ConvertFrom-BackupListLine -Line 'pair' $entry = ConvertFrom-BackupListLine -Line 'pair'
(Get-ItemArchiveName -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3) | Should -Be 'pair' (Get-BakNRetItemArchiveName -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3) | Should -Be 'pair'
} }
} }
@@ -227,7 +227,7 @@ Describe '清单修饰符:新契约格式' {
It ':: 覆盖 Path:单 Slot 条目直接生效' { It ':: 覆盖 Path:单 Slot 条目直接生效' {
$entry = ConvertFrom-BackupListLine -Line "solo :: $script:DirB" $entry = ConvertFrom-BackupListLine -Line "solo :: $script:DirB"
$entry.Overrides.ContainsKey('Path') | Should -BeTrue $entry.Overrides.ContainsKey('Path') | Should -BeTrue
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3 $resolved = Resolve-BakNRetBackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3
@($resolved.Items).Count | Should -Be 1 @($resolved.Items).Count | Should -Be 1
$resolved.Items[0].ArchivePath | Should -Be 'Only' $resolved.Items[0].ArchivePath | Should -Be 'Only'
$resolved.Items[0].RealPath | Should -Be $script:DirB $resolved.Items[0].RealPath | Should -Be $script:DirB
@@ -236,7 +236,7 @@ Describe '清单修饰符:新契约格式' {
It ':: 覆盖遇到多 Slot 条目 -> Blocking(不知道给哪一个,绝不猜)' { It ':: 覆盖遇到多 Slot 条目 -> Blocking(不知道给哪一个,绝不猜)' {
$entry = ConvertFrom-BackupListLine -Line "pair :: $script:DirB" $entry = ConvertFrom-BackupListLine -Line "pair :: $script:DirB"
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3 $resolved = Resolve-BakNRetBackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3
@($resolved.Items).Count | Should -Be 0 @($resolved.Items).Count | Should -Be 0
$resolved.Blocking | Should -Match '不能用一个' $resolved.Blocking | Should -Match '不能用一个'
} }
@@ -246,7 +246,7 @@ Describe '清单修饰符:新契约格式' {
(@($entry.ExcludePatterns) -join '|') | Should -Be 'logs\|!*Cache' (@($entry.ExcludePatterns) -join '|') | Should -Be 'logs\|!*Cache'
(@($entry.Includes) -join '|') | Should -Be "Mods:$script:DirB" (@($entry.Includes) -join '|') | Should -Be "Mods:$script:DirB"
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3 $resolved = Resolve-BakNRetBackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3
$resolved.HasExcludeOverride | Should -BeTrue $resolved.HasExcludeOverride | Should -BeTrue
$resolved.HasIncludeOverride | Should -BeTrue $resolved.HasIncludeOverride | Should -BeTrue
@($resolved.Items | ForEach-Object { $_.ArchivePath }) | Should -Contain 'Mods' @($resolved.Items | ForEach-Object { $_.ArchivePath }) | Should -Contain 'Mods'
@@ -261,13 +261,13 @@ Describe '清单修饰符:新契约格式' {
} }
It ':encrypt / :!encrypt 覆盖名录里的加密默认值' { It ':encrypt / :!encrypt 覆盖名录里的加密默认值' {
$base = Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line 'fileapp') -CatalogPath $script:FormatCatalog -MaxDepth 3 $base = Resolve-BakNRetBackupEntry -Entry (ConvertFrom-BackupListLine -Line 'fileapp') -CatalogPath $script:FormatCatalog -MaxDepth 3
$base.Encrypt | Should -BeTrue # 名录里 Cfg Slot 标了 Encrypt $base.Encrypt | Should -BeTrue # 名录里 Cfg Slot 标了 Encrypt
$off = Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line 'fileapp :!encrypt') -CatalogPath $script:FormatCatalog -MaxDepth 3 $off = Resolve-BakNRetBackupEntry -Entry (ConvertFrom-BackupListLine -Line 'fileapp :!encrypt') -CatalogPath $script:FormatCatalog -MaxDepth 3
$off.Encrypt | Should -BeFalse $off.Encrypt | Should -BeFalse
$on = Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line 'pair :encrypt') -CatalogPath $script:FormatCatalog -MaxDepth 3 $on = Resolve-BakNRetBackupEntry -Entry (ConvertFrom-BackupListLine -Line 'pair :encrypt') -CatalogPath $script:FormatCatalog -MaxDepth 3
$on.Encrypt | Should -BeTrue $on.Encrypt | Should -BeTrue
} }
@@ -278,8 +278,8 @@ Describe '清单修饰符:新契约格式' {
# @pathname 对软件名条目也会改用真实路径命名 # @pathname 对软件名条目也会改用真实路径命名
$entry = ConvertFrom-BackupListLine -Line 'pair @pathname' $entry = ConvertFrom-BackupListLine -Line 'pair @pathname'
$expected = Get-BackupBaseName -RawPath $script:DirA $expected = Get-BakNRetBackupBaseName -RawPath $script:DirA
(Get-ItemArchiveName -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3) | Should -Be $expected (Get-BakNRetItemArchiveName -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3) | Should -Be $expected
} }
It '同一行里重复写同类记号会累积(不静默丢掉前一条规则)' { It '同一行里重复写同类记号会累积(不静默丢掉前一条规则)' {
@@ -350,14 +350,14 @@ Describe '集成:Slot 布局的打包与恢复' -Skip:(-not ($script:HasSevenZ
$script:SlotList = Write-ListFile -Path (Join-Path $script:SlotRoot 'list.txt') -Content "appkit`n" $script:SlotList = Write-ListFile -Path (Join-Path $script:SlotRoot 'list.txt') -Content "appkit`n"
$script:SlotBackupDir = Join-Path $script:SlotRoot 'Backups' $script:SlotBackupDir = Join-Path $script:SlotRoot 'Backups'
$script:SlotBackupRun = Invoke-BaknretScript -Script $script:BackupScript -Parameters @{ $script:SlotBackupRun = Invoke-BakNRetScript -Script $script:BackupScript -Parameters @{
BackupListPath = $script:SlotList BackupListPath = $script:SlotList
BackupDir = $script:SlotBackupDir BackupDir = $script:SlotBackupDir
ConfigPath = $script:SlotConfig ConfigPath = $script:SlotConfig
Force = $true Force = $true
QuietTool = $true QuietTool = $true
} }
$script:SlotManifest = Read-BaknretManifest -Path (Join-Path $script:SlotBackupDir 'manifest.json') $script:SlotManifest = Read-BakNRetManifest -Path (Join-Path $script:SlotBackupDir 'manifest.json')
$script:SlotArchive = @(Get-ChildItem -LiteralPath $script:SlotBackupDir -File -Filter *.7z)[0] $script:SlotArchive = @(Get-ChildItem -LiteralPath $script:SlotBackupDir -File -Filter *.7z)[0]
} }
@@ -367,7 +367,7 @@ Describe '集成:Slot 布局的打包与恢复' -Skip:(-not ($script:HasSevenZ
} }
It '归档顶层就是各个 Slot 名(目录 Slot + 文件 Slot + Include 项)' { It '归档顶层就是各个 Slot 名(目录 Slot + 文件 Slot + Include 项)' {
$top = @(Get-ArchiveTopLevelNames -ArchivePath $script:SlotArchive.FullName -SevenZip $script:SevenZip) $top = @(Get-BakNRetArchiveTopLevelNames -ArchivePath $script:SlotArchive.FullName -SevenZip $script:SevenZip)
(@($top | Sort-Object) -join ',') | Should -Be 'Cfg,Data,Extra,Modules' (@($top | Sort-Object) -join ',') | Should -Be 'Cfg,Data,Extra,Modules'
} }
@@ -396,7 +396,7 @@ Describe '集成:Slot 布局的打包与恢复' -Skip:(-not ($script:HasSevenZ
Remove-Item -LiteralPath (Join-Path $script:SlotRoot 'apps') -Recurse -Force Remove-Item -LiteralPath (Join-Path $script:SlotRoot 'apps') -Recurse -Force
Remove-Item -LiteralPath $script:SlotInclude -Recurse -Force Remove-Item -LiteralPath $script:SlotInclude -Recurse -Force
$run = Invoke-BaknretScript -Script $script:RestoreScript -Parameters @{ $run = Invoke-BakNRetScript -Script $script:RestoreScript -Parameters @{
BackupListPath = $script:SlotList BackupListPath = $script:SlotList
BackupDir = $script:SlotBackupDir BackupDir = $script:SlotBackupDir
ConfigPath = $script:SlotConfig ConfigPath = $script:SlotConfig
@@ -422,7 +422,7 @@ Describe '集成:Slot 布局的打包与恢复' -Skip:(-not ($script:HasSevenZ
} }
It '恢复之后 manifest 记下 lastRestoreAt' { It '恢复之后 manifest 记下 lastRestoreAt' {
$manifest = Read-BaknretManifest -Path (Join-Path $script:SlotBackupDir 'manifest.json') $manifest = Read-BakNRetManifest -Path (Join-Path $script:SlotBackupDir 'manifest.json')
$manifest.items['appkit'].lastRestoreAt | Should -Not -BeNullOrEmpty $manifest.items['appkit'].lastRestoreAt | Should -Not -BeNullOrEmpty
} }
} }
@@ -462,7 +462,7 @@ Describe '集成:旧布局归档的回退恢复' -Skip:(-not ($script:HasSeven
} }
It '归档确实是旧布局:顶层是源目录名而不是 Slot 名' { It '归档确实是旧布局:顶层是源目录名而不是 Slot 名' {
$top = @(Get-ArchiveTopLevelNames -ArchivePath $script:LegacyArchive -SevenZip $script:SevenZip) $top = @(Get-BakNRetArchiveTopLevelNames -ArchivePath $script:LegacyArchive -SevenZip $script:SevenZip)
(@($top | Sort-Object) -join ',') | Should -Be $script:LegacyLeaf (@($top | Sort-Object) -join ',') | Should -Be $script:LegacyLeaf
} }
@@ -471,7 +471,7 @@ Describe '集成:旧布局归档的回退恢复' -Skip:(-not ($script:HasSeven
# 不能靠 7z 的退出码猜(7z 对不存在的条目同样返回 0)。 # 不能靠 7z 的退出码猜(7z 对不存在的条目同样返回 0)。
Remove-Item -LiteralPath (Join-Path $script:LegacyDestParent $script:LegacyLeaf) -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath (Join-Path $script:LegacyDestParent $script:LegacyLeaf) -Recurse -Force -ErrorAction SilentlyContinue
$run = Invoke-BaknretScript -Script $script:RestoreScript -Parameters @{ $run = Invoke-BakNRetScript -Script $script:RestoreScript -Parameters @{
BackupListPath = $script:LegacyList BackupListPath = $script:LegacyList
BackupDir = $script:LegacyBackupDir BackupDir = $script:LegacyBackupDir
ConfigPath = $script:LegacyConfig ConfigPath = $script:LegacyConfig
@@ -490,7 +490,7 @@ Describe '集成:旧布局归档的回退恢复' -Skip:(-not ($script:HasSeven
$missingList = Write-ListFile -Path (Join-Path $script:LegacyRoot 'missing-list.txt') ` $missingList = Write-ListFile -Path (Join-Path $script:LegacyRoot 'missing-list.txt') `
-Content "oldapp :: $script:LegacyRoot\dest2\Nothing Here`n" -Content "oldapp :: $script:LegacyRoot\dest2\Nothing Here`n"
$run = Invoke-BaknretScript -Script $script:RestoreScript -Parameters @{ $run = Invoke-BakNRetScript -Script $script:RestoreScript -Parameters @{
BackupListPath = $missingList BackupListPath = $missingList
BackupDir = $script:LegacyBackupDir BackupDir = $script:LegacyBackupDir
ConfigPath = $script:LegacyConfig ConfigPath = $script:LegacyConfig
@@ -522,7 +522,7 @@ Describe '集成:归档内路径冲突会被拒绝执行' -Skip:(-not ($script
$script:RejectConfig = Write-ListFile -Path (Join-Path $script:RejectRoot 'config.psd1') -Content "@{ SoftwareCatalog = '$script:RejectCatalog' }`n" $script:RejectConfig = Write-ListFile -Path (Join-Path $script:RejectRoot 'config.psd1') -Content "@{ SoftwareCatalog = '$script:RejectCatalog' }`n"
$script:RejectBackupDir = Join-Path $script:RejectRoot 'Backups' $script:RejectBackupDir = Join-Path $script:RejectRoot 'Backups'
$script:RejectRun = Invoke-BaknretScript -Script $script:BackupScript -Parameters @{ $script:RejectRun = Invoke-BakNRetScript -Script $script:BackupScript -Parameters @{
BackupListPath = $script:RejectList BackupListPath = $script:RejectList
BackupDir = $script:RejectBackupDir BackupDir = $script:RejectBackupDir
ConfigPath = $script:RejectConfig ConfigPath = $script:RejectConfig
@@ -538,7 +538,7 @@ Describe '集成:归档内路径冲突会被拒绝执行' -Skip:(-not ($script
} }
It 'manifest 里记下这次是 failed,并带上原因' { It 'manifest 里记下这次是 failed,并带上原因' {
$manifest = Read-BaknretManifest -Path (Join-Path $script:RejectBackupDir 'manifest.json') $manifest = Read-BakNRetManifest -Path (Join-Path $script:RejectBackupDir 'manifest.json')
$record = $manifest.items['collide'] $record = $manifest.items['collide']
$record.action | Should -Be 'failed' $record.action | Should -Be 'failed'
$record.reason | Should -Match '归档内路径冲突' $record.reason | Should -Match '归档内路径冲突'
@@ -568,7 +568,7 @@ Describe '条目从清单里消失后,旧归档必须被点名为孤儿' -Skip
# 第一次:清单里有 my-app # 第一次:清单里有 my-app
Write-ListFile -Path $script:OrphanList -Content "my-app`n" | Out-Null Write-ListFile -Path $script:OrphanList -Content "my-app`n" | Out-Null
$null = Invoke-BaknretScript -Script $script:BackupScript -Parameters @{ $null = Invoke-BakNRetScript -Script $script:BackupScript -Parameters @{
BackupListPath = $script:OrphanList BackupListPath = $script:OrphanList
BackupDir = $script:OrphanBackupDir BackupDir = $script:OrphanBackupDir
ConfigPath = $script:OrphanConfig ConfigPath = $script:OrphanConfig
@@ -578,7 +578,7 @@ Describe '条目从清单里消失后,旧归档必须被点名为孤儿' -Skip
# 第二次:把条目从清单里拿掉,归档留在磁盘上 # 第二次:把条目从清单里拿掉,归档留在磁盘上
Write-ListFile -Path $script:OrphanList -Content "# 清单里没有条目了`n" | Out-Null Write-ListFile -Path $script:OrphanList -Content "# 清单里没有条目了`n" | Out-Null
$script:OrphanRun = Invoke-BaknretScript -Script $script:BackupScript -Parameters @{ $script:OrphanRun = Invoke-BakNRetScript -Script $script:BackupScript -Parameters @{
BackupListPath = $script:OrphanList BackupListPath = $script:OrphanList
BackupDir = $script:OrphanBackupDir BackupDir = $script:OrphanBackupDir
ConfigPath = $script:OrphanConfig ConfigPath = $script:OrphanConfig
@@ -588,7 +588,7 @@ Describe '条目从清单里消失后,旧归档必须被点名为孤儿' -Skip
It '归档确实还在磁盘上,manifest 里也还留着历史记录' { It '归档确实还在磁盘上,manifest 里也还留着历史记录' {
Test-Path -LiteralPath (Join-Path $script:OrphanBackupDir 'my-app.7z') | Should -BeTrue Test-Path -LiteralPath (Join-Path $script:OrphanBackupDir 'my-app.7z') | Should -BeTrue
$manifest = Read-BaknretManifest -Path (Join-Path $script:OrphanBackupDir 'manifest.json') $manifest = Read-BakNRetManifest -Path (Join-Path $script:OrphanBackupDir 'manifest.json')
$manifest.items.Contains('my-app') | Should -BeTrue $manifest.items.Contains('my-app') | Should -BeTrue
} }
@@ -610,16 +610,16 @@ Describe 'manifest 一致性:archive 字段只在文件真的存在时才写'
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $script:SyncRoot 'real.7z') 'x' Set-Content -Encoding UTF8 -LiteralPath (Join-Path $script:SyncRoot 'real.7z') 'x'
$script:SyncManifestPath = Join-Path $script:SyncRoot 'manifest.json' $script:SyncManifestPath = Join-Path $script:SyncRoot 'manifest.json'
$manifest = Read-BaknretManifest -Path $script:SyncManifestPath $manifest = Read-BakNRetManifest -Path $script:SyncManifestPath
$manifest.items['real'] = [pscustomobject]@{ archive = 'real.7z'; action = 'backed-up'; source = 'real' } $manifest.items['real'] = [pscustomobject]@{ archive = 'real.7z'; action = 'backed-up'; source = 'real' }
$manifest.items['gone'] = [pscustomobject]@{ archive = 'gone.7z'; action = 'missing-source'; source = 'gone' } $manifest.items['gone'] = [pscustomobject]@{ archive = 'gone.7z'; action = 'missing-source'; source = 'gone' }
$manifest.items['none'] = [pscustomobject]@{ archive = $null; action = 'missing-source'; source = 'none' } $manifest.items['none'] = [pscustomobject]@{ archive = $null; action = 'missing-source'; source = 'none' }
Write-BaknretManifest -Path $script:SyncManifestPath -Manifest $manifest | Out-Null Write-BakNRetManifest -Path $script:SyncManifestPath -Manifest $manifest | Out-Null
} }
It '存在的归档保留 archive,不存在的被清空' { It '存在的归档保留 archive,不存在的被清空' {
$manifest = Read-BaknretManifest -Path $script:SyncManifestPath $manifest = Read-BakNRetManifest -Path $script:SyncManifestPath
$cleared = Sync-BaknretManifestArchive -Manifest $manifest -BackupDir $script:SyncRoot $cleared = Sync-BakNRetManifestArchive -Manifest $manifest -BackupDir $script:SyncRoot
$cleared.Count | Should -Be 1 $cleared.Count | Should -Be 1
$cleared[0] | Should -Be 'gone' $cleared[0] | Should -Be 'gone'
@@ -629,16 +629,16 @@ Describe 'manifest 一致性:archive 字段只在文件真的存在时才写'
} }
It '清空 archive 时保留条目本身的历史(source / action 不动)' { It '清空 archive 时保留条目本身的历史(source / action 不动)' {
$manifest = Read-BaknretManifest -Path $script:SyncManifestPath $manifest = Read-BakNRetManifest -Path $script:SyncManifestPath
$null = Sync-BaknretManifestArchive -Manifest $manifest -BackupDir $script:SyncRoot $null = Sync-BakNRetManifestArchive -Manifest $manifest -BackupDir $script:SyncRoot
$manifest.items['gone'].source | Should -Be 'gone' $manifest.items['gone'].source | Should -Be 'gone'
$manifest.items['gone'].action | Should -Be 'missing-source' $manifest.items['gone'].action | Should -Be 'missing-source'
} }
It '人工删掉归档之后再同步一次,记录会被纠正过来' { It '人工删掉归档之后再同步一次,记录会被纠正过来' {
Remove-Item -LiteralPath (Join-Path $script:SyncRoot 'real.7z') -Force Remove-Item -LiteralPath (Join-Path $script:SyncRoot 'real.7z') -Force
$manifest = Read-BaknretManifest -Path $script:SyncManifestPath $manifest = Read-BakNRetManifest -Path $script:SyncManifestPath
$cleared = Sync-BaknretManifestArchive -Manifest $manifest -BackupDir $script:SyncRoot $cleared = Sync-BakNRetManifestArchive -Manifest $manifest -BackupDir $script:SyncRoot
$cleared | Should -Contain 'real' $cleared | Should -Contain 'real'
$manifest.items['real'].archive | Should -BeNullOrEmpty $manifest.items['real'].archive | Should -BeNullOrEmpty
+40 -40
View File
@@ -118,7 +118,7 @@ BeforeAll {
Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction SilentlyContinue
} }
function Invoke-BaknretScript { function Invoke-BakNRetScript {
<# .SYNOPSIS 用子进程跑 Backup.ps1 / Restore.ps1(两个脚本结尾都 exit,必须独立进程)。 #> <# .SYNOPSIS 用子进程跑 Backup.ps1 / Restore.ps1(两个脚本结尾都 exit,必须独立进程)。 #>
param( param(
[Parameter(Mandatory = $true)][string]$Script, [Parameter(Mandatory = $true)][string]$Script,
@@ -224,31 +224,31 @@ AfterAll {
Describe '排除判定与 7z 的 -x! / -xr! 语义对齐' { Describe '排除判定与 7z 的 -x! / -xr! 语义对齐' {
# ============================================================================ # ============================================================================
It '锚定模式只命中它自己那棵子树' { It '锚定模式只命中它自己那棵子树' {
Test-BaknretPathExcluded -RelativePath 'Default\Cache' -Patterns @('Default\Cache') | Should -BeTrue Test-BakNRetPathExcluded -RelativePath 'Default\Cache' -Patterns @('Default\Cache') | Should -BeTrue
Test-BaknretPathExcluded -RelativePath 'Default\Cache\sub\x.bin' -Patterns @('Default\Cache') | Should -BeFalse Test-BakNRetPathExcluded -RelativePath 'Default\Cache\sub\x.bin' -Patterns @('Default\Cache') | Should -BeFalse
Test-BaknretPathExcluded -RelativePath 'Other\Cache' -Patterns @('Default\Cache') | Should -BeFalse Test-BakNRetPathExcluded -RelativePath 'Other\Cache' -Patterns @('Default\Cache') | Should -BeFalse
} }
It '! 通配按任意层级的组件名匹配(* 不是正则)' { It '! 通配按任意层级的组件名匹配(* 不是正则)' {
Test-BaknretPathExcluded -RelativePath 'a\Code Cache\f' -Patterns @('!*Cache') | Should -BeTrue Test-BakNRetPathExcluded -RelativePath 'a\Code Cache\f' -Patterns @('!*Cache') | Should -BeTrue
Test-BaknretPathExcluded -RelativePath 'Code Cache' -Patterns @('!*Cache') | Should -BeTrue Test-BakNRetPathExcluded -RelativePath 'Code Cache' -Patterns @('!*Cache') | Should -BeTrue
Test-BaknretPathExcluded -RelativePath 'a\teamcache.bin' -Patterns @('!*Cache') | Should -BeFalse Test-BakNRetPathExcluded -RelativePath 'a\teamcache.bin' -Patterns @('!*Cache') | Should -BeFalse
} }
It '!re: 走正则,且组件名与整条相对路径都算命中' { It '!re: 走正则,且组件名与整条相对路径都算命中' {
Test-BaknretPathExcluded -RelativePath 'x\y\a.log' -Patterns @('!re:\.log$') | Should -BeTrue Test-BakNRetPathExcluded -RelativePath 'x\y\a.log' -Patterns @('!re:\.log$') | Should -BeTrue
Test-BaknretPathExcluded -RelativePath 'x\y\a.txt' -Patterns @('!re:\.log$') | Should -BeFalse Test-BakNRetPathExcluded -RelativePath 'x\y\a.txt' -Patterns @('!re:\.log$') | Should -BeFalse
Test-BaknretPathExcluded -RelativePath 'Cache\data' -Patterns @('!re:^Cache$') | Should -BeTrue Test-BakNRetPathExcluded -RelativePath 'Cache\data' -Patterns @('!re:^Cache$') | Should -BeTrue
} }
It '没有模式时一律不排除' { It '没有模式时一律不排除' {
Test-BaknretPathExcluded -RelativePath 'a\b' -Patterns @() | Should -BeFalse Test-BakNRetPathExcluded -RelativePath 'a\b' -Patterns @() | Should -BeFalse
Test-BaknretPathExcluded -RelativePath '' -Patterns @('!*') | Should -BeFalse Test-BakNRetPathExcluded -RelativePath '' -Patterns @('!*') | Should -BeFalse
} }
It '模式里的空格按 7z 的规矩当 ? 处理' { It '模式里的空格按 7z 的规矩当 ? 处理' {
Test-BaknretPathExcluded -RelativePath 'a\Cache' -Patterns @('!*Cache Extras') | Should -BeFalse Test-BakNRetPathExcluded -RelativePath 'a\Cache' -Patterns @('!*Cache Extras') | Should -BeFalse
Test-BaknretPathExcluded -RelativePath 'a\Cache Extras' -Patterns @('!*Cache Extras') | Should -BeTrue Test-BakNRetPathExcluded -RelativePath 'a\Cache Extras' -Patterns @('!*Cache Extras') | Should -BeTrue
} }
} }
@@ -257,19 +257,19 @@ Describe 'SID 映射(跨机恢复)' {
# ============================================================================ # ============================================================================
It '整 SID 精确替换' { It '整 SID 精确替换' {
$sddl = 'O:S-1-5-21-1-2-3-1001G:S-1-5-21-1-2-3-1001D:(A;;FA;;;S-1-5-21-1-2-3-1001)' $sddl = 'O:S-1-5-21-1-2-3-1001G:S-1-5-21-1-2-3-1001D:(A;;FA;;;S-1-5-21-1-2-3-1001)'
$mapped = Convert-BaknretSidMap -Sddl $sddl -SidMap @{ 'S-1-5-21-1-2-3-1001' = 'S-1-5-21-9-8-7-1001' } $mapped = Convert-BakNRetSidMap -Sddl $sddl -SidMap @{ 'S-1-5-21-1-2-3-1001' = 'S-1-5-21-9-8-7-1001' }
$mapped | Should -Be 'O:S-1-5-21-9-8-7-1001G:S-1-5-21-9-8-7-1001D:(A;;FA;;;S-1-5-21-9-8-7-1001)' $mapped | Should -Be 'O:S-1-5-21-9-8-7-1001G:S-1-5-21-9-8-7-1001D:(A;;FA;;;S-1-5-21-9-8-7-1001)'
} }
It '不会误伤以它为前缀的更长的 SID' { It '不会误伤以它为前缀的更长的 SID' {
$sddl = 'D:(A;;FA;;;S-1-5-21-1-2-3-1001)(A;;FA;;;S-1-5-21-1-2-3-10012)' $sddl = 'D:(A;;FA;;;S-1-5-21-1-2-3-1001)(A;;FA;;;S-1-5-21-1-2-3-10012)'
$mapped = Convert-BaknretSidMap -Sddl $sddl -SidMap @{ 'S-1-5-21-1-2-3-1001' = 'S-1-5-21-9-8-7-1001' } $mapped = Convert-BakNRetSidMap -Sddl $sddl -SidMap @{ 'S-1-5-21-1-2-3-1001' = 'S-1-5-21-9-8-7-1001' }
$mapped | Should -Be 'D:(A;;FA;;;S-1-5-21-9-8-7-1001)(A;;FA;;;S-1-5-21-1-2-3-10012)' $mapped | Should -Be 'D:(A;;FA;;;S-1-5-21-9-8-7-1001)(A;;FA;;;S-1-5-21-1-2-3-10012)'
} }
It '空映射表时原样返回' { It '空映射表时原样返回' {
$sddl = 'D:(A;;FA;;;SY)' $sddl = 'D:(A;;FA;;;SY)'
Convert-BaknretSidMap -Sddl $sddl -SidMap @{} | Should -Be $sddl Convert-BakNRetSidMap -Sddl $sddl -SidMap @{} | Should -Be $sddl
} }
} }
@@ -283,7 +283,7 @@ Describe '安全描述符采集' {
} }
It 'Full:每个对象一条记录,键是归档内相对路径' { It 'Full:每个对象一条记录,键是归档内相对路径' {
$capture = Get-BaknretSecurityRecords -Items @($script:CaptureItem) -Mode Full $capture = Get-BakNRetSecurityRecords -Items @($script:CaptureItem) -Mode Full
$capture.Scanned | Should -Be 3 $capture.Scanned | Should -Be 3
$capture.Kept | Should -Be 3 $capture.Kept | Should -Be 3
$capture.Errors | Should -Be 0 $capture.Errors | Should -Be 0
@@ -291,7 +291,7 @@ Describe '安全描述符采集' {
} }
It '根记录的 SDDL 保留了 CREATOR OWNER、IO 标志、孤儿 SID 和 protected 位' { It '根记录的 SDDL 保留了 CREATOR OWNER、IO 标志、孤儿 SID 和 protected 位' {
$capture = Get-BaknretSecurityRecords -Items @($script:CaptureItem) -Mode Full $capture = Get-BakNRetSecurityRecords -Items @($script:CaptureItem) -Mode Full
$root = @($capture.Records | Where-Object { $_.p -eq 'Data' })[0] $root = @($capture.Records | Where-Object { $_.p -eq 'Data' })[0]
$root.s | Should -Match 'D:PAI' $root.s | Should -Match 'D:PAI'
$root.s | Should -Match '\(A;OICIIO;GA;;;CO\)' $root.s | Should -Match '\(A;OICIIO;GA;;;CO\)'
@@ -300,23 +300,23 @@ Describe '安全描述符采集' {
} }
It 'Smart 比 Full 少,但根永远保留' { It 'Smart 比 Full 少,但根永远保留' {
$full = Get-BaknretSecurityRecords -Items @($script:CaptureItem) -Mode Full $full = Get-BakNRetSecurityRecords -Items @($script:CaptureItem) -Mode Full
$smart = Get-BaknretSecurityRecords -Items @($script:CaptureItem) -Mode Smart $smart = Get-BakNRetSecurityRecords -Items @($script:CaptureItem) -Mode Smart
$smart.Kept | Should -BeLessOrEqual $full.Kept $smart.Kept | Should -BeLessOrEqual $full.Kept
@($smart.Records | ForEach-Object { $_.p }) | Should -Contain 'Data' @($smart.Records | ForEach-Object { $_.p }) | Should -Contain 'Data'
} }
It 'Roots 只存归档项的根,不再往下走' { It 'Roots 只存归档项的根,不再往下走' {
$roots = Get-BaknretSecurityRecords -Items @($script:CaptureItem) -Mode Roots $roots = Get-BakNRetSecurityRecords -Items @($script:CaptureItem) -Mode Roots
$roots.Kept | Should -Be 1 $roots.Kept | Should -Be 1
$roots.Records[0].p | Should -Be 'Data' $roots.Records[0].p | Should -Be 'Data'
} }
It 'sidecar 往返:条数与 SDDL 原样保留' { It 'sidecar 往返:条数与 SDDL 原样保留' {
$capture = Get-BaknretSecurityRecords -Items @($script:CaptureItem) -Mode Full $capture = Get-BakNRetSecurityRecords -Items @($script:CaptureItem) -Mode Full
$path = Join-Path $script:Sandbox 'roundtrip.acl.json' $path = Join-Path $script:Sandbox 'roundtrip.acl.json'
Save-BaknretSecuritySidecar -Path $path -Records $capture.Records -Mode Full -Errors $capture.Errors -Scanned $capture.Scanned | Out-Null Save-BakNRetSecuritySidecar -Path $path -Records $capture.Records -Mode Full -Errors $capture.Errors -Scanned $capture.Scanned | Out-Null
$sidecar = Read-BaknretSecuritySidecar -Path $path $sidecar = Read-BakNRetSecuritySidecar -Path $path
$sidecar.Records.Count | Should -Be 3 $sidecar.Records.Count | Should -Be 3
$record = @($sidecar.Records | Where-Object { $_.p -eq 'Data\sub\a.txt' })[0] $record = @($sidecar.Records | Where-Object { $_.p -eq 'Data\sub\a.txt' })[0]
$record.k | Should -Be 'f' $record.k | Should -Be 'f'
@@ -324,7 +324,7 @@ Describe '安全描述符采集' {
} }
It '旁挂文件不存在时读出 $null(调用方据此打告警,而不是静默当没事)' { It '旁挂文件不存在时读出 $null(调用方据此打告警,而不是静默当没事)' {
Read-BaknretSecuritySidecar -Path (Join-Path $script:Sandbox 'nope.acl.json') | Should -BeNullOrEmpty Read-BakNRetSecuritySidecar -Path (Join-Path $script:Sandbox 'nope.acl.json') | Should -BeNullOrEmpty
} }
It '排除模式在采集时同样生效(采集树 == 归档树)' { It '排除模式在采集时同样生效(采集树 == 归档树)' {
@@ -336,7 +336,7 @@ Describe '安全描述符采集' {
[System.IO.File]::WriteAllText((Join-Path $walkRoot 'keep.txt'), 'x') [System.IO.File]::WriteAllText((Join-Path $walkRoot 'keep.txt'), 'x')
$walkItem = [pscustomobject]@{ ArchivePath = 'Data'; RealPath = $walkRoot } $walkItem = [pscustomobject]@{ ArchivePath = 'Data'; RealPath = $walkRoot }
$capture = Get-BaknretSecurityRecords -Items @($walkItem) -Mode Full -ScopeMap @{ 0 = @('!Cache') } $capture = Get-BakNRetSecurityRecords -Items @($walkItem) -Mode Full -ScopeMap @{ 0 = @('!Cache') }
@($capture.Records | ForEach-Object { $_.p }) | Should -Not -Contain 'Data\Cache' @($capture.Records | ForEach-Object { $_.p }) | Should -Not -Contain 'Data\Cache'
@($capture.Records | ForEach-Object { $_.p }) | Should -Contain 'Data\keep.txt' @($capture.Records | ForEach-Object { $_.p }) | Should -Contain 'Data\keep.txt'
} }
@@ -350,17 +350,17 @@ Describe '安全描述符回放' {
$script:TargetRoot = Join-Path $script:Sandbox 'restore\target' $script:TargetRoot = Join-Path $script:Sandbox 'restore\target'
$script:RestoreFingerprints = New-AclSourceTree -Root $script:RestoreRoot $script:RestoreFingerprints = New-AclSourceTree -Root $script:RestoreRoot
$capture = Get-BaknretSecurityRecords -Items @([pscustomobject]@{ ArchivePath = 'Data'; RealPath = $script:RestoreRoot }) -Mode Full $capture = Get-BakNRetSecurityRecords -Items @([pscustomobject]@{ ArchivePath = 'Data'; RealPath = $script:RestoreRoot }) -Mode Full
$script:RestoreSidecarPath = Join-Path $script:Sandbox 'restore.acl.json' $script:RestoreSidecarPath = Join-Path $script:Sandbox 'restore.acl.json'
Save-BaknretSecuritySidecar -Path $script:RestoreSidecarPath -Records $capture.Records -Mode Full -Errors $capture.Errors -Scanned $capture.Scanned | Out-Null Save-BakNRetSecuritySidecar -Path $script:RestoreSidecarPath -Records $capture.Records -Mode Full -Errors $capture.Errors -Scanned $capture.Scanned | Out-Null
$script:RestoreSidecar = Read-BaknretSecuritySidecar -Path $script:RestoreSidecarPath $script:RestoreSidecar = Read-BakNRetSecuritySidecar -Path $script:RestoreSidecarPath
} }
It '回放后根对象的安全描述符与源逐字节一致(protected / CO / 孤儿 SID 全在)' { It '回放后根对象的安全描述符与源逐字节一致(protected / CO / 孤儿 SID 全在)' {
# 干净目标:只拷内容,不带 ACL(ACL 是新建对象的默认值) # 干净目标:只拷内容,不带 ACL(ACL 是新建对象的默认值)
& robocopy.exe $script:RestoreRoot $script:TargetRoot /E /COPY:DAT /NFL /NDL /NJH /NJS /NP | Out-Null & robocopy.exe $script:RestoreRoot $script:TargetRoot /E /COPY:DAT /NFL /NDL /NJH /NJS /NP | Out-Null
$result = Restore-BaknretSecurity -Sidecar $script:RestoreSidecar -ArchiveRoot 'Data' -TargetPath $script:TargetRoot $result = Restore-BakNRetSecurity -Sidecar $script:RestoreSidecar -ArchiveRoot 'Data' -TargetPath $script:TargetRoot
$result.Total | Should -Be 3 $result.Total | Should -Be 3
$result.Failed | Should -Be 0 $result.Failed | Should -Be 0
$result.Applied | Should -Be 3 $result.Applied | Should -Be 3
@@ -374,7 +374,7 @@ Describe '安全描述符回放' {
$targetPath = if ($relative -eq '.') { $script:TargetRoot } else { Join-Path $script:TargetRoot $relative } $targetPath = if ($relative -eq '.') { $script:TargetRoot } else { Join-Path $script:TargetRoot $relative }
# 唯一允许的差异:陈旧继承 ACE 被"冻结"成显式 + protected 的对象, # 唯一允许的差异:陈旧继承 ACE 被"冻结"成显式 + protected 的对象,
# protected 位会从 False 变 True(见 Get-BaknretSecuritySddlWithStale)。 # protected 位会从 False 变 True(见 Get-BakNRetSecuritySddlWithStale)。
$expected = $script:RestoreFingerprints[$relative] -replace ' P=(True|False) ', ' P=' $expected = $script:RestoreFingerprints[$relative] -replace ' P=(True|False) ', ' P='
$actual = (Get-AclFingerprint -Path $targetPath) -replace ' P=(True|False) ', ' P=' $actual = (Get-AclFingerprint -Path $targetPath) -replace ' P=(True|False) ', ' P='
$actual | Should -Be $expected -Because "$relative 的 ACE 集合应当与源一致" $actual | Should -Be $expected -Because "$relative 的 ACE 集合应当与源一致"
@@ -382,7 +382,7 @@ Describe '安全描述符回放' {
} }
It '目标不存在或不是普通对象时记 Skipped,不记 Failed' { It '目标不存在或不是普通对象时记 Skipped,不记 Failed' {
$result = Restore-BaknretSecurity -Sidecar $script:RestoreSidecar -ArchiveRoot 'Data' ` $result = Restore-BakNRetSecurity -Sidecar $script:RestoreSidecar -ArchiveRoot 'Data' `
-TargetPath (Join-Path $script:Sandbox 'restore\does-not-exist') -TargetPath (Join-Path $script:Sandbox 'restore\does-not-exist')
$result.Total | Should -Be 3 $result.Total | Should -Be 3
$result.Skipped | Should -Be 3 $result.Skipped | Should -Be 3
@@ -390,7 +390,7 @@ Describe '安全描述符回放' {
} }
It '归档根名对不上时一条都不回放(不会把兄弟项的 ACL 倒过来)' { It '归档根名对不上时一条都不回放(不会把兄弟项的 ACL 倒过来)' {
$result = Restore-BaknretSecurity -Sidecar $script:RestoreSidecar -ArchiveRoot 'Other' -TargetPath $script:TargetRoot $result = Restore-BakNRetSecurity -Sidecar $script:RestoreSidecar -ArchiveRoot 'Other' -TargetPath $script:TargetRoot
$result.Total | Should -Be 0 $result.Total | Should -Be 0
$result.Applied | Should -Be 0 $result.Applied | Should -Be 0
} }
@@ -406,7 +406,7 @@ Describe '安全描述符回放' {
Records = @([pscustomobject]@{ p = 'Data'; k = 'd'; s = $sddl }) Records = @([pscustomobject]@{ p = 'Data'; k = 'd'; s = $sddl })
} }
$result = Restore-BaknretSecurity -Sidecar $sidecar -ArchiveRoot 'Data' -TargetPath $path $result = Restore-BakNRetSecurity -Sidecar $sidecar -ArchiveRoot 'Data' -TargetPath $path
$result.Failed | Should -Be 0 $result.Failed | Should -Be 0
($result.Applied + $result.OwnerFailed) | Should -Be 1 ($result.Applied + $result.OwnerFailed) | Should -Be 1
(Get-Acl -LiteralPath $path).Sddl | Should -Match '\(A;;FA;;;SY\)' (Get-Acl -LiteralPath $path).Sddl | Should -Match '\(A;;FA;;;SY\)'
@@ -416,7 +416,7 @@ Describe '安全描述符回放' {
$record = [pscustomobject]@{ p = 'Data'; k = 'd'; s = $null; e = '读不到' } $record = [pscustomobject]@{ p = 'Data'; k = 'd'; s = $null; e = '读不到' }
$sidecar = [pscustomobject]@{ Records = @($record) } $sidecar = [pscustomobject]@{ Records = @($record) }
$path = Join-Path $script:Sandbox 'restore\bogus-group' $path = Join-Path $script:Sandbox 'restore\bogus-group'
$result = Restore-BaknretSecurity -Sidecar $sidecar -ArchiveRoot 'Data' -TargetPath $path $result = Restore-BakNRetSecurity -Sidecar $sidecar -ArchiveRoot 'Data' -TargetPath $path
$result.Skipped | Should -Be 1 $result.Skipped | Should -Be 1
$result.Applied | Should -Be 0 $result.Applied | Should -Be 0
$result.Failed | Should -Be 0 $result.Failed | Should -Be 0
@@ -432,7 +432,7 @@ Describe '与 Backup.ps1 / Restore.ps1 的集成' -Skip:(-not $script:HasSevenZi
} }
It '备份会写出 <归档名>.acl.json,并在 manifest 里记下它' { It '备份会写出 <归档名>.acl.json,并在 manifest 里记下它' {
$result = Invoke-BaknretScript -Script $script:BackupScript -Parameters @{ $result = Invoke-BakNRetScript -Script $script:BackupScript -Parameters @{
BackupListPath = $script:Harness.ListPath BackupListPath = $script:Harness.ListPath
ConfigPath = $script:Harness.ConfigPath ConfigPath = $script:Harness.ConfigPath
BackupDir = $script:Harness.BackupDir BackupDir = $script:Harness.BackupDir
@@ -454,7 +454,7 @@ Describe '与 Backup.ps1 / Restore.ps1 的集成' -Skip:(-not $script:HasSevenZi
Reset-AclTree -Path $script:Harness.SourcePath Reset-AclTree -Path $script:Harness.SourcePath
(Test-Path -LiteralPath $script:Harness.SourcePath) | Should -BeFalse (Test-Path -LiteralPath $script:Harness.SourcePath) | Should -BeFalse
$result = Invoke-BaknretScript -Script $script:RestoreScript -Parameters @{ $result = Invoke-BakNRetScript -Script $script:RestoreScript -Parameters @{
BackupListPath = $script:Harness.ListPath BackupListPath = $script:Harness.ListPath
ConfigPath = $script:Harness.ConfigPath ConfigPath = $script:Harness.ConfigPath
BackupDir = $script:Harness.BackupDir BackupDir = $script:Harness.BackupDir
@@ -476,7 +476,7 @@ Describe '与 Backup.ps1 / Restore.ps1 的集成' -Skip:(-not $script:HasSevenZi
It '-SkipSecurity 时不回放(目标保持新建对象的默认 ACL)' { It '-SkipSecurity 时不回放(目标保持新建对象的默认 ACL)' {
Reset-AclTree -Path $script:Harness.SourcePath Reset-AclTree -Path $script:Harness.SourcePath
$result = Invoke-BaknretScript -Script $script:RestoreScript -Parameters @{ $result = Invoke-BakNRetScript -Script $script:RestoreScript -Parameters @{
BackupListPath = $script:Harness.ListPath BackupListPath = $script:Harness.ListPath
ConfigPath = $script:Harness.ConfigPath ConfigPath = $script:Harness.ConfigPath
BackupDir = $script:Harness.BackupDir BackupDir = $script:Harness.BackupDir
@@ -491,7 +491,7 @@ Describe '与 Backup.ps1 / Restore.ps1 的集成' -Skip:(-not $script:HasSevenZi
Reset-AclTree -Path $script:Harness.SourcePath Reset-AclTree -Path $script:Harness.SourcePath
Get-ChildItem -LiteralPath $script:Harness.BackupDir -Filter '*.acl.json' | Remove-Item -Force Get-ChildItem -LiteralPath $script:Harness.BackupDir -Filter '*.acl.json' | Remove-Item -Force
$result = Invoke-BaknretScript -Script $script:RestoreScript -Parameters @{ $result = Invoke-BakNRetScript -Script $script:RestoreScript -Parameters @{
BackupListPath = $script:Harness.ListPath BackupListPath = $script:Harness.ListPath
ConfigPath = $script:Harness.ConfigPath ConfigPath = $script:Harness.ConfigPath
BackupDir = $script:Harness.BackupDir BackupDir = $script:Harness.BackupDir
+173 -173
View File
@@ -54,7 +54,7 @@ BeforeAll {
# 于是这里改成:把整条命令写进临时 .cmd,由 cmd 自己把输出重定向到文件, # 于是这里改成:把整条命令写进临时 .cmd,由 cmd 自己把输出重定向到文件,
# 再用 Invoke-ExternalCommand 调 `cmd /c <批处理>`。退出码来自真实进程, # 再用 Invoke-ExternalCommand 调 `cmd /c <批处理>`。退出码来自真实进程,
# 输出从文件读回,全程不经过 PowerShell 的管道。 # 输出从文件读回,全程不经过 PowerShell 的管道。
function Invoke-BaknretScript { function Invoke-BakNRetScript {
param( param(
[Parameter(Mandatory = $true)][string]$Script, [Parameter(Mandatory = $true)][string]$Script,
[hashtable]$Parameters = @{} [hashtable]$Parameters = @{}
@@ -337,29 +337,29 @@ Describe 'BackupList.txt 解析' {
} }
It '记号切分:引号内的空白不切分,引号本身留在记号里' { It '记号切分:引号内的空白不切分,引号本身留在记号里' {
(@(Split-BaknretToken -Text 'A "B C" D') -join '|') | Should -Be 'A|"B C"|D' (@(Split-BakNRetToken -Text 'A "B C" D') -join '|') | Should -Be 'A|"B C"|D'
# 引号不配对时按"引号延伸到行尾"处理,不抛异常 # 引号不配对时按"引号延伸到行尾"处理,不抛异常
(@(Split-BaknretToken -Text 'A "B C') -join '|') | Should -Be 'A|"B C' (@(Split-BakNRetToken -Text 'A "B C') -join '|') | Should -Be 'A|"B C'
(@(Split-BaknretToken -Text '') -join '|') | Should -Be '' (@(Split-BakNRetToken -Text '') -join '|') | Should -Be ''
} }
It '记号识别只认完整记号' { It '记号识别只认完整记号' {
(Test-BaknretMarker -Token '::') | Should -Be 'path' (Test-BakNRetMarker -Token '::') | Should -Be 'path'
(Test-BaknretMarker -Token ':-') | Should -Be 'exclude' (Test-BakNRetMarker -Token ':-') | Should -Be 'exclude'
(Test-BaknretMarker -Token ':+') | Should -Be 'include' (Test-BakNRetMarker -Token ':+') | Should -Be 'include'
(Test-BaknretMarker -Token ':encrypt') | Should -Be 'encrypt' (Test-BakNRetMarker -Token ':encrypt') | Should -Be 'encrypt'
(Test-BaknretMarker -Token ':!encrypt') | Should -Be 'noencrypt' (Test-BakNRetMarker -Token ':!encrypt') | Should -Be 'noencrypt'
(Test-BaknretMarker -Token '@') | Should -Be 'at' (Test-BakNRetMarker -Token '@') | Should -Be 'at'
(Test-BaknretMarker -Token 'C:\a:-b') | Should -BeNullOrEmpty (Test-BakNRetMarker -Token 'C:\a:-b') | Should -BeNullOrEmpty
(Test-BaknretMarker -Token ':') | Should -BeNullOrEmpty (Test-BakNRetMarker -Token ':') | Should -BeNullOrEmpty
} }
It '去引号:成对才去,不成对原样返回' { It '去引号:成对才去,不成对原样返回' {
(Remove-BaknretQuote -Text '"a"') | Should -Be 'a' (Remove-BakNRetQuote -Text '"a"') | Should -Be 'a'
(Remove-BaknretQuote -Text "'a'") | Should -Be 'a' (Remove-BakNRetQuote -Text "'a'") | Should -Be 'a'
(Remove-BaknretQuote -Text 'a') | Should -Be 'a' (Remove-BakNRetQuote -Text 'a') | Should -Be 'a'
(Remove-BaknretQuote -Text '"a') | Should -Be '"a' (Remove-BakNRetQuote -Text '"a') | Should -Be '"a'
(Remove-BaknretQuote -Text '""') | Should -Be '' (Remove-BakNRetQuote -Text '""') | Should -Be ''
} }
} }
@@ -382,49 +382,49 @@ Describe '归档命名' {
# 注意:Pester 的 It 名字里出现 $( ) 会被求值,所以标题里不要写子表达式。 # 注意:Pester 的 It 名字里出现 $( ) 会被求值,所以标题里不要写子表达式。
It '基础命名规则:末级名_from_上级路径用加号连接' { It '基础命名规则:末级名_from_上级路径用加号连接' {
Get-BackupBaseName -RawPath 'C:\Programs\FooClolor' | Should -Be 'FooClolor_from_C_+Programs' Get-BakNRetBackupBaseName -RawPath 'C:\Programs\FooClolor' | Should -Be 'FooClolor_from_C_+Programs'
} }
It '/ 与 \ 以及重复分隔符结果一致' { It '/ 与 \ 以及重复分隔符结果一致' {
$a = Get-BackupBaseName -RawPath 'C:\a/b' $a = Get-BakNRetBackupBaseName -RawPath 'C:\a/b'
$b = Get-BackupBaseName -RawPath 'C:\\a\\\\b' $b = Get-BakNRetBackupBaseName -RawPath 'C:\\a\\\\b'
$a | Should -Be $b $a | Should -Be $b
} }
It '命名与路径往返' { It '命名与路径往返' {
$base = Get-BackupBaseName -RawPath 'D:\UserData\Documents\Aria' $base = Get-BakNRetBackupBaseName -RawPath 'D:\UserData\Documents\Aria'
Convert-BackupFileNameToPath -FileName ($base + '.7z') | Should -Be 'D:\UserData\Documents\Aria' Convert-BakNRetBackupFileNameToPath -FileName ($base + '.7z') | Should -Be 'D:\UserData\Documents\Aria'
} }
It '归档名里不含非法文件名字符' { It '归档名里不含非法文件名字符' {
$base = Get-BackupBaseName -RawPath 'C:\a<b>c|d?e*f' $base = Get-BakNRetBackupBaseName -RawPath 'C:\a<b>c|d?e*f'
($base.IndexOfAny([System.IO.Path]::GetInvalidFileNameChars())) | Should -Be -1 ($base.IndexOfAny([System.IO.Path]::GetInvalidFileNameChars())) | Should -Be -1
} }
It '%变量% 写法里的 % 会保留在归档名里' { It '%变量% 写法里的 % 会保留在归档名里' {
Get-BackupBaseName -RawPath '%UserProfile%\.ssh' | Should -Be '.ssh_from_%UserProfile%' Get-BakNRetBackupBaseName -RawPath '%UserProfile%\.ssh' | Should -Be '.ssh_from_%UserProfile%'
} }
It '软件名条目:默认用软件名做归档名' { It '软件名条目:默认用软件名做归档名' {
$entry = ConvertFrom-BackupListLine -Line 'my-app' $entry = ConvertFrom-BackupListLine -Line 'my-app'
(Get-ItemArchiveName -Entry $entry -CatalogPath $script:NamingCatalog -MaxDepth 3) | Should -Be 'my-app' (Get-BakNRetItemArchiveName -Entry $entry -CatalogPath $script:NamingCatalog -MaxDepth 3) | Should -Be 'my-app'
} }
It '字面路径条目:仍用路径命名算法(现有清单无需改写)' { It '字面路径条目:仍用路径命名算法(现有清单无需改写)' {
$entry = ConvertFrom-BackupListLine -Line 'C:\Programs\FooClolor' $entry = ConvertFrom-BackupListLine -Line 'C:\Programs\FooClolor'
(Get-ItemArchiveName -Entry $entry -CatalogPath $script:NamingCatalog -MaxDepth 3) | Should -Be 'FooClolor_from_C_+Programs' (Get-BakNRetItemArchiveName -Entry $entry -CatalogPath $script:NamingCatalog -MaxDepth 3) | Should -Be 'FooClolor_from_C_+Programs'
} }
It '@pathname 用名录里的真实路径命名,而不是软件名' { It '@pathname 用名录里的真实路径命名,而不是软件名' {
$entry = ConvertFrom-BackupListLine -Line 'my-app @pathname' $entry = ConvertFrom-BackupListLine -Line 'my-app @pathname'
$expected = Get-BackupBaseName -RawPath $script:NamingApp $expected = Get-BakNRetBackupBaseName -RawPath $script:NamingApp
(Get-ItemArchiveName -Entry $entry -CatalogPath $script:NamingCatalog -MaxDepth 3) | Should -Be $expected (Get-BakNRetItemArchiveName -Entry $entry -CatalogPath $script:NamingCatalog -MaxDepth 3) | Should -Be $expected
} }
It '名录里没有该名字:归档名退回可读目录名,并给出 Error' { It '名录里没有该名字:归档名退回可读目录名,并给出 Error' {
$entry = ConvertFrom-BackupListLine -Line 'no-such-thing' $entry = ConvertFrom-BackupListLine -Line 'no-such-thing'
(Get-ItemArchiveName -Entry $entry -CatalogPath $script:NamingCatalog -MaxDepth 3) | Should -Be 'no-such-thing' (Get-BakNRetItemArchiveName -Entry $entry -CatalogPath $script:NamingCatalog -MaxDepth 3) | Should -Be 'no-such-thing'
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:NamingCatalog -MaxDepth 3 $resolved = Resolve-BakNRetBackupEntry -Entry $entry -CatalogPath $script:NamingCatalog -MaxDepth 3
$resolved.Error | Should -Not -BeNullOrEmpty $resolved.Error | Should -Not -BeNullOrEmpty
@($resolved.Items).Count | Should -Be 0 @($resolved.Items).Count | Should -Be 0
} }
@@ -443,142 +443,142 @@ Describe '7z 排除参数翻译' {
function New-TestArchiveItem { function New-TestArchiveItem {
param([string]$ArchivePath, [string]$RealPath = 'C:\nonexistent-path') param([string]$ArchivePath, [string]$RealPath = 'C:\nonexistent-path')
New-BaknretArchiveItem -ArchivePath $ArchivePath -RealPath $RealPath -Kind 'path' -Origin 'path' -Exists $false -IsFile $false New-BakNRetArchiveItem -ArchivePath $ArchivePath -RealPath $RealPath -Kind 'path' -Origin 'path' -Exists $false -IsFile $false
} }
} }
It '相对模式自动补上归档根目录名' { It '相对模式自动补上归档根目录名' {
$result = Get-BaknretExcludeArgument -Item (New-TestArchiveItem -ArchivePath 'Foo') -Patterns @('logs\') $result = Get-BakNRetExcludeArgument -Item (New-TestArchiveItem -ArchivePath 'Foo') -Patterns @('logs\')
@($result.Arguments) | Should -Contain '-x!Foo\logs' @($result.Arguments) | Should -Contain '-x!Foo\logs'
$result.Error | Should -BeNullOrEmpty $result.Error | Should -BeNullOrEmpty
} }
It '模式已带根名前缀时,Split-BaknretPatternScope 先摘掉前缀,结果不重复' { It '模式已带根名前缀时,Split-BakNRetPatternScope 先摘掉前缀,结果不重复' {
$item = New-TestArchiveItem -ArchivePath 'Foo' $item = New-TestArchiveItem -ArchivePath 'Foo'
$map = Split-BaknretPatternScope -Items @($item) -Patterns @('Foo\logs\') $map = Split-BakNRetPatternScope -Items @($item) -Patterns @('Foo\logs\')
(@($map[0]) -join '|') | Should -Be 'logs' (@($map[0]) -join '|') | Should -Be 'logs'
$result = Get-BaknretExcludeArgument -Item $item -Patterns @($map[0]) $result = Get-BakNRetExcludeArgument -Item $item -Patterns @($map[0])
@($result.Arguments) | Should -Contain '-x!Foo\logs' @($result.Arguments) | Should -Contain '-x!Foo\logs'
@($result.Arguments) | Should -Not -Contain '-x!Foo\Foo\logs' @($result.Arguments) | Should -Not -Contain '-x!Foo\Foo\logs'
} }
It '! 前缀翻译成递归组件匹配(-xr!)' { It '! 前缀翻译成递归组件匹配(-xr!)' {
$result = Get-BaknretExcludeArgument -Item (New-TestArchiveItem -ArchivePath 'User Data') -Patterns @('!*Cache') $result = Get-BakNRetExcludeArgument -Item (New-TestArchiveItem -ArchivePath 'User Data') -Patterns @('!*Cache')
@($result.Arguments) | Should -Contain '-xr!*Cache' @($result.Arguments) | Should -Contain '-xr!*Cache'
} }
It '模式里的空格转成 ?(归档项自己的名字按原样保留)' { It '模式里的空格转成 ?(归档项自己的名字按原样保留)' {
$result = Get-BaknretExcludeArgument -Item (New-TestArchiveItem -ArchivePath 'User Data') -Patterns @('Default\Code Cache') $result = Get-BakNRetExcludeArgument -Item (New-TestArchiveItem -ArchivePath 'User Data') -Patterns @('Default\Code Cache')
@($result.Arguments) | Should -Contain '-x!User Data\Default\Code?Cache' @($result.Arguments) | Should -Contain '-x!User Data\Default\Code?Cache'
$plain = Get-BaknretExcludeArgument -Item (New-TestArchiveItem -ArchivePath 'Foo') -Patterns @('Default\Code Cache') $plain = Get-BakNRetExcludeArgument -Item (New-TestArchiveItem -ArchivePath 'Foo') -Patterns @('Default\Code Cache')
@($plain.Arguments) | Should -Contain '-x!Foo\Default\Code?Cache' @($plain.Arguments) | Should -Contain '-x!Foo\Default\Code?Cache'
} }
It '生成的参数里绝不出现引号(旧实现 -x!"路径" 让排除全部失效)' { It '生成的参数里绝不出现引号(旧实现 -x!"路径" 让排除全部失效)' {
$result = Get-BaknretExcludeArgument -Item (New-TestArchiveItem -ArchivePath 'User Data') -Patterns @('!*Cache', 'Default\Code Cache') $result = Get-BakNRetExcludeArgument -Item (New-TestArchiveItem -ArchivePath 'User Data') -Patterns @('!*Cache', 'Default\Code Cache')
(@($result.Arguments) -join ' ') | Should -Not -Match '"' (@($result.Arguments) -join ' ') | Should -Not -Match '"'
} }
It '空模式被忽略' { It '空模式被忽略' {
$result = Get-BaknretExcludeArgument -Item (New-TestArchiveItem -ArchivePath 'Foo') -Patterns @('', ' ', '!') $result = Get-BakNRetExcludeArgument -Item (New-TestArchiveItem -ArchivePath 'Foo') -Patterns @('', ' ', '!')
@($result.Arguments).Count | Should -Be 0 @($result.Arguments).Count | Should -Be 0
$result.Error | Should -BeNullOrEmpty $result.Error | Should -BeNullOrEmpty
} }
It '!re: 会把正则展开成精确的 -x! 参数' { It '!re: 会把正则展开成精确的 -x! 参数' {
$item = New-TestArchiveItem -ArchivePath 'Top' -RealPath $script:ExcludeTree $item = New-TestArchiveItem -ArchivePath 'Top' -RealPath $script:ExcludeTree
$result = Get-BaknretExcludeArgument -Item $item -Patterns @('!re:^Cache$') $result = Get-BakNRetExcludeArgument -Item $item -Patterns @('!re:^Cache$')
@($result.Arguments) | Should -Contain '-x!Top\sub\Cache' @($result.Arguments) | Should -Contain '-x!Top\sub\Cache'
# 命中目录后不再往下走(一个命中只产生一条参数,避免命令行爆炸) # 命中目录后不再往下走(一个命中只产生一条参数,避免命令行爆炸)
$pruned = Get-BaknretExcludeArgument -Item $item -Patterns @('!re:^sub$') $pruned = Get-BakNRetExcludeArgument -Item $item -Patterns @('!re:^sub$')
@($pruned.Arguments) | Should -Contain '-x!Top\sub' @($pruned.Arguments) | Should -Contain '-x!Top\sub'
@($pruned.Arguments).Count | Should -Be 1 @($pruned.Arguments).Count | Should -Be 1
} }
It '非法正则给出 Error,而不是抛异常' { It '非法正则给出 Error,而不是抛异常' {
$result = Get-BaknretExcludeArgument -Item (New-TestArchiveItem -ArchivePath 'Top' -RealPath $script:ExcludeTree) -Patterns @('!re:[') $result = Get-BakNRetExcludeArgument -Item (New-TestArchiveItem -ArchivePath 'Top' -RealPath $script:ExcludeTree) -Patterns @('!re:[')
@($result.Arguments).Count | Should -Be 0 @($result.Arguments).Count | Should -Be 0
$result.Error | Should -Not -BeNullOrEmpty $result.Error | Should -Not -BeNullOrEmpty
} }
It '正则命中数超过上限时明确报错' { It '正则命中数超过上限时明确报错' {
$result = Get-BaknretExcludeArgument -Item (New-TestArchiveItem -ArchivePath 'Top' -RealPath $script:ExcludeTree) ` $result = Get-BakNRetExcludeArgument -Item (New-TestArchiveItem -ArchivePath 'Top' -RealPath $script:ExcludeTree) `
-Patterns @('!re:.') -MaxRegexMatches 1 -Patterns @('!re:.') -MaxRegexMatches 1
$result.Error | Should -Not -BeNullOrEmpty $result.Error | Should -Not -BeNullOrEmpty
} }
It '参数总长超过安全上限时明确报错' { It '参数总长超过安全上限时明确报错' {
$result = Get-BaknretExcludeArgument -Item (New-TestArchiveItem -ArchivePath 'Top') ` $result = Get-BakNRetExcludeArgument -Item (New-TestArchiveItem -ArchivePath 'Top') `
-Patterns @('!*zzz') -MaxCommandLineChars 5 -Patterns @('!*zzz') -MaxCommandLineChars 5
$result.Error | Should -Not -BeNullOrEmpty $result.Error | Should -Not -BeNullOrEmpty
} }
It 'Split-BaknretPatternScope:指名 Slot 的模式只分给那个 Slot' { It 'Split-BakNRetPatternScope:指名 Slot 的模式只分给那个 Slot' {
$items = @( $items = @(
New-TestArchiveItem -ArchivePath 'Alpha' New-TestArchiveItem -ArchivePath 'Alpha'
New-TestArchiveItem -ArchivePath 'Beta' New-TestArchiveItem -ArchivePath 'Beta'
) )
$map = Split-BaknretPatternScope -Items $items -Patterns @('Alpha\logs\') $map = Split-BakNRetPatternScope -Items $items -Patterns @('Alpha\logs\')
(@($map[0]) -join '|') | Should -Be 'logs' (@($map[0]) -join '|') | Should -Be 'logs'
@($map[1]).Count | Should -Be 0 @($map[1]).Count | Should -Be 0
# 用 / 分隔也一样 # 用 / 分隔也一样
$slash = Split-BaknretPatternScope -Items $items -Patterns @('Beta/sub') $slash = Split-BakNRetPatternScope -Items $items -Patterns @('Beta/sub')
(@($slash[1]) -join '|') | Should -Be 'sub' (@($slash[1]) -join '|') | Should -Be 'sub'
@($slash[0]).Count | Should -Be 0 @($slash[0]).Count | Should -Be 0
} }
It 'Split-BaknretPatternScope:没点名任何 Slot 的模式广播给每一项' { It 'Split-BakNRetPatternScope:没点名任何 Slot 的模式广播给每一项' {
$items = @( $items = @(
New-TestArchiveItem -ArchivePath 'Alpha' New-TestArchiveItem -ArchivePath 'Alpha'
New-TestArchiveItem -ArchivePath 'Beta' New-TestArchiveItem -ArchivePath 'Beta'
) )
$map = Split-BaknretPatternScope -Items $items -Patterns @('plain\path', 'Alpha') $map = Split-BakNRetPatternScope -Items $items -Patterns @('plain\path', 'Alpha')
(@($map[0]) -join '|') | Should -Be 'plain\path|Alpha' (@($map[0]) -join '|') | Should -Be 'plain\path|Alpha'
(@($map[1]) -join '|') | Should -Be 'plain\path|Alpha' (@($map[1]) -join '|') | Should -Be 'plain\path|Alpha'
} }
It 'Split-BaknretPatternScope:! 与 !re: 模式广播给每一项' { It 'Split-BakNRetPatternScope:! 与 !re: 模式广播给每一项' {
$items = @( $items = @(
New-TestArchiveItem -ArchivePath 'Alpha' New-TestArchiveItem -ArchivePath 'Alpha'
New-TestArchiveItem -ArchivePath 'Beta' New-TestArchiveItem -ArchivePath 'Beta'
) )
$map = Split-BaknretPatternScope -Items $items -Patterns @('!*Cache', '!re:^x$') $map = Split-BakNRetPatternScope -Items $items -Patterns @('!*Cache', '!re:^x$')
(@($map[0]) -join '|') | Should -Be '!*Cache|!re:^x$' (@($map[0]) -join '|') | Should -Be '!*Cache|!re:^x$'
(@($map[1]) -join '|') | Should -Be '!*Cache|!re:^x$' (@($map[1]) -join '|') | Should -Be '!*Cache|!re:^x$'
} }
It 'Split-BaknretPatternScope:每个归档项都有键(没有模式时是空数组)' { It 'Split-BakNRetPatternScope:每个归档项都有键(没有模式时是空数组)' {
$items = @( $items = @(
New-TestArchiveItem -ArchivePath 'Alpha' New-TestArchiveItem -ArchivePath 'Alpha'
New-TestArchiveItem -ArchivePath 'Beta' New-TestArchiveItem -ArchivePath 'Beta'
) )
$map = Split-BaknretPatternScope -Items $items -Patterns @() $map = Split-BakNRetPatternScope -Items $items -Patterns @()
$map.ContainsKey(0) | Should -BeTrue $map.ContainsKey(0) | Should -BeTrue
$map.ContainsKey(1) | Should -BeTrue $map.ContainsKey(1) | Should -BeTrue
@($map[0]).Count | Should -Be 0 @($map[0]).Count | Should -Be 0
} }
It 'Merge-BaknretExcludeArgument 去重且保持顺序' { It 'Merge-BakNRetExcludeArgument 去重且保持顺序' {
$merged = Merge-BaknretExcludeArgument -ArgumentLists @(@('-x!a', '-x!b'), @('-x!b', '-x!c')) $merged = Merge-BakNRetExcludeArgument -ArgumentLists @(@('-x!a', '-x!b'), @('-x!b', '-x!c'))
(@($merged) -join ' ') | Should -Be '-x!a -x!b -x!c' (@($merged) -join ' ') | Should -Be '-x!a -x!b -x!c'
} }
It '多 Slot 条目:逐项分配 + 翻译 + 去重合成一份参数' { It '多 Slot 条目:逐项分配 + 翻译 + 去重合成一份参数' {
$items = @( $items = @(
New-BaknretArchiveItem -ArchivePath 'Alpha' -RealPath $script:ExcludeTree -Kind 'slot' -Slot 'Alpha' -Origin 'catalog' -Exists $true New-BakNRetArchiveItem -ArchivePath 'Alpha' -RealPath $script:ExcludeTree -Kind 'slot' -Slot 'Alpha' -Origin 'catalog' -Exists $true
New-BaknretArchiveItem -ArchivePath 'Beta' -RealPath $script:ExcludeTree -Kind 'slot' -Slot 'Beta' -Origin 'catalog' -Exists $true New-BakNRetArchiveItem -ArchivePath 'Beta' -RealPath $script:ExcludeTree -Kind 'slot' -Slot 'Beta' -Origin 'catalog' -Exists $true
) )
$patterns = @('Alpha\logs\', '!*Cache', 'Beta\sub', '!*Cache') $patterns = @('Alpha\logs\', '!*Cache', 'Beta\sub', '!*Cache')
$map = Split-BaknretPatternScope -Items $items -Patterns $patterns $map = Split-BakNRetPatternScope -Items $items -Patterns $patterns
$lists = @() $lists = @()
for ($index = 0; $index -lt $items.Count; $index++) { for ($index = 0; $index -lt $items.Count; $index++) {
$lists += , @((Get-BaknretExcludeArgument -Item $items[$index] -Patterns @($map[$index])).Arguments) $lists += , @((Get-BakNRetExcludeArgument -Item $items[$index] -Patterns @($map[$index])).Arguments)
} }
$effective = @(Merge-BaknretExcludeArgument -ArgumentLists $lists) $effective = @(Merge-BakNRetExcludeArgument -ArgumentLists $lists)
$effective | Should -Contain '-x!Alpha\logs' $effective | Should -Contain '-x!Alpha\logs'
$effective | Should -Contain '-x!Beta\sub' $effective | Should -Contain '-x!Beta\sub'
$effective | Should -Contain '-xr!*Cache' $effective | Should -Contain '-xr!*Cache'
@@ -600,8 +600,8 @@ Describe '归档项与暂存目录' {
Set-Content -Encoding UTF8 -LiteralPath $script:StagingFile 'file-content' Set-Content -Encoding UTF8 -LiteralPath $script:StagingFile 'file-content'
} }
It 'New-BaknretArchiveItem 规范化归档内路径并算出 TopName' { It 'New-BakNRetArchiveItem 规范化归档内路径并算出 TopName' {
$item = New-BaknretArchiveItem -ArchivePath '\Alpha\Sub\' -RealPath $script:StagingDir -Kind 'slot' -Slot 'Alpha' $item = New-BakNRetArchiveItem -ArchivePath '\Alpha\Sub\' -RealPath $script:StagingDir -Kind 'slot' -Slot 'Alpha'
$item.ArchivePath | Should -Be 'Alpha\Sub' $item.ArchivePath | Should -Be 'Alpha\Sub'
$item.TopName | Should -Be 'Alpha' $item.TopName | Should -Be 'Alpha'
$item.Kind | Should -Be 'slot' $item.Kind | Should -Be 'slot'
@@ -610,10 +610,10 @@ Describe '归档项与暂存目录' {
@($item.Exclude).Count | Should -Be 0 @($item.Exclude).Count | Should -Be 0
} }
It 'Get-BaknretArchiveTopName 取归档内相对路径的第一段' { It 'Get-BakNRetArchiveTopName 取归档内相对路径的第一段' {
(Get-BaknretArchiveTopName -ArchivePath 'A\B\c.txt') | Should -Be 'A' (Get-BakNRetArchiveTopName -ArchivePath 'A\B\c.txt') | Should -Be 'A'
(Get-BaknretArchiveTopName -ArchivePath ' A ') | Should -Be 'A' (Get-BakNRetArchiveTopName -ArchivePath ' A ') | Should -Be 'A'
(Get-BaknretArchiveTopName -ArchivePath '') | Should -Be '' (Get-BakNRetArchiveTopName -ArchivePath '') | Should -Be ''
} }
It '暂存半途失败会自己清干净(不留指向真实数据的 junction)' { It '暂存半途失败会自己清干净(不留指向真实数据的 junction)' {
@@ -628,12 +628,12 @@ Describe '归档项与暂存目录' {
$items = @( $items = @(
# 第一项会成功挂上,而且是**目录走 junction** —— 这正是危险的那个物件:它指向 # 第一项会成功挂上,而且是**目录走 junction** —— 这正是危险的那个物件:它指向
# 真实数据,而临时目录迟早会被某次 Remove-Item -Recurse 扫到。 # 真实数据,而临时目录迟早会被某次 Remove-Item -Recurse 扫到。
New-BaknretArchiveItem -ArchivePath 'Good' -RealPath $realDir -Kind 'slot' -Slot 'Good' -Exists $true -IsFile $false New-BakNRetArchiveItem -ArchivePath 'Good' -RealPath $realDir -Kind 'slot' -Slot 'Good' -Exists $true -IsFile $false
# 第二项必然失败:源文件不存在,硬链接与复制都会失败 # 第二项必然失败:源文件不存在,硬链接与复制都会失败
New-BaknretArchiveItem -ArchivePath 'Missing.txt' -RealPath (Join-Path $base 'does-not-exist.txt') -Kind 'slot' -Slot 'Missing' -Exists $false -IsFile $true New-BakNRetArchiveItem -ArchivePath 'Missing.txt' -RealPath (Join-Path $base 'does-not-exist.txt') -Kind 'slot' -Slot 'Missing' -Exists $false -IsFile $true
) )
{ New-BaknretArchiveStaging -Items $items -Root (Join-Path $sandbox 'stage') } | Should -Throw { New-BakNRetArchiveStaging -Items $items -Root (Join-Path $sandbox 'stage') } | Should -Throw
$left = @(Get-ChildItem -LiteralPath $sandbox -Recurse -Force -ErrorAction SilentlyContinue) $left = @(Get-ChildItem -LiteralPath $sandbox -Recurse -Force -ErrorAction SilentlyContinue)
@($left | Where-Object { $_.LinkType -eq 'Junction' }).Count | Should -Be 0 @($left | Where-Object { $_.LinkType -eq 'Junction' }).Count | Should -Be 0
@@ -644,12 +644,12 @@ Describe '归档项与暂存目录' {
It '目录项用 junction 挂进暂存目录,文件项用硬链接(名字就是归档内路径)' { It '目录项用 junction 挂进暂存目录,文件项用硬链接(名字就是归档内路径)' {
$items = @( $items = @(
New-BaknretArchiveItem -ArchivePath 'Alpha' -RealPath $script:StagingDir -Kind 'slot' -Slot 'Alpha' -Exists $true -IsFile $false New-BakNRetArchiveItem -ArchivePath 'Alpha' -RealPath $script:StagingDir -Kind 'slot' -Slot 'Alpha' -Exists $true -IsFile $false
New-BaknretArchiveItem -ArchivePath 'Nested\Deep' -RealPath $script:StagingDir -Kind 'include' -Origin 'include' -Exists $true -IsFile $false New-BakNRetArchiveItem -ArchivePath 'Nested\Deep' -RealPath $script:StagingDir -Kind 'include' -Origin 'include' -Exists $true -IsFile $false
New-BaknretArchiveItem -ArchivePath 'Cfg' -RealPath $script:StagingFile -Kind 'slot' -Slot 'Cfg' -Exists $true -IsFile $true New-BakNRetArchiveItem -ArchivePath 'Cfg' -RealPath $script:StagingFile -Kind 'slot' -Slot 'Cfg' -Exists $true -IsFile $true
) )
$stage = New-BaknretArchiveStaging -Items $items $stage = New-BakNRetArchiveStaging -Items $items
try { try {
$alpha = Get-Item -LiteralPath (Join-Path $stage 'Alpha') -Force $alpha = Get-Item -LiteralPath (Join-Path $stage 'Alpha') -Force
$alpha.LinkType | Should -Be 'Junction' $alpha.LinkType | Should -Be 'Junction'
@@ -663,15 +663,15 @@ Describe '归档项与暂存目录' {
(Get-Content -Encoding UTF8 -LiteralPath $cfg.FullName -Raw).Trim() | Should -Be 'file-content' (Get-Content -Encoding UTF8 -LiteralPath $cfg.FullName -Raw).Trim() | Should -Be 'file-content'
} }
finally { finally {
Remove-BaknretArchiveStaging -Root $stage Remove-BakNRetArchiveStaging -Root $stage
} }
} }
It 'Remove-BaknretArchiveStaging 只删连接点,不顺着走进真实目录' { It 'Remove-BakNRetArchiveStaging 只删连接点,不顺着走进真实目录' {
$stage = New-BaknretArchiveStaging -Items @( $stage = New-BakNRetArchiveStaging -Items @(
New-BaknretArchiveItem -ArchivePath 'Alpha' -RealPath $script:StagingDir -Kind 'slot' -Slot 'Alpha' -Exists $true -IsFile $false New-BakNRetArchiveItem -ArchivePath 'Alpha' -RealPath $script:StagingDir -Kind 'slot' -Slot 'Alpha' -Exists $true -IsFile $false
) )
Remove-BaknretArchiveStaging -Root $stage Remove-BakNRetArchiveStaging -Root $stage
Test-Path -LiteralPath $stage | Should -BeFalse Test-Path -LiteralPath $stage | Should -BeFalse
Test-Path -LiteralPath (Join-Path $script:StagingDir 'inner\a.txt') | Should -BeTrue Test-Path -LiteralPath (Join-Path $script:StagingDir 'inner\a.txt') | Should -BeTrue
@@ -683,23 +683,23 @@ Describe '命令行参数拼接' {
# ============================================================================ # ============================================================================
It '无空格参数原样输出' { It '无空格参数原样输出' {
ConvertTo-NativeArgumentString -ArgumentList @('a', '-mx=9') | Should -Be 'a -mx=9' ConvertTo-BakNRetNativeArgumentString -ArgumentList @('a', '-mx=9') | Should -Be 'a -mx=9'
} }
It '含空格参数加引号' { It '含空格参数加引号' {
ConvertTo-NativeArgumentString -ArgumentList @('C:\a b\c') | Should -Be '"C:\a b\c"' ConvertTo-BakNRetNativeArgumentString -ArgumentList @('C:\a b\c') | Should -Be '"C:\a b\c"'
} }
It '引号内的结尾反斜杠翻倍(否则会被当成转义引号)' { It '引号内的结尾反斜杠翻倍(否则会被当成转义引号)' {
ConvertTo-NativeArgumentString -ArgumentList @('C:\a b\') | Should -Be '"C:\a b\\"' ConvertTo-BakNRetNativeArgumentString -ArgumentList @('C:\a b\') | Should -Be '"C:\a b\\"'
} }
It '内部引号被转义' { It '内部引号被转义' {
ConvertTo-NativeArgumentString -ArgumentList @('a"b c') | Should -Be '"a\"b c"' ConvertTo-BakNRetNativeArgumentString -ArgumentList @('a"b c') | Should -Be '"a\"b c"'
} }
It '空参数输出一对空引号' { It '空参数输出一对空引号' {
ConvertTo-NativeArgumentString -ArgumentList @('') | Should -Be '""' ConvertTo-BakNRetNativeArgumentString -ArgumentList @('') | Should -Be '""'
} }
} }
@@ -709,11 +709,11 @@ Describe 'manifest 与配置' {
It 'manifest 读写往返' { It 'manifest 读写往返' {
$path = Join-Path $script:Sandbox 'roundtrip\manifest.json' $path = Join-Path $script:Sandbox 'roundtrip\manifest.json'
$manifest = Read-BaknretManifest -Path $path $manifest = Read-BakNRetManifest -Path $path
$manifest.items['demo'] = [pscustomobject]@{ archive = 'demo.7z'; action = 'backed-up'; verified = $true } $manifest.items['demo'] = [pscustomobject]@{ archive = 'demo.7z'; action = 'backed-up'; verified = $true }
Write-BaknretManifest -Path $path -Manifest $manifest | Out-Null Write-BakNRetManifest -Path $path -Manifest $manifest | Out-Null
$again = Read-BaknretManifest -Path $path $again = Read-BakNRetManifest -Path $path
$again.items.Count | Should -Be 1 $again.items.Count | Should -Be 1
$again.items['demo'].archive | Should -Be 'demo.7z' $again.items['demo'].archive | Should -Be 'demo.7z'
$again.items['demo'].verified | Should -BeTrue $again.items['demo'].verified | Should -BeTrue
@@ -721,7 +721,7 @@ Describe 'manifest 与配置' {
It 'manifest 原样保存 layouts(name/kind),全新恢复时靠它判断目录还是文件' { It 'manifest 原样保存 layouts(name/kind),全新恢复时靠它判断目录还是文件' {
$path = Join-Path $script:Sandbox 'layouts\manifest.json' $path = Join-Path $script:Sandbox 'layouts\manifest.json'
$manifest = Read-BaknretManifest -Path $path $manifest = Read-BakNRetManifest -Path $path
$manifest.items['app'] = [pscustomobject]@{ $manifest.items['app'] = [pscustomobject]@{
archive = 'app.7z' archive = 'app.7z'
layouts = @( layouts = @(
@@ -729,28 +729,28 @@ Describe 'manifest 与配置' {
[pscustomobject]@{ name = 'Cfg'; kind = 'file' } [pscustomobject]@{ name = 'Cfg'; kind = 'file' }
) )
} }
Write-BaknretManifest -Path $path -Manifest $manifest | Out-Null Write-BakNRetManifest -Path $path -Manifest $manifest | Out-Null
$again = Read-BaknretManifest -Path $path $again = Read-BakNRetManifest -Path $path
@($again.items['app'].layouts).Count | Should -Be 2 @($again.items['app'].layouts).Count | Should -Be 2
(@($again.items['app'].layouts | ForEach-Object { $_.name + ':' + $_.kind }) -join '|') | Should -Be 'Data:dir|Cfg:file' (@($again.items['app'].layouts | ForEach-Object { $_.name + ':' + $_.kind }) -join '|') | Should -Be 'Data:dir|Cfg:file'
} }
It 'manifest 损坏时不抛异常,而是重建空清单' { It 'manifest 损坏时不抛异常,而是重建空清单' {
$path = Write-ListFile -Path (Join-Path $script:Sandbox 'broken.json') -Content '{ this is not json' $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 = Read-BakNRetManifest -Path $path } | Should -Not -Throw
$script:broken.items.Count | Should -Be 0 $script:broken.items.Count | Should -Be 0
} }
It '配置缺失时返回默认值' { It '配置缺失时返回默认值' {
$config = Get-BaknretConfig -Path (Join-Path $script:Sandbox 'no-such-config.psd1') $config = Get-BakNRetConfig -Path (Join-Path $script:Sandbox 'no-such-config.psd1')
$config.BackupDir | Should -Be 'Backups' $config.BackupDir | Should -Be 'Backups'
$config.VerifyArchive | Should -BeTrue $config.VerifyArchive | Should -BeTrue
} }
It '配置嵌套段落合并且不丢默认键' { It '配置嵌套段落合并且不丢默认键' {
$path = Write-ListFile -Path (Join-Path $script:Sandbox 'cfg.psd1') -Content "@{ MinFreeSpaceGB = 3; Encryption = @{ Enabled = `$true } }" $path = Write-ListFile -Path (Join-Path $script:Sandbox 'cfg.psd1') -Content "@{ MinFreeSpaceGB = 3; Encryption = @{ Enabled = `$true } }"
$config = Get-BaknretConfig -Path $path $config = Get-BakNRetConfig -Path $path
$config.MinFreeSpaceGB | Should -Be 3 $config.MinFreeSpaceGB | Should -Be 3
$config.Encryption.Enabled | Should -BeTrue $config.Encryption.Enabled | Should -BeTrue
# 没写到的子键要保留默认值。Encryption 是哈希表,用 ContainsKey 查。 # 没写到的子键要保留默认值。Encryption 是哈希表,用 ContainsKey 查。
@@ -761,9 +761,9 @@ Describe 'manifest 与配置' {
$saved = $env:BAKNRET_PASSWORD $saved = $env:BAKNRET_PASSWORD
try { try {
$env:BAKNRET_PASSWORD = 'sekrit' $env:BAKNRET_PASSWORD = 'sekrit'
(Get-BaknretPassword) | Should -Be 'sekrit' (Get-BakNRetPassword) | Should -Be 'sekrit'
$env:BAKNRET_PASSWORD = $null $env:BAKNRET_PASSWORD = $null
(Get-BaknretPassword) | Should -BeNullOrEmpty (Get-BakNRetPassword) | Should -BeNullOrEmpty
} }
finally { finally {
$env:BAKNRET_PASSWORD = $saved $env:BAKNRET_PASSWORD = $saved
@@ -827,7 +827,7 @@ Describe '软件名录' {
} }
It '名录解析:条目与 Slot 的字段集合就是新契约' { It '名录解析:条目与 Slot 的字段集合就是新契约' {
$catalog = Get-SoftwareCatalog -Path $script:CatFile -MaxDepth 3 -NoCache $catalog = Get-BakNRetSoftwareCatalog -Path $script:CatFile -MaxDepth 3 -NoCache
$entry = $catalog['single'] $entry = $catalog['single']
(@($entry.PSObject.Properties.Name) -join ',') | Should -Be 'Name,Path,Description,Slots,Kind,Missing,Error,Raw' (@($entry.PSObject.Properties.Name) -join ',') | Should -Be 'Name,Path,Description,Slots,Kind,Missing,Error,Raw'
$entry.Name | Should -Be 'single' $entry.Name | Should -Be 'single'
@@ -849,7 +849,7 @@ Describe '软件名录' {
} }
It '一个软件多个 Slot:Kind=Multi,Slot 按名字排序' { It '一个软件多个 Slot:Kind=Multi,Slot 按名字排序' {
$catalog = Get-SoftwareCatalog -Path $script:CatFile -MaxDepth 3 -NoCache $catalog = Get-BakNRetSoftwareCatalog -Path $script:CatFile -MaxDepth 3 -NoCache
$catalog['multi'].Kind | Should -Be 'Multi' $catalog['multi'].Kind | Should -Be 'Multi'
@($catalog['multi'].Slots).Count | Should -Be 2 @($catalog['multi'].Slots).Count | Should -Be 2
(@($catalog['multi'].Slots | ForEach-Object { $_.Name }) -join ',') | Should -Be 'Alpha,Zeta' (@($catalog['multi'].Slots | ForEach-Object { $_.Name }) -join ',') | Should -Be 'Alpha,Zeta'
@@ -858,7 +858,7 @@ Describe '软件名录' {
} }
It 'Kind:Single / Multi / Partial / Unresolved / Invalid' { It 'Kind:Single / Multi / Partial / Unresolved / Invalid' {
$catalog = Get-SoftwareCatalog -Path $script:CatFile -MaxDepth 3 -NoCache $catalog = Get-BakNRetSoftwareCatalog -Path $script:CatFile -MaxDepth 3 -NoCache
$catalog['single'].Kind | Should -Be 'Single' $catalog['single'].Kind | Should -Be 'Single'
$catalog['multi'].Kind | Should -Be 'Multi' $catalog['multi'].Kind | Should -Be 'Multi'
$catalog['partial'].Kind | Should -Be 'Partial' $catalog['partial'].Kind | Should -Be 'Partial'
@@ -873,7 +873,7 @@ Describe '软件名录' {
} }
It '前缀补全:<名>_<后缀> 与 <名>-<后缀> 都会被补全' { It '前缀补全:<名>_<后缀> 与 <名>-<后缀> 都会被补全' {
$catalog = Get-SoftwareCatalog -Path $script:CatFile -MaxDepth 3 -NoCache $catalog = Get-BakNRetSoftwareCatalog -Path $script:CatFile -MaxDepth 3 -NoCache
$catalog['legendary'].Slots[0].Resolved | Should -Be (Join-Path $script:CatRoot 'legendary_2.0.4') $catalog['legendary'].Slots[0].Resolved | Should -Be (Join-Path $script:CatRoot 'legendary_2.0.4')
$catalog['legendary'].Slots[0].Suffixed | Should -BeTrue $catalog['legendary'].Slots[0].Suffixed | Should -BeTrue
@@ -882,12 +882,12 @@ Describe '软件名录' {
} }
It '不会把 Legendary 误配成 LegendarySomething' { It '不会把 Legendary 误配成 LegendarySomething' {
$catalog = Get-SoftwareCatalog -Path $script:CatFile -MaxDepth 3 -NoCache $catalog = Get-BakNRetSoftwareCatalog -Path $script:CatFile -MaxDepth 3 -NoCache
$catalog['legendary'].Slots[0].Resolved | Should -Not -Be (Join-Path $script:CatRoot 'LegendarySomething') $catalog['legendary'].Slots[0].Resolved | Should -Not -Be (Join-Path $script:CatRoot 'LegendarySomething')
} }
It '一个 Slot 命中多个候选目录:报 Error,绝不悄悄挑一棵树' { It '一个 Slot 命中多个候选目录:报 Error,绝不悄悄挑一棵树' {
$catalog = Get-SoftwareCatalog -Path $script:CatFile -MaxDepth 3 -NoCache $catalog = Get-BakNRetSoftwareCatalog -Path $script:CatFile -MaxDepth 3 -NoCache
$entry = $catalog['dupslot'] $entry = $catalog['dupslot']
$entry.Error | Should -Match '只能对应一个目录' $entry.Error | Should -Match '只能对应一个目录'
$entry.Error | Should -Match 'dup_1' $entry.Error | Should -Match 'dup_1'
@@ -897,14 +897,14 @@ Describe '软件名录' {
} }
It '文件 Slot:Path 指向文件时 IsFile 为真,Encrypt 也带上' { It '文件 Slot:Path 指向文件时 IsFile 为真,Encrypt 也带上' {
$catalog = Get-SoftwareCatalog -Path $script:CatFile -MaxDepth 3 -NoCache $catalog = Get-BakNRetSoftwareCatalog -Path $script:CatFile -MaxDepth 3 -NoCache
$slot = $catalog['fileslot'].Slots[0] $slot = $catalog['fileslot'].Slots[0]
$slot.IsFile | Should -BeTrue $slot.IsFile | Should -BeTrue
$slot.Encrypt | Should -BeTrue $slot.Encrypt | Should -BeTrue
} }
It '%变量% 会在名录路径里展开' { It '%变量% 会在名录路径里展开' {
$catalog = Get-SoftwareCatalog -Path $script:CatFile -MaxDepth 3 -NoCache $catalog = Get-BakNRetSoftwareCatalog -Path $script:CatFile -MaxDepth 3 -NoCache
$catalog['envpath'].Slots[0].Resolved | Should -Be ([Environment]::ExpandEnvironmentVariables('%TEMP%')) $catalog['envpath'].Slots[0].Resolved | Should -Be ([Environment]::ExpandEnvironmentVariables('%TEMP%'))
} }
@@ -916,39 +916,39 @@ Describe '软件名录' {
} }
It '读取结果按"路径 + 时间戳 + 长度 + 内容 MD5"缓存' { It '读取结果按"路径 + 时间戳 + 长度 + 内容 MD5"缓存' {
$first = Get-SoftwareCatalog -Path $script:CatFile -MaxDepth 3 $first = Get-BakNRetSoftwareCatalog -Path $script:CatFile -MaxDepth 3
$second = Get-SoftwareCatalog -Path $script:CatFile -MaxDepth 3 $second = Get-BakNRetSoftwareCatalog -Path $script:CatFile -MaxDepth 3
[object]::ReferenceEquals($first, $second) | Should -BeTrue [object]::ReferenceEquals($first, $second) | Should -BeTrue
$fresh = Get-SoftwareCatalog -Path $script:CatFile -MaxDepth 3 -NoCache $fresh = Get-BakNRetSoftwareCatalog -Path $script:CatFile -MaxDepth 3 -NoCache
[object]::ReferenceEquals($first, $fresh) | Should -BeFalse [object]::ReferenceEquals($first, $fresh) | Should -BeFalse
} }
It '名录文件内容变了以后缓存自动失效' { It '名录文件内容变了以后缓存自动失效' {
$path = Write-ListFile -Path (Join-Path $script:CatRoot 'cache.psd1') -Content "@{ 'x' = @{ S = @{ Path = '$script:CatReal'; Description = 'one' } } }" $path = Write-ListFile -Path (Join-Path $script:CatRoot 'cache.psd1') -Content "@{ 'x' = @{ S = @{ Path = '$script:CatReal'; Description = 'one' } } }"
(Get-SoftwareCatalog -Path $path -MaxDepth 3)['x'].Description | Should -Be 'one' (Get-BakNRetSoftwareCatalog -Path $path -MaxDepth 3)['x'].Description | Should -Be 'one'
Write-ListFile -Path $path -Content "@{ 'x' = @{ S = @{ Path = '$script:CatReal'; Description = 'two' } } }" | Out-Null Write-ListFile -Path $path -Content "@{ 'x' = @{ S = @{ Path = '$script:CatReal'; Description = 'two' } } }" | Out-Null
(Get-SoftwareCatalog -Path $path -MaxDepth 3)['x'].Description | Should -Be 'two' (Get-BakNRetSoftwareCatalog -Path $path -MaxDepth 3)['x'].Description | Should -Be 'two'
} }
It 'Includes:分文件维护的名录会被合并' { It 'Includes:分文件维护的名录会被合并' {
Write-ListFile -Path (Join-Path $script:CatRoot 'Extra.psd1') -Content "@{ 'extra-app' = @{ S = @{ Path = '$script:CatReal' } } }" | Out-Null Write-ListFile -Path (Join-Path $script:CatRoot 'Extra.psd1') -Content "@{ 'extra-app' = @{ S = @{ Path = '$script:CatReal' } } }" | Out-Null
$main = Write-ListFile -Path (Join-Path $script:CatRoot 'Main.psd1') -Content "@{ Includes = @('Extra.psd1'); 'main-app' = @{ S = @{ Path = '$script:CatReal' } } }" $main = Write-ListFile -Path (Join-Path $script:CatRoot 'Main.psd1') -Content "@{ Includes = @('Extra.psd1'); 'main-app' = @{ S = @{ Path = '$script:CatReal' } } }"
$catalog = Get-SoftwareCatalog -Path $main -MaxDepth 3 -NoCache $catalog = Get-BakNRetSoftwareCatalog -Path $main -MaxDepth 3 -NoCache
$catalog.ContainsKey('main-app') | Should -BeTrue $catalog.ContainsKey('main-app') | Should -BeTrue
$catalog.ContainsKey('extra-app') | Should -BeTrue $catalog.ContainsKey('extra-app') | Should -BeTrue
} }
It '旧的裸字符串 / 字符串数组 / 对象数组写法都会报 ERROR 并被跳过' { It '旧的裸字符串 / 字符串数组 / 对象数组写法都会报 ERROR 并被跳过' {
$catalog = Get-SoftwareCatalog -Path $script:LegacyFile -MaxDepth 3 -NoCache $catalog = Get-BakNRetSoftwareCatalog -Path $script:LegacyFile -MaxDepth 3 -NoCache
$catalog.ContainsKey('bare') | Should -BeFalse $catalog.ContainsKey('bare') | Should -BeFalse
$catalog.ContainsKey('arr') | Should -BeFalse $catalog.ContainsKey('arr') | Should -BeFalse
$catalog.ContainsKey('objarr') | Should -BeFalse $catalog.ContainsKey('objarr') | Should -BeFalse
} }
It '旧的 @{ Dirs = @(...) } 写法不再展开:留下 Invalid 条目和原因' { It '旧的 @{ Dirs = @(...) } 写法不再展开:留下 Invalid 条目和原因' {
$catalog = Get-SoftwareCatalog -Path $script:LegacyFile -MaxDepth 3 -NoCache $catalog = Get-BakNRetSoftwareCatalog -Path $script:LegacyFile -MaxDepth 3 -NoCache
$catalog.ContainsKey('dirstyle') | Should -BeTrue $catalog.ContainsKey('dirstyle') | Should -BeTrue
$catalog['dirstyle'].Kind | Should -Be 'Invalid' $catalog['dirstyle'].Kind | Should -Be 'Invalid'
$catalog['dirstyle'].Error | Should -Not -BeNullOrEmpty $catalog['dirstyle'].Error | Should -Not -BeNullOrEmpty
@@ -956,14 +956,14 @@ Describe '软件名录' {
} }
It '软件名会被规范化成合法文件名' { It '软件名会被规范化成合法文件名' {
$clean = Format-CatalogName -Name 'a<b>c:d/e' $clean = Format-BakNRetCatalogName -Name 'a<b>c:d/e'
($clean.IndexOfAny([System.IO.Path]::GetInvalidFileNameChars())) | Should -Be -1 ($clean.IndexOfAny([System.IO.Path]::GetInvalidFileNameChars())) | Should -Be -1
(Format-CatalogName -Name ' My App ') | Should -Be 'My App' (Format-BakNRetCatalogName -Name ' My App ') | Should -Be 'My App'
} }
} }
# ============================================================================ # ============================================================================
Describe 'Resolve-BackupEntry:条目解析' { Describe 'Resolve-BakNRetBackupEntry:条目解析' {
# ============================================================================ # ============================================================================
BeforeAll { BeforeAll {
@@ -976,7 +976,7 @@ Describe 'Resolve-BackupEntry:条目解析' {
It 'resolve 与归档项的字段集合就是新契约(旧字段已删除)' { It 'resolve 与归档项的字段集合就是新契约(旧字段已删除)' {
$entry = ConvertFrom-BackupListLine -Line 'single' $entry = ConvertFrom-BackupListLine -Line 'single'
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3 $resolved = Resolve-BakNRetBackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3
(@($resolved.PSObject.Properties.Name) -join ',') | Should -Be 'IsName,CatalogEntry,BaseName,ArchiveFlavor,Direction,Items,Encrypt,ExcludePatterns,HasExcludeOverride,Includes,HasIncludeOverride,Source,Error,Blocking' (@($resolved.PSObject.Properties.Name) -join ',') | Should -Be 'IsName,CatalogEntry,BaseName,ArchiveFlavor,Direction,Items,Encrypt,ExcludePatterns,HasExcludeOverride,Includes,HasIncludeOverride,Source,Error,Blocking'
@($resolved.Items)[0].PSObject.Properties.Name | Should -Contain 'ArchivePath' @($resolved.Items)[0].PSObject.Properties.Name | Should -Contain 'ArchivePath'
@($resolved.Items)[0].PSObject.Properties.Name | Should -Contain 'Origin' @($resolved.Items)[0].PSObject.Properties.Name | Should -Contain 'Origin'
@@ -985,7 +985,7 @@ Describe 'Resolve-BackupEntry:条目解析' {
It '软件名条目:isName/CatalogEntry/BaseName/ArchiveFlavor/Source' { It '软件名条目:isName/CatalogEntry/BaseName/ArchiveFlavor/Source' {
$entry = ConvertFrom-BackupListLine -Line 'single' $entry = ConvertFrom-BackupListLine -Line 'single'
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3 $resolved = Resolve-BakNRetBackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3
$resolved.IsName | Should -BeTrue $resolved.IsName | Should -BeTrue
$resolved.BaseName | Should -Be 'single' $resolved.BaseName | Should -Be 'single'
$resolved.ArchiveFlavor | Should -Be 'name' $resolved.ArchiveFlavor | Should -Be 'name'
@@ -997,7 +997,7 @@ Describe 'Resolve-BackupEntry:条目解析' {
It '软件名条目:Items 来自 Slot(ArchivePath=Slot 名,Kind=slot,Origin=catalog)' { It '软件名条目:Items 来自 Slot(ArchivePath=Slot 名,Kind=slot,Origin=catalog)' {
$entry = ConvertFrom-BackupListLine -Line 'multi' $entry = ConvertFrom-BackupListLine -Line 'multi'
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3 $resolved = Resolve-BakNRetBackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3
@($resolved.Items).Count | Should -Be 2 @($resolved.Items).Count | Should -Be 2
(@($resolved.Items | ForEach-Object { $_.ArchivePath }) -join ',') | Should -Be 'Alpha,Zeta' (@($resolved.Items | ForEach-Object { $_.ArchivePath }) -join ',') | Should -Be 'Alpha,Zeta'
(@($resolved.Items | ForEach-Object { $_.Kind }) -join ',') | Should -Be 'slot,slot' (@($resolved.Items | ForEach-Object { $_.Kind }) -join ',') | Should -Be 'slot,slot'
@@ -1009,7 +1009,7 @@ Describe 'Resolve-BackupEntry:条目解析' {
It '字面路径条目:一个 path 项(ArchivePath 是末级名)' { It '字面路径条目:一个 path 项(ArchivePath 是末级名)' {
$entry = ConvertFrom-BackupListLine -Line 'C:\Programs\FooClolor' $entry = ConvertFrom-BackupListLine -Line 'C:\Programs\FooClolor'
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3 $resolved = Resolve-BakNRetBackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3
$resolved.IsName | Should -BeFalse $resolved.IsName | Should -BeFalse
$resolved.ArchiveFlavor | Should -Be 'path' $resolved.ArchiveFlavor | Should -Be 'path'
@($resolved.Items).Count | Should -Be 1 @($resolved.Items).Count | Should -Be 1
@@ -1021,7 +1021,7 @@ Describe 'Resolve-BackupEntry:条目解析' {
It '字面路径条目上的 @ Path= 覆盖目标路径,但归档名仍按原路径' { It '字面路径条目上的 @ Path= 覆盖目标路径,但归档名仍按原路径' {
$entry = ConvertFrom-BackupListLine -Line "C:\Programs\Foo @ Path='D:\Elsewhere'" $entry = ConvertFrom-BackupListLine -Line "C:\Programs\Foo @ Path='D:\Elsewhere'"
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3 $resolved = Resolve-BakNRetBackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3
$resolved.Items[0].RealPath | Should -Be 'D:\Elsewhere' $resolved.Items[0].RealPath | Should -Be 'D:\Elsewhere'
$resolved.BaseName | Should -Be 'Foo_from_C_+Programs' $resolved.BaseName | Should -Be 'Foo_from_C_+Programs'
} }
@@ -1029,7 +1029,7 @@ Describe 'Resolve-BackupEntry:条目解析' {
It '名录里的路径不存在时仍给出 Items(恢复要靠它还原回原位)' { It '名录里的路径不存在时仍给出 Items(恢复要靠它还原回原位)' {
$missingCatalog = Write-ListFile -Path (Join-Path $script:Sandbox 'catalog\Missing.psd1') -Content "@{ 'gone' = @{ S = @{ Path = 'C:\definitely-not-here-98765' } } }" $missingCatalog = Write-ListFile -Path (Join-Path $script:Sandbox 'catalog\Missing.psd1') -Content "@{ 'gone' = @{ S = @{ Path = 'C:\definitely-not-here-98765' } } }"
$entry = ConvertFrom-BackupListLine -Line 'gone' $entry = ConvertFrom-BackupListLine -Line 'gone'
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $missingCatalog -MaxDepth 3 $resolved = Resolve-BakNRetBackupEntry -Entry $entry -CatalogPath $missingCatalog -MaxDepth 3
@($resolved.Items).Count | Should -Be 1 @($resolved.Items).Count | Should -Be 1
$resolved.Items[0].Exists | Should -BeFalse $resolved.Items[0].Exists | Should -BeFalse
$resolved.Items[0].RealPath | Should -Be 'C:\definitely-not-here-98765' $resolved.Items[0].RealPath | Should -Be 'C:\definitely-not-here-98765'
@@ -1037,7 +1037,7 @@ Describe 'Resolve-BackupEntry:条目解析' {
It ':: 覆盖 Path:单 Slot 条目直接生效' { It ':: 覆盖 Path:单 Slot 条目直接生效' {
$entry = ConvertFrom-BackupListLine -Line "single :: $script:CatOther" $entry = ConvertFrom-BackupListLine -Line "single :: $script:CatOther"
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3 $resolved = Resolve-BakNRetBackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3
@($resolved.Items).Count | Should -Be 1 @($resolved.Items).Count | Should -Be 1
$resolved.Items[0].ArchivePath | Should -Be 'Main' $resolved.Items[0].ArchivePath | Should -Be 'Main'
$resolved.Items[0].RealPath | Should -Be $script:CatOther $resolved.Items[0].RealPath | Should -Be $script:CatOther
@@ -1047,7 +1047,7 @@ Describe 'Resolve-BackupEntry:条目解析' {
It ':: / @ Path= 覆盖遇到多 Slot 条目 -> Blocking(不猜是哪一个)' { It ':: / @ Path= 覆盖遇到多 Slot 条目 -> Blocking(不猜是哪一个)' {
foreach ($line in @("multi :: $script:CatOther", "multi @ Path='$script:CatOther'")) { foreach ($line in @("multi :: $script:CatOther", "multi @ Path='$script:CatOther'")) {
$entry = ConvertFrom-BackupListLine -Line $line $entry = ConvertFrom-BackupListLine -Line $line
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3 $resolved = Resolve-BakNRetBackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3
@($resolved.Items).Count | Should -Be 0 @($resolved.Items).Count | Should -Be 0
$resolved.Blocking | Should -Match '不能用一个' $resolved.Blocking | Should -Match '不能用一个'
} }
@@ -1055,14 +1055,14 @@ Describe 'Resolve-BackupEntry:条目解析' {
It '条目级 :- 覆盖名录里的排除:HasExcludeOverride 为真' { It '条目级 :- 覆盖名录里的排除:HasExcludeOverride 为真' {
$entry = ConvertFrom-BackupListLine -Line 'single :- logs\,!*Cache' $entry = ConvertFrom-BackupListLine -Line 'single :- logs\,!*Cache'
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3 $resolved = Resolve-BakNRetBackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3
$resolved.HasExcludeOverride | Should -BeTrue $resolved.HasExcludeOverride | Should -BeTrue
(@($resolved.ExcludePatterns) -join '|') | Should -Be 'logs\|!*Cache' (@($resolved.ExcludePatterns) -join '|') | Should -Be 'logs\|!*Cache'
} }
It '名录 Slot 的 Include 会追加成 include 项' { It '名录 Slot 的 Include 会追加成 include 项' {
$entry = ConvertFrom-BackupListLine -Line 'incapp' $entry = ConvertFrom-BackupListLine -Line 'incapp'
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3 $resolved = Resolve-BakNRetBackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3
@($resolved.Items).Count | Should -Be 2 @($resolved.Items).Count | Should -Be 2
$include = @($resolved.Items | Where-Object { $_.Origin -eq 'include' }) $include = @($resolved.Items | Where-Object { $_.Origin -eq 'include' })
$include.Count | Should -Be 1 $include.Count | Should -Be 1
@@ -1074,7 +1074,7 @@ Describe 'Resolve-BackupEntry:条目解析' {
It '条目级 :+ / @ Include= 覆盖名录里的 Include' { It '条目级 :+ / @ Include= 覆盖名录里的 Include' {
foreach ($line in @("incapp :+ Other:$script:CatOther", "incapp @ Include='Other:$script:CatOther'")) { foreach ($line in @("incapp :+ Other:$script:CatOther", "incapp @ Include='Other:$script:CatOther'")) {
$entry = ConvertFrom-BackupListLine -Line $line $entry = ConvertFrom-BackupListLine -Line $line
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3 $resolved = Resolve-BakNRetBackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3
$resolved.HasIncludeOverride | Should -BeTrue $resolved.HasIncludeOverride | Should -BeTrue
(@($resolved.Items | ForEach-Object { $_.ArchivePath }) -join ',') | Should -Be 'A,Other' (@($resolved.Items | ForEach-Object { $_.ArchivePath }) -join ',') | Should -Be 'A,Other'
@($resolved.Items)[1].RealPath | Should -Be $script:CatOther @($resolved.Items)[1].RealPath | Should -Be $script:CatOther
@@ -1082,34 +1082,34 @@ Describe 'Resolve-BackupEntry:条目解析' {
} }
It '加密:名录里各 Slot 取或;条目级 :encrypt / :!encrypt 覆盖' { It '加密:名录里各 Slot 取或;条目级 :encrypt / :!encrypt 覆盖' {
(Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line 'single') -CatalogPath $script:CatFile -MaxDepth 3).Encrypt | Should -BeFalse (Resolve-BakNRetBackupEntry -Entry (ConvertFrom-BackupListLine -Line 'single') -CatalogPath $script:CatFile -MaxDepth 3).Encrypt | Should -BeFalse
(Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line 'encapp') -CatalogPath $script:CatFile -MaxDepth 3).Encrypt | Should -BeTrue (Resolve-BakNRetBackupEntry -Entry (ConvertFrom-BackupListLine -Line 'encapp') -CatalogPath $script:CatFile -MaxDepth 3).Encrypt | Should -BeTrue
(Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line 'encapp :!encrypt') -CatalogPath $script:CatFile -MaxDepth 3).Encrypt | Should -BeFalse (Resolve-BakNRetBackupEntry -Entry (ConvertFrom-BackupListLine -Line 'encapp :!encrypt') -CatalogPath $script:CatFile -MaxDepth 3).Encrypt | Should -BeFalse
(Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line 'single :encrypt') -CatalogPath $script:CatFile -MaxDepth 3).Encrypt | Should -BeTrue (Resolve-BakNRetBackupEntry -Entry (ConvertFrom-BackupListLine -Line 'single :encrypt') -CatalogPath $script:CatFile -MaxDepth 3).Encrypt | Should -BeTrue
(Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line "single @ Encrypt='true'") -CatalogPath $script:CatFile -MaxDepth 3).Encrypt | Should -BeTrue (Resolve-BakNRetBackupEntry -Entry (ConvertFrom-BackupListLine -Line "single @ Encrypt='true'") -CatalogPath $script:CatFile -MaxDepth 3).Encrypt | Should -BeTrue
} }
It '方向标记会传递到 Resolve-BackupEntry.Direction' { It '方向标记会传递到 Resolve-BakNRetBackupEntry.Direction' {
(Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line '+ single') -CatalogPath $script:CatFile -MaxDepth 3).Direction | Should -Be 'backup' (Resolve-BakNRetBackupEntry -Entry (ConvertFrom-BackupListLine -Line '+ single') -CatalogPath $script:CatFile -MaxDepth 3).Direction | Should -Be 'backup'
(Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line '- single') -CatalogPath $script:CatFile -MaxDepth 3).Direction | Should -Be 'restore' (Resolve-BakNRetBackupEntry -Entry (ConvertFrom-BackupListLine -Line '- single') -CatalogPath $script:CatFile -MaxDepth 3).Direction | Should -Be 'restore'
(Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line 'single') -CatalogPath $script:CatFile -MaxDepth 3).Direction | Should -Be 'both' (Resolve-BakNRetBackupEntry -Entry (ConvertFrom-BackupListLine -Line 'single') -CatalogPath $script:CatFile -MaxDepth 3).Direction | Should -Be 'both'
} }
It '归档内路径冲突(include 与 Slot 同名)-> Blocking' { It '归档内路径冲突(include 与 Slot 同名)-> Blocking' {
$entry = ConvertFrom-BackupListLine -Line "conflict :+ Data:$script:CatOther" $entry = ConvertFrom-BackupListLine -Line "conflict :+ Data:$script:CatOther"
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3 $resolved = Resolve-BakNRetBackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3
$resolved.Blocking | Should -Match '归档内路径冲突' $resolved.Blocking | Should -Match '归档内路径冲突'
} }
It '归档内路径冲突(父子关系)-> Blocking' { It '归档内路径冲突(父子关系)-> Blocking' {
$entry = ConvertFrom-BackupListLine -Line "conflict :+ Data\sub:$script:CatOther" $entry = ConvertFrom-BackupListLine -Line "conflict :+ Data\sub:$script:CatOther"
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3 $resolved = Resolve-BakNRetBackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3
$resolved.Blocking | Should -Match '父子关系' $resolved.Blocking | Should -Match '父子关系'
} }
It '名录里没有该名字:Error 给出,Items 为空' { It '名录里没有该名字:Error 给出,Items 为空' {
$entry = ConvertFrom-BackupListLine -Line 'no-such-thing' $entry = ConvertFrom-BackupListLine -Line 'no-such-thing'
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3 $resolved = Resolve-BakNRetBackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3
$resolved.Error | Should -Match 'no-such-thing' $resolved.Error | Should -Match 'no-such-thing'
@($resolved.Items).Count | Should -Be 0 @($resolved.Items).Count | Should -Be 0
} }
@@ -1125,13 +1125,13 @@ Describe '外部命令退出码(旧实现的核心缺陷)' {
$sentinel = 'SENTINEL-PW-9f3a' $sentinel = 'SENTINEL-PW-9f3a'
$logPath = $null $logPath = $null
try { try {
$logPath = Start-BaknretLog -Directory $sandbox -Prefix 'secret' $logPath = Start-BakNRetLog -Directory $sandbox -Prefix 'secret'
Set-BaknretDebug Set-BakNRetDebug
$null = Invoke-ExternalCommand -FilePath 'cmd.exe' -ArgumentList @('/c', 'exit 0', "-p$sentinel") $null = Invoke-ExternalCommand -FilePath 'cmd.exe' -ArgumentList @('/c', 'exit 0', "-p$sentinel")
} }
finally { finally {
Set-BaknretDebug -Enabled:$false Set-BakNRetDebug -Enabled:$false
Stop-BaknretLog Stop-BakNRetLog
} }
$logText = Get-Content -Encoding UTF8 -LiteralPath $logPath -Raw $logText = Get-Content -Encoding UTF8 -LiteralPath $logPath -Raw
@@ -1150,21 +1150,21 @@ Describe '外部命令退出码(旧实现的核心缺陷)' {
$first = $null $first = $null
$second = $null $second = $null
try { try {
$first = Enter-BaknretRunLock -Directory $dir $first = Enter-BakNRetRunLock -Directory $dir
$first | Should -Not -BeNullOrEmpty $first | Should -Not -BeNullOrEmpty
$second = Enter-BaknretRunLock -Directory $dir $second = Enter-BakNRetRunLock -Directory $dir
$second | Should -BeNullOrEmpty $second | Should -BeNullOrEmpty
Test-Path -LiteralPath (Get-BaknretRunLockPath -Directory $dir) | Should -BeTrue Test-Path -LiteralPath (Get-BakNRetRunLockPath -Directory $dir) | Should -BeTrue
} }
finally { finally {
Exit-BaknretRunLock -Lock $second Exit-BakNRetRunLock -Lock $second
Exit-BaknretRunLock -Lock $first Exit-BakNRetRunLock -Lock $first
} }
# 锁文件是独占打开的(FileShare.None),内容只能在释放之后读 # 锁文件是独占打开的(FileShare.None),内容只能在释放之后读
(Get-Content -Encoding UTF8 -LiteralPath (Get-BaknretRunLockPath -Directory $dir) -Raw) | (Get-Content -Encoding UTF8 -LiteralPath (Get-BakNRetRunLockPath -Directory $dir) -Raw) |
Should -Match "pid=$PID" Should -Match "pid=$PID"
Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction SilentlyContinue
@@ -1173,13 +1173,13 @@ Describe '外部命令退出码(旧实现的核心缺陷)' {
It '运行锁:释放之后可以重新取得' { It '运行锁:释放之后可以重新取得' {
$dir = Join-Path $env:TEMP ("bnr-lock-" + [guid]::NewGuid().ToString('N').Substring(0, 8)) $dir = Join-Path $env:TEMP ("bnr-lock-" + [guid]::NewGuid().ToString('N').Substring(0, 8))
try { try {
$first = Enter-BaknretRunLock -Directory $dir $first = Enter-BakNRetRunLock -Directory $dir
$first | Should -Not -BeNullOrEmpty $first | Should -Not -BeNullOrEmpty
Exit-BaknretRunLock -Lock $first Exit-BakNRetRunLock -Lock $first
$second = Enter-BaknretRunLock -Directory $dir $second = Enter-BakNRetRunLock -Directory $dir
$second | Should -Not -BeNullOrEmpty $second | Should -Not -BeNullOrEmpty
Exit-BaknretRunLock -Lock $second Exit-BakNRetRunLock -Lock $second
} }
finally { finally {
Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction SilentlyContinue
@@ -1192,7 +1192,7 @@ Describe '外部命令退出码(旧实现的核心缺陷)' {
$empty = Join-Path $env:TEMP ("bnr-empty-" + [guid]::NewGuid().ToString('N').Substring(0, 8)) $empty = Join-Path $env:TEMP ("bnr-empty-" + [guid]::NewGuid().ToString('N').Substring(0, 8))
New-Item -ItemType Directory -Path $empty -Force | Out-Null New-Item -ItemType Directory -Path $empty -Force | Out-Null
try { try {
$summary = Get-FolderSummary -FolderPath $empty $summary = Get-BakNRetFolderSummary -FolderPath $empty
$summary.FileCount | Should -Be 0 $summary.FileCount | Should -Be 0
$summary.TotalSize | Should -Not -BeNullOrEmpty $summary.TotalSize | Should -Not -BeNullOrEmpty
$summary.TotalSize | Should -Be 0 $summary.TotalSize | Should -Be 0
@@ -1207,18 +1207,18 @@ Describe '外部命令退出码(旧实现的核心缺陷)' {
New-Item -ItemType Directory -Path $base -Force | Out-Null New-Item -ItemType Directory -Path $base -Force | Out-Null
$target = Join-Path $base 'text.json' $target = Join-Path $base 'text.json'
try { try {
Write-BaknretAtomicText -Path $target -Text 'FIRST' | Out-Null Write-BakNRetAtomicText -Path $target -Text 'FIRST' | Out-Null
(Get-Content -Encoding UTF8 -LiteralPath $target -Raw) | Should -Be 'FIRST' (Get-Content -Encoding UTF8 -LiteralPath $target -Raw) | Should -Be 'FIRST'
# 目标已存在时走 File.Replace # 目标已存在时走 File.Replace
Write-BaknretAtomicText -Path $target -Text 'SECOND' | Out-Null Write-BakNRetAtomicText -Path $target -Text 'SECOND' | Out-Null
(Get-Content -Encoding UTF8 -LiteralPath $target -Raw) | Should -Be 'SECOND' (Get-Content -Encoding UTF8 -LiteralPath $target -Raw) | Should -Be 'SECOND'
"$target.tmp" | Should -Not -Exist "$target.tmp" | Should -Not -Exist
# 目标只读 -> 替换必然失败 -> 旧内容必须还在。这正是 File.Replace 与"先删后移" # 目标只读 -> 替换必然失败 -> 旧内容必须还在。这正是 File.Replace 与"先删后移"
# 的区别:后者失败时旧文件已经没了,而 manifest 丢了只能靠文件名反推。 # 的区别:后者失败时旧文件已经没了,而 manifest 丢了只能靠文件名反推。
(Get-Item -LiteralPath $target).IsReadOnly = $true (Get-Item -LiteralPath $target).IsReadOnly = $true
{ Write-BaknretAtomicText -Path $target -Text 'SHOULD-NOT-LAND' } | Should -Throw { Write-BakNRetAtomicText -Path $target -Text 'SHOULD-NOT-LAND' } | Should -Throw
(Get-Content -Encoding UTF8 -LiteralPath $target -Raw) | Should -Be 'SECOND' (Get-Content -Encoding UTF8 -LiteralPath $target -Raw) | Should -Be 'SECOND'
} }
finally { finally {
@@ -1260,9 +1260,9 @@ Describe '集成:真实 7z 压缩与排除规则' -Skip:(-not $script:HasSeven
It '排除规则与空格处理在真实归档上生效' { It '排除规则与空格处理在真实归档上生效' {
$patterns = @('!*Cache', 'component_crx_cache', 'Default\Code Cache', 'Default\Extensions', 'Default\IndexedDB') $patterns = @('!*Cache', 'component_crx_cache', 'Default\Code Cache', 'Default\Extensions', 'Default\IndexedDB')
$item = New-BaknretArchiveItem -ArchivePath $script:IntegrationItem -RealPath $script:IntegrationPath ` $item = New-BakNRetArchiveItem -ArchivePath $script:IntegrationItem -RealPath $script:IntegrationPath `
-Kind 'path' -Origin 'path' -Exists $true -IsFile $false -Kind 'path' -Origin 'path' -Exists $true -IsFile $false
$excludeArguments = @((Get-BaknretExcludeArgument -Item $item -Patterns $patterns).Arguments) $excludeArguments = @((Get-BakNRetExcludeArgument -Item $item -Patterns $patterns).Arguments)
$archive = Join-Path $script:IntegrationRoot 'excl.7z' $archive = Join-Path $script:IntegrationRoot 'excl.7z'
$arguments = @('a', '-t7z', '-mx=1', '-bso0', '-bsp0') + $excludeArguments + @($archive, $script:IntegrationItem) $arguments = @('a', '-t7z', '-mx=1', '-bso0', '-bsp0') + $excludeArguments + @($archive, $script:IntegrationItem)
@@ -1316,14 +1316,14 @@ Describe '集成:Backup.ps1 / Restore.ps1 端到端(字面路径条目)' -
$script:E2EHashes = New-VerifiableSourceTree -Root $script:E2ESource $script:E2EHashes = New-VerifiableSourceTree -Root $script:E2ESource
Write-ListFile -Path $script:E2EList -Content "# e2e`n$script:E2ESource :- logs\,!*Cache`n" | Out-Null Write-ListFile -Path $script:E2EList -Content "# e2e`n$script:E2ESource :- logs\,!*Cache`n" | Out-Null
$script:BackupRun = Invoke-BaknretScript -Script $script:BackupScript -Parameters @{ $script:BackupRun = Invoke-BakNRetScript -Script $script:BackupScript -Parameters @{
BackupListPath = $script:E2EList BackupListPath = $script:E2EList
BackupDir = $script:E2EBackupDir BackupDir = $script:E2EBackupDir
Force = $true Force = $true
QuietTool = $true QuietTool = $true
} }
$script:E2EManifestPath = Join-Path $script:E2EBackupDir 'manifest.json' $script:E2EManifestPath = Join-Path $script:E2EBackupDir 'manifest.json'
$script:E2EManifest = Read-BaknretManifest -Path $script:E2EManifestPath $script:E2EManifest = Read-BakNRetManifest -Path $script:E2EManifestPath
$script:E2EArchive = @(Get-ChildItem -LiteralPath $script:E2EBackupDir -File -Filter *.7z)[0] $script:E2EArchive = @(Get-ChildItem -LiteralPath $script:E2EBackupDir -File -Filter *.7z)[0]
} }
@@ -1352,7 +1352,7 @@ Describe '集成:Backup.ps1 / Restore.ps1 端到端(字面路径条目)' -
} }
It '字面路径条目沿用 <末级名> 布局;归档里保留应保留内容、不含被排除项' { It '字面路径条目沿用 <末级名> 布局;归档里保留应保留内容、不含被排除项' {
(Get-BaknretArchiveTopName -ArchivePath 'My Code Space\keep.txt') | Should -Be 'My Code Space' (Get-BakNRetArchiveTopName -ArchivePath 'My Code Space\keep.txt') | Should -Be 'My Code Space'
$verify = Join-Path $script:E2ERoot 'verify' $verify = Join-Path $script:E2ERoot 'verify'
New-Item -ItemType Directory -Path $verify -Force | Out-Null New-Item -ItemType Directory -Path $verify -Force | Out-Null
@@ -1370,7 +1370,7 @@ Describe '集成:Backup.ps1 / Restore.ps1 端到端(字面路径条目)' -
$record.roots | Should -Contain 'My Code Space' $record.roots | Should -Contain 'My Code Space'
# 和归档里的真实内容对一次账,而不是只信 manifest 自己 # 和归档里的真实内容对一次账,而不是只信 manifest 自己
$topLevel = @(Get-ArchiveTopLevelNames -ArchivePath $script:E2EArchive.FullName -SevenZip $script:SevenZip) $topLevel = @(Get-BakNRetArchiveTopLevelNames -ArchivePath $script:E2EArchive.FullName -SevenZip $script:SevenZip)
(@($topLevel | Sort-Object) -join ',') | Should -Be 'My Code Space' (@($topLevel | Sort-Object) -join ',') | Should -Be 'My Code Space'
} }
@@ -1378,7 +1378,7 @@ Describe '集成:Backup.ps1 / Restore.ps1 端到端(字面路径条目)' -
$before = (Get-FileHash -LiteralPath $script:E2EManifestPath -Algorithm SHA256).Hash $before = (Get-FileHash -LiteralPath $script:E2EManifestPath -Algorithm SHA256).Hash
Remove-Item -LiteralPath $script:E2ESource -Recurse -Force Remove-Item -LiteralPath $script:E2ESource -Recurse -Force
$run = Invoke-BaknretScript -Script $script:RestoreScript -Parameters @{ $run = Invoke-BakNRetScript -Script $script:RestoreScript -Parameters @{
BackupListPath = $script:E2EList BackupListPath = $script:E2EList
BackupDir = $script:E2EBackupDir BackupDir = $script:E2EBackupDir
DryRun = $true DryRun = $true
@@ -1390,7 +1390,7 @@ Describe '集成:Backup.ps1 / Restore.ps1 端到端(字面路径条目)' -
} }
It 'Restore -WhatIf 同样不写盘' { It 'Restore -WhatIf 同样不写盘' {
$run = Invoke-BaknretScript -Script $script:RestoreScript -Parameters @{ $run = Invoke-BakNRetScript -Script $script:RestoreScript -Parameters @{
BackupListPath = $script:E2EList BackupListPath = $script:E2EList
BackupDir = $script:E2EBackupDir BackupDir = $script:E2EBackupDir
WhatIf = $true WhatIf = $true
@@ -1402,7 +1402,7 @@ Describe '集成:Backup.ps1 / Restore.ps1 端到端(字面路径条目)' -
It '真实恢复:退出码 0,文件逐字节一致,被排除项没有被恢复出来' { It '真实恢复:退出码 0,文件逐字节一致,被排除项没有被恢复出来' {
Test-Path -LiteralPath $script:E2ESource | Should -BeFalse # 保证不是空操作 Test-Path -LiteralPath $script:E2ESource | Should -BeFalse # 保证不是空操作
$run = Invoke-BaknretScript -Script $script:RestoreScript -Parameters @{ $run = Invoke-BakNRetScript -Script $script:RestoreScript -Parameters @{
BackupListPath = $script:E2EList BackupListPath = $script:E2EList
BackupDir = $script:E2EBackupDir BackupDir = $script:E2EBackupDir
Force = $true Force = $true
@@ -1420,7 +1420,7 @@ Describe '集成:Backup.ps1 / Restore.ps1 端到端(字面路径条目)' -
} }
It '真实恢复之后 manifest 才被更新(lastRestoreAt)' { It '真实恢复之后 manifest 才被更新(lastRestoreAt)' {
$manifest = Read-BaknretManifest -Path $script:E2EManifestPath $manifest = Read-BakNRetManifest -Path $script:E2EManifestPath
$record = $manifest.items[$script:E2EArchive.BaseName] $record = $manifest.items[$script:E2EArchive.BaseName]
$record.lastRestoreAt | Should -Not -BeNullOrEmpty $record.lastRestoreAt | Should -Not -BeNullOrEmpty
} }
@@ -1428,7 +1428,7 @@ Describe '集成:Backup.ps1 / Restore.ps1 端到端(字面路径条目)' -
It '孤儿归档会被点名报告,但不影响退出码' { It '孤儿归档会被点名报告,但不影响退出码' {
Copy-Item -LiteralPath $script:E2EArchive.FullName -Destination (Join-Path $script:E2EBackupDir 'Orphaned-Archive.7z') -Force Copy-Item -LiteralPath $script:E2EArchive.FullName -Destination (Join-Path $script:E2EBackupDir 'Orphaned-Archive.7z') -Force
$run = Invoke-BaknretScript -Script $script:BackupScript -Parameters @{ $run = Invoke-BakNRetScript -Script $script:BackupScript -Parameters @{
BackupListPath = $script:E2EList BackupListPath = $script:E2EList
BackupDir = $script:E2EBackupDir BackupDir = $script:E2EBackupDir
QuietTool = $true QuietTool = $true
@@ -1440,7 +1440,7 @@ Describe '集成:Backup.ps1 / Restore.ps1 端到端(字面路径条目)' -
} }
It '带 -Only 时不做孤儿审计(避免把未选中的归档误报成孤儿)' { It '带 -Only 时不做孤儿审计(避免把未选中的归档误报成孤儿)' {
$run = Invoke-BaknretScript -Script $script:BackupScript -Parameters @{ $run = Invoke-BakNRetScript -Script $script:BackupScript -Parameters @{
BackupListPath = $script:E2EList BackupListPath = $script:E2EList
BackupDir = $script:E2EBackupDir BackupDir = $script:E2EBackupDir
Only = @('My Code Space') Only = @('My Code Space')
@@ -1451,20 +1451,20 @@ Describe '集成:Backup.ps1 / Restore.ps1 端到端(字面路径条目)' -
It '源路径不存在时记为 missing-source,退出码仍为 0(跳过不算失败)' { It '源路径不存在时记为 missing-source,退出码仍为 0(跳过不算失败)' {
$missingList = Write-ListFile -Path (Join-Path $script:E2ERoot 'missing.txt') -Content "Z:\definitely-not-here-12345`n" $missingList = Write-ListFile -Path (Join-Path $script:E2ERoot 'missing.txt') -Content "Z:\definitely-not-here-12345`n"
$run = Invoke-BaknretScript -Script $script:BackupScript -Parameters @{ $run = Invoke-BakNRetScript -Script $script:BackupScript -Parameters @{
BackupListPath = $missingList BackupListPath = $missingList
BackupDir = $script:E2EBackupDir BackupDir = $script:E2EBackupDir
QuietTool = $true QuietTool = $true
} }
$run.ExitCode | Should -Be 0 $run.ExitCode | Should -Be 0
$manifest = Read-BaknretManifest -Path $script:E2EManifestPath $manifest = Read-BakNRetManifest -Path $script:E2EManifestPath
$manifest.items['definitely-not-here-12345_from_Z_'].action | Should -Be 'missing-source' $manifest.items['definitely-not-here-12345_from_Z_'].action | Should -Be 'missing-source'
} }
It '归档名重复时直接报失败(退出码 1),不静默互相覆盖' { It '归档名重复时直接报失败(退出码 1),不静默互相覆盖' {
$duplicateList = Write-ListFile -Path (Join-Path $script:E2ERoot 'dup.txt') -Content "$script:E2ESource`n$script:E2ESource`n" $duplicateList = Write-ListFile -Path (Join-Path $script:E2ERoot 'dup.txt') -Content "$script:E2ESource`n$script:E2ESource`n"
$run = Invoke-BaknretScript -Script $script:BackupScript -Parameters @{ $run = Invoke-BakNRetScript -Script $script:BackupScript -Parameters @{
BackupListPath = $duplicateList BackupListPath = $duplicateList
BackupDir = $script:E2EBackupDir BackupDir = $script:E2EBackupDir
Force = $true Force = $true
@@ -1493,7 +1493,7 @@ Describe '集成:方向标记与孤儿审计' -Skip:(-not ($script:HasSevenZip
# 正确方向:先正常备份出归档 # 正确方向:先正常备份出归档
Write-ListFile -Path $script:DirectionList -Content "$script:DirectionSource`n" | Out-Null Write-ListFile -Path $script:DirectionList -Content "$script:DirectionSource`n" | Out-Null
$null = Invoke-BaknretScript -Script $script:BackupScript -Parameters @{ $null = Invoke-BakNRetScript -Script $script:BackupScript -Parameters @{
BackupListPath = $script:DirectionList BackupListPath = $script:DirectionList
BackupDir = $script:DirectionBackupDir BackupDir = $script:DirectionBackupDir
Force = $true Force = $true
@@ -1507,7 +1507,7 @@ Describe '集成:方向标记与孤儿审计' -Skip:(-not ($script:HasSevenZip
# 行首 -:仅恢复,备份端应当跳过它,但要把它算作"有主" # 行首 -:仅恢复,备份端应当跳过它,但要把它算作"有主"
Write-ListFile -Path $script:DirectionList -Content "- $script:DirectionSource`n" | Out-Null Write-ListFile -Path $script:DirectionList -Content "- $script:DirectionSource`n" | Out-Null
$script:BackupDirectionRun = Invoke-BaknretScript -Script $script:BackupScript -Parameters @{ $script:BackupDirectionRun = Invoke-BakNRetScript -Script $script:BackupScript -Parameters @{
BackupListPath = $script:DirectionList BackupListPath = $script:DirectionList
BackupDir = $script:DirectionBackupDir BackupDir = $script:DirectionBackupDir
QuietTool = $true QuietTool = $true
@@ -1516,7 +1516,7 @@ Describe '集成:方向标记与孤儿审计' -Skip:(-not ($script:HasSevenZip
# 行首 +:仅备份,恢复端应当跳过它,同样要登记归档名 # 行首 +:仅备份,恢复端应当跳过它,同样要登记归档名
Remove-Item -LiteralPath $script:DirectionSource -Recurse -Force Remove-Item -LiteralPath $script:DirectionSource -Recurse -Force
Write-ListFile -Path $script:DirectionList -Content "+ $script:DirectionSource`n" | Out-Null Write-ListFile -Path $script:DirectionList -Content "+ $script:DirectionSource`n" | Out-Null
$script:RestoreDirectionRun = Invoke-BaknretScript -Script $script:RestoreScript -Parameters @{ $script:RestoreDirectionRun = Invoke-BakNRetScript -Script $script:RestoreScript -Parameters @{
BackupListPath = $script:DirectionList BackupListPath = $script:DirectionList
BackupDir = $script:DirectionBackupDir BackupDir = $script:DirectionBackupDir
Force = $true Force = $true
+4 -4
View File
@@ -82,8 +82,8 @@ if (-not (Test-Path -LiteralPath $restoreScript)) {
exit 1 exit 1
} }
$config = Get-BaknretConfig -Path $ConfigPath $config = Get-BakNRetConfig -Path $ConfigPath
$catalogPath = Resolve-CatalogPath -Configured $config.SoftwareCatalog -Root $projectRoot $catalogPath = Resolve-BakNRetCatalogPath -Configured $config.SoftwareCatalog -Root $projectRoot
if (-not $BackupDir) { if (-not $BackupDir) {
$BackupDir = $config.BackupDir $BackupDir = $config.BackupDir
@@ -100,7 +100,7 @@ if (-not $WorkRoot) {
} }
New-Item -ItemType Directory -Path $WorkRoot -Force | Out-Null New-Item -ItemType Directory -Path $WorkRoot -Force | Out-Null
$manifest = Read-BaknretManifest -Path (Join-Path $BackupDir 'manifest.json') $manifest = Read-BakNRetManifest -Path (Join-Path $BackupDir 'manifest.json')
$archiveFiles = @(Get-ChildItem -LiteralPath $BackupDir -File -Force -ErrorAction SilentlyContinue | $archiveFiles = @(Get-ChildItem -LiteralPath $BackupDir -File -Force -ErrorAction SilentlyContinue |
Where-Object { $_.Extension.ToLower() -in @('.7z', '.rar', '.zip', '.tar') }) Where-Object { $_.Extension.ToLower() -in @('.7z', '.rar', '.zip', '.tar') })
@@ -322,7 +322,7 @@ foreach ($name in $Entries) {
$entry = ConvertFrom-BackupListLine -Line $name $entry = ConvertFrom-BackupListLine -Line $name
if (-not $entry) { continue } if (-not $entry) { continue }
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $catalogPath -MaxDepth $config.CatalogMaxDepth $resolved = Resolve-BakNRetBackupEntry -Entry $entry -CatalogPath $catalogPath -MaxDepth $config.CatalogMaxDepth
if (-not $resolved.BaseName) { if (-not $resolved.BaseName) {
$failures += "$name :解析不出归档名" $failures += "$name :解析不出归档名"
+10 -10
View File
@@ -167,7 +167,7 @@ $manifestPath = Join-Path $backupDir 'manifest.json'
Test-Case 'manifest 记录了条目、动作、校验结果、roots 与 layouts' { Test-Case 'manifest 记录了条目、动作、校验结果、roots 与 layouts' {
Assert-FileExists $manifestPath Assert-FileExists $manifestPath
$manifest = Read-BaknretManifest -Path $manifestPath $manifest = Read-BakNRetManifest -Path $manifestPath
Assert-Equal 1 $manifest.items.Count Assert-Equal 1 $manifest.items.Count
$record = $manifest.items[$archives[0].BaseName] $record = $manifest.items[$archives[0].BaseName]
@@ -251,7 +251,7 @@ $cleanExitCode = $LASTEXITCODE
$cleanArchive = Get-ChildItem -LiteralPath $lockBackupDir -File -Filter *.7z | Select-Object -First 1 $cleanArchive = Get-ChildItem -LiteralPath $lockBackupDir -File -Filter *.7z | Select-Object -First 1
$cleanSize = $cleanArchive.Length $cleanSize = $cleanArchive.Length
$lockManifestPath = Join-Path $lockBackupDir 'manifest.json' $lockManifestPath = Join-Path $lockBackupDir 'manifest.json'
$cleanRecord = (Read-BaknretManifest -Path $lockManifestPath).items[$cleanArchive.BaseName] $cleanRecord = (Read-BakNRetManifest -Path $lockManifestPath).items[$cleanArchive.BaseName]
Test-Case '干净归档:退出码 0,manifest 记 warnings=false' { Test-Case '干净归档:退出码 0,manifest 记 warnings=false' {
Assert-Equal 0 $cleanExitCode Assert-Equal 0 $cleanExitCode
@@ -269,7 +269,7 @@ try {
& $backupScript -BackupListPath $lockList -BackupDir $lockBackupDir -ConfigPath $cfg2 -Force -QuietTool & $backupScript -BackupListPath $lockList -BackupDir $lockBackupDir -ConfigPath $cfg2 -Force -QuietTool
$warnExitCode = $LASTEXITCODE $warnExitCode = $LASTEXITCODE
$afterArchive = Get-ChildItem -LiteralPath $lockBackupDir -File -Filter *.7z | Select-Object -First 1 $afterArchive = Get-ChildItem -LiteralPath $lockBackupDir -File -Filter *.7z | Select-Object -First 1
$afterRecord = (Read-BaknretManifest -Path $lockManifestPath).items[$afterArchive.BaseName] $afterRecord = (Read-BakNRetManifest -Path $lockManifestPath).items[$afterArchive.BaseName]
Test-Case '有警告时拒绝覆盖完整归档:退出码 1,旧归档原样保留' { Test-Case '有警告时拒绝覆盖完整归档:退出码 1,旧归档原样保留' {
Assert-Equal 1 $warnExitCode Assert-Equal 1 $warnExitCode
@@ -282,7 +282,7 @@ try {
# 明确接受之后才允许覆盖 # 明确接受之后才允许覆盖
& $backupScript -BackupListPath $lockList -BackupDir $lockBackupDir -ConfigPath $cfg2 -Force -QuietTool -AcceptWarnings & $backupScript -BackupListPath $lockList -BackupDir $lockBackupDir -ConfigPath $cfg2 -Force -QuietTool -AcceptWarnings
$acceptExitCode = $LASTEXITCODE $acceptExitCode = $LASTEXITCODE
$acceptedRecord = (Read-BaknretManifest -Path $lockManifestPath).items[$afterArchive.BaseName] $acceptedRecord = (Read-BakNRetManifest -Path $lockManifestPath).items[$afterArchive.BaseName]
Test-Case '-AcceptWarnings 允许覆盖,并在 manifest 记 warnings=true' { Test-Case '-AcceptWarnings 允许覆盖,并在 manifest 记 warnings=true' {
Assert-Equal 0 $acceptExitCode Assert-Equal 0 $acceptExitCode
@@ -357,7 +357,7 @@ Test-Case '名录条目:一个软件一个归档,归档名 = 软件名;独
} }
Test-Case 'manifest.roots 列出各归档项的顶层名,layouts 标出 dir / file' { Test-Case 'manifest.roots 列出各归档项的顶层名,layouts 标出 dir / file' {
$record = (Read-BaknretManifest -Path (Join-Path $catBackupDir 'manifest.json')).items['my-app'] $record = (Read-BakNRetManifest -Path (Join-Path $catBackupDir 'manifest.json')).items['my-app']
Assert-True ($null -ne $record) Assert-True ($null -ne $record)
$roots = @($record.roots | Sort-Object) $roots = @($record.roots | Sort-Object)
Assert-Equal 3 $roots.Count Assert-Equal 3 $roots.Count
@@ -438,7 +438,7 @@ Test-Case '@pathname 覆盖:用名录里的真实路径跑命名算法(独
[System.IO.File]::WriteAllText($pathList, "my-app @pathname :+ Modules:$settingsFile`n", [System.Text.UTF8Encoding]::new($true)) [System.IO.File]::WriteAllText($pathList, "my-app @pathname :+ Modules:$settingsFile`n", [System.Text.UTF8Encoding]::new($true))
# settings.json 刚才被删了,重建一份,让 Include 的宿主机路径存在 # settings.json 刚才被删了,重建一份,让 Include 的宿主机路径存在
Set-Content -LiteralPath $settingsFile -Value '{"slot":"file"}' -Encoding UTF8 Set-Content -LiteralPath $settingsFile -Value '{"slot":"file"}' -Encoding UTF8
$expectedBase = Get-BackupBaseName -RawPath $dirA $expectedBase = Get-BakNRetBackupBaseName -RawPath $dirA
& $backupScript -BackupListPath $pathList -BackupDir $pathNameDir -ConfigPath $catConfig -Force -QuietTool & $backupScript -BackupListPath $pathList -BackupDir $pathNameDir -ConfigPath $catConfig -Force -QuietTool
Assert-Equal 0 $LASTEXITCODE Assert-Equal 0 $LASTEXITCODE
Assert-FileExists (Join-Path $pathNameDir ($expectedBase + '.7z')) Assert-FileExists (Join-Path $pathNameDir ($expectedBase + '.7z'))
@@ -578,7 +578,7 @@ if ($sevenZip) {
[System.IO.File]::WriteAllText($legacyList, "legacy-file`n", [System.Text.UTF8Encoding]::new($true)) [System.IO.File]::WriteAllText($legacyList, "legacy-file`n", [System.Text.UTF8Encoding]::new($true))
New-E2EConfig -Path $legacyConfig -LogDir $legacyLogDir -SoftwareCatalog $legacyCatalog New-E2EConfig -Path $legacyConfig -LogDir $legacyLogDir -SoftwareCatalog $legacyCatalog
$legacyManifest = Read-BaknretManifest -Path (Join-Path $legacyBackupDir 'manifest.json') $legacyManifest = Read-BakNRetManifest -Path (Join-Path $legacyBackupDir 'manifest.json')
$legacyManifest.items['legacy-file'] = [ordered]@{ $legacyManifest.items['legacy-file'] = [ordered]@{
baseName = 'legacy-file' baseName = 'legacy-file'
source = 'legacy-file' source = 'legacy-file'
@@ -586,7 +586,7 @@ $legacyManifest.items['legacy-file'] = [ordered]@{
action = 'backed-up' action = 'backed-up'
encrypted = $false encrypted = $false
} }
Write-BaknretManifest -Path (Join-Path $legacyBackupDir 'manifest.json') -Manifest $legacyManifest | Out-Null Write-BakNRetManifest -Path (Join-Path $legacyBackupDir 'manifest.json') -Manifest $legacyManifest | Out-Null
# 让"恢复确实做了事"可验证:把活文件改成别的内容,恢复后应回到归档里的内容 # 让"恢复确实做了事"可验证:把活文件改成别的内容,恢复后应回到归档里的内容
Set-Content -LiteralPath $legacyLive -Value '{"version":"changed-after-backup"}' -Encoding UTF8 Set-Content -LiteralPath $legacyLive -Value '{"version":"changed-after-backup"}' -Encoding UTF8
@@ -646,7 +646,7 @@ Test-Case '字面路径不受名录影响,仍走路径命名' {
[System.IO.File]::WriteAllText($literalList, "$dirA2`n", [System.Text.UTF8Encoding]::new($true)) [System.IO.File]::WriteAllText($literalList, "$dirA2`n", [System.Text.UTF8Encoding]::new($true))
& $backupScript -BackupListPath $literalList -BackupDir $literalDir -ConfigPath $catConfig -Force -QuietTool & $backupScript -BackupListPath $literalList -BackupDir $literalDir -ConfigPath $catConfig -Force -QuietTool
Assert-Equal 0 $LASTEXITCODE Assert-Equal 0 $LASTEXITCODE
Assert-FileExists (Join-Path $literalDir ((Get-BackupBaseName -RawPath $dirA2) + '.7z')) Assert-FileExists (Join-Path $literalDir ((Get-BakNRetBackupBaseName -RawPath $dirA2) + '.7z'))
} }
$missingList = Join-Path $WorkRoot 'missing.txt' $missingList = Join-Path $WorkRoot 'missing.txt'
@@ -656,7 +656,7 @@ $missingExitCode = $LASTEXITCODE
Test-Case '源路径不存在被记为 missing-source,退出码仍为 0(跳过不算失败)' { Test-Case '源路径不存在被记为 missing-source,退出码仍为 0(跳过不算失败)' {
Assert-Equal 0 $missingExitCode Assert-Equal 0 $missingExitCode
$after = Read-BaknretManifest -Path $manifestPath $after = Read-BakNRetManifest -Path $manifestPath
$record = $after.items['definitely-not-here-12345_from_Z_'] $record = $after.items['definitely-not-here-12345_from_Z_']
Assert-True ($null -ne $record) 'manifest 里应有该条目的记录' Assert-True ($null -ne $record) 'manifest 里应有该条目的记录'
Assert-Equal 'missing-source' $record.action Assert-Equal 'missing-source' $record.action
+6 -6
View File
@@ -58,13 +58,13 @@ if (-not (Test-Path -LiteralPath $manifestPath)) {
exit 0 exit 0
} }
$config = Get-BaknretConfig -Path $configPath $config = Get-BakNRetConfig -Path $configPath
$catalogPath = Resolve-CatalogPath -Configured $config.SoftwareCatalog -Root $projectRoot $catalogPath = Resolve-BakNRetCatalogPath -Configured $config.SoftwareCatalog -Root $projectRoot
$listLines = @(Get-Content -Encoding UTF8 -LiteralPath $listPath) $listLines = @(Get-Content -Encoding UTF8 -LiteralPath $listPath)
# 用 cmd 批次文件把子进程输出落盘再读回。为什么不用 `& pwsh ... 2>&1` 直接捕获: # 用 cmd 批次文件把子进程输出落盘再读回。为什么不用 `& pwsh ... 2>&1` 直接捕获:
# 那会给子进程建管道,在受限环境 / 沙箱里会被直接拒绝(见 tests\BakNRet.Tests.ps1 里的同款注释)。 # 那会给子进程建管道,在受限环境 / 沙箱里会被直接拒绝(见 tests\BakNRet.Tests.ps1 里的同款注释)。
function Invoke-BaknretCaptured { function Invoke-BakNRetCaptured {
param([string]$Script, [string[]]$Arguments) param([string]$Script, [string[]]$Arguments)
$hostExe = if ($PSVersionTable.PSEdition -eq 'Core') { 'pwsh' } else { 'powershell' } $hostExe = if ($PSVersionTable.PSEdition -eq 'Core') { 'pwsh' } else { 'powershell' }
@@ -130,7 +130,7 @@ Test-Case '真实清单里每个条目都能解析出方向,且软件名都能
if ($item.Direction -eq 'backup') { $backupOnly++ } if ($item.Direction -eq 'backup') { $backupOnly++ }
if ($item.Direction -eq 'restore') { $restoreOnly++ } if ($item.Direction -eq 'restore') { $restoreOnly++ }
$resolved = Resolve-BackupEntry -Entry $item -CatalogPath $catalogPath -MaxDepth $config.CatalogMaxDepth $resolved = Resolve-BakNRetBackupEntry -Entry $item -CatalogPath $catalogPath -MaxDepth $config.CatalogMaxDepth
if ($resolved.Error -and $resolved.Error -like '*名录里没有*') { $catalogMiss += $resolved.Error } if ($resolved.Error -and $resolved.Error -like '*名录里没有*') { $catalogMiss += $resolved.Error }
} }
@@ -144,7 +144,7 @@ Write-Host "`n== 只读模式不写一个字节 ==" -ForegroundColor Cyan
Test-Case 'Backup.ps1 -DryRun 退出 0、不再有"名录里没有"告警、manifest 不变' { Test-Case 'Backup.ps1 -DryRun 退出 0、不再有"名录里没有"告警、manifest 不变' {
$before = Get-ManifestHash $before = Get-ManifestHash
$arguments = @('-BackupListPath', $listPath, '-DryRun', '-QuietTool', '-Only') + $SampleEntries $arguments = @('-BackupListPath', $listPath, '-DryRun', '-QuietTool', '-Only') + $SampleEntries
$run = Invoke-BaknretCaptured -Script $backupScript -Arguments $arguments $run = Invoke-BakNRetCaptured -Script $backupScript -Arguments $arguments
Assert-Equal 0 $run.ExitCode ("DryRun 退出码不是 0;输出尾部:" + ($run.Output -split "`n" | Select-Object -Last 4 | Out-String)) Assert-Equal 0 $run.ExitCode ("DryRun 退出码不是 0;输出尾部:" + ($run.Output -split "`n" | Select-Object -Last 4 | Out-String))
Assert-False ($run.Output -match "名录里没有 '") "DryRun 仍然把某些条目当成查不到的软件名" Assert-False ($run.Output -match "名录里没有 '") "DryRun 仍然把某些条目当成查不到的软件名"
@@ -153,7 +153,7 @@ Test-Case 'Backup.ps1 -DryRun 退出 0、不再有"名录里没有"告警、mani
Test-Case 'Restore.ps1 -VerifyOnly 退出 0、把行首 + 识别成"仅备份"、manifest 不变' { Test-Case 'Restore.ps1 -VerifyOnly 退出 0、把行首 + 识别成"仅备份"、manifest 不变' {
$before = Get-ManifestHash $before = Get-ManifestHash
$run = Invoke-BaknretCaptured -Script $restoreScript -Arguments @( $run = Invoke-BakNRetCaptured -Script $restoreScript -Arguments @(
'-BackupListPath', $listPath, '-VerifyOnly', '-Only', $RestoreSample, '-Verbose') '-BackupListPath', $listPath, '-VerifyOnly', '-Only', $RestoreSample, '-Verbose')
Assert-Equal 0 $run.ExitCode ("VerifyOnly 退出码不是 0;输出尾部:" + ($run.Output -split "`n" | Select-Object -Last 4 | Out-String)) Assert-Equal 0 $run.ExitCode ("VerifyOnly 退出码不是 0;输出尾部:" + ($run.Output -split "`n" | Select-Object -Last 4 | Out-String))
+166 -166
View File
@@ -13,7 +13,7 @@
* 软件名录新结构(`软件名 -> Slot -> @{ Path = ... }`):Slot 排序、前缀补全、 * 软件名录新结构(`软件名 -> Slot -> @{ Path = ... }`):Slot 排序、前缀补全、
多候选报错、`%VAR%` / `$( ... )` 展开、缓存与 -NoCache 多候选报错、`%VAR%` / `$( ... )` 展开、缓存与 -NoCache
* 归档命名与逆向解析(软件名 / 字面路径 / @pathname) * 归档命名与逆向解析(软件名 / 字面路径 / @pathname)
* 归档项与归档内布局(New-BaknretArchiveItem / 暂存 junction / 硬链接) * 归档项与归档内布局(New-BakNRetArchiveItem / 暂存 junction / 硬链接)
* 7z 排除参数翻译(!前缀、!re: 正则展开、Slot 前缀分配、去重) * 7z 排除参数翻译(!前缀、!re: 正则展开、Slot 前缀分配、去重)
* Windows 命令行拼接 * Windows 命令行拼接
* manifest 读写往返与 layouts * manifest 读写往返与 layouts
@@ -271,18 +271,18 @@ Test-Case '标记必须是独立记号:C:\a:-b 仍然是一个路径' {
$r = ConvertFrom-BackupListLine -Line 'C:\a:-b' $r = ConvertFrom-BackupListLine -Line 'C:\a:-b'
Assert-Equal 'C:\a:-b' $r.Path Assert-Equal 'C:\a:-b' $r.Path
Assert-Equal 0 $r.Overrides.Keys.Count Assert-Equal 0 $r.Overrides.Keys.Count
Assert-Null (Test-BaknretMarker -Token 'C:\a:-b') Assert-Null (Test-BakNRetMarker -Token 'C:\a:-b')
Assert-Null (Test-BaknretMarker -Token ':+X') Assert-Null (Test-BakNRetMarker -Token ':+X')
} }
Test-Case 'Test-BaknretMarker 识别全部标记(大小写敏感)' { Test-Case 'Test-BakNRetMarker 识别全部标记(大小写敏感)' {
Assert-Equal 'path' (Test-BaknretMarker -Token '::') Assert-Equal 'path' (Test-BakNRetMarker -Token '::')
Assert-Equal 'exclude' (Test-BaknretMarker -Token ':-') Assert-Equal 'exclude' (Test-BakNRetMarker -Token ':-')
Assert-Equal 'include' (Test-BaknretMarker -Token ':+') Assert-Equal 'include' (Test-BakNRetMarker -Token ':+')
Assert-Equal 'encrypt' (Test-BaknretMarker -Token ':encrypt') Assert-Equal 'encrypt' (Test-BakNRetMarker -Token ':encrypt')
Assert-Equal 'noencrypt' (Test-BaknretMarker -Token ':!encrypt') Assert-Equal 'noencrypt' (Test-BakNRetMarker -Token ':!encrypt')
Assert-Equal 'at' (Test-BaknretMarker -Token '@x') Assert-Equal 'at' (Test-BakNRetMarker -Token '@x')
Assert-Equal 'path' (Test-BaknretMarker -Token ' :: ') Assert-Equal 'path' (Test-BakNRetMarker -Token ' :: ')
} }
Test-Case '记号里有空格时目标可以带空格(第一个修饰符之前全是目标)' { Test-Case '记号里有空格时目标可以带空格(第一个修饰符之前全是目标)' {
@@ -308,8 +308,8 @@ Test-Case '没有说明时 Comment 为 $null' {
Assert-Null (ConvertFrom-BackupListLine -Line 'App').Comment Assert-Null (ConvertFrom-BackupListLine -Line 'App').Comment
} }
Test-Case 'Split-BaknretToken:引号内的空白不切分,引号保留' { Test-Case 'Split-BakNRetToken:引号内的空白不切分,引号保留' {
$tokens = @(Split-BaknretToken -Text 'a "b c" d :- ''e,f''') $tokens = @(Split-BakNRetToken -Text 'a "b c" d :- ''e,f''')
Assert-Equal 5 $tokens.Count Assert-Equal 5 $tokens.Count
Assert-Equal 'a' $tokens[0] Assert-Equal 'a' $tokens[0]
Assert-Equal '"b c"' $tokens[1] Assert-Equal '"b c"' $tokens[1]
@@ -318,19 +318,19 @@ Test-Case 'Split-BaknretToken:引号内的空白不切分,引号保留' {
Assert-Equal "'e,f'" $tokens[4] Assert-Equal "'e,f'" $tokens[4]
} }
Test-Case 'Remove-BaknretQuote:成对引号去掉,不成对原样返回' { Test-Case 'Remove-BakNRetQuote:成对引号去掉,不成对原样返回' {
Assert-Equal 'a' (Remove-BaknretQuote -Text "'a'") Assert-Equal 'a' (Remove-BakNRetQuote -Text "'a'")
Assert-Equal 'a' (Remove-BaknretQuote -Text '"a"') Assert-Equal 'a' (Remove-BakNRetQuote -Text '"a"')
Assert-Equal '"a' (Remove-BaknretQuote -Text '"a') Assert-Equal '"a' (Remove-BakNRetQuote -Text '"a')
Assert-Equal 'plain' (Remove-BaknretQuote -Text 'plain') Assert-Equal 'plain' (Remove-BakNRetQuote -Text 'plain')
} }
Test-Case 'Test-LiteralPath:带分隔符或 % 的按路径,其余按软件名' { Test-Case 'Test-BakNRetLiteralPath:带分隔符或 % 的按路径,其余按软件名' {
Assert-True (Test-LiteralPath -Path 'C:\a') Assert-True (Test-BakNRetLiteralPath -Path 'C:\a')
Assert-True (Test-LiteralPath -Path 'C:/a') Assert-True (Test-BakNRetLiteralPath -Path 'C:/a')
Assert-True (Test-LiteralPath -Path '%UserProfile%\.ssh') Assert-True (Test-BakNRetLiteralPath -Path '%UserProfile%\.ssh')
Assert-False (Test-LiteralPath -Path 'MyApp') Assert-False (Test-BakNRetLiteralPath -Path 'MyApp')
Assert-True (Test-LiteralPath -Path '') Assert-True (Test-BakNRetLiteralPath -Path '')
} }
# ============================================================================ # ============================================================================
@@ -338,19 +338,19 @@ Write-Host "`n== 归档命名 ==" -ForegroundColor Cyan
# ============================================================================ # ============================================================================
Test-Case '基础命名规则' { Test-Case '基础命名规则' {
Assert-Equal 'legendary_from_%UserProfile%+.config' (Get-BackupBaseName -RawPath '%UserProfile%/.config/legendary') Assert-Equal 'legendary_from_%UserProfile%+.config' (Get-BakNRetBackupBaseName -RawPath '%UserProfile%/.config/legendary')
Assert-Equal 'FooClolor_from_C_+Programs' (Get-BackupBaseName -RawPath 'C:\Programs\FooClolor') Assert-Equal 'FooClolor_from_C_+Programs' (Get-BakNRetBackupBaseName -RawPath 'C:\Programs\FooClolor')
Assert-Equal 'settings.json_from_C_+Programs' (Get-BackupBaseName -RawPath 'C:\Programs\settings.json') Assert-Equal 'settings.json_from_C_+Programs' (Get-BakNRetBackupBaseName -RawPath 'C:\Programs\settings.json')
} }
Test-Case '盘符冒号被归一化成下划线' { Test-Case '盘符冒号被归一化成下划线' {
Assert-Equal 'a_from_C_' (Get-BackupBaseName -RawPath 'C:\a') Assert-Equal 'a_from_C_' (Get-BakNRetBackupBaseName -RawPath 'C:\a')
} }
Test-Case '/ 与 \ 以及重复分隔符结果一致' { Test-Case '/ 与 \ 以及重复分隔符结果一致' {
$a = Get-BackupBaseName -RawPath '%UserProfile%/.config/scoop' $a = Get-BakNRetBackupBaseName -RawPath '%UserProfile%/.config/scoop'
$b = Get-BackupBaseName -RawPath '%UserProfile%\.config\scoop' $b = Get-BakNRetBackupBaseName -RawPath '%UserProfile%\.config\scoop'
$c = Get-BackupBaseName -RawPath '%UserProfile%\\Documents\\WindowsPowerShell' $c = Get-BakNRetBackupBaseName -RawPath '%UserProfile%\\Documents\\WindowsPowerShell'
Assert-Equal $a $b Assert-Equal $a $b
Assert-Equal 'WindowsPowerShell_from_%UserProfile%+Documents' $c Assert-Equal 'WindowsPowerShell_from_%UserProfile%+Documents' $c
} }
@@ -362,26 +362,26 @@ Test-Case '命名 <-> 路径往返' {
'%LocalAppData%\Microsoft\Edge\User Data' '%LocalAppData%\Microsoft\Edge\User Data'
) )
foreach ($case in $cases) { foreach ($case in $cases) {
$base = Get-BackupBaseName -RawPath $case $base = Get-BakNRetBackupBaseName -RawPath $case
$back = Convert-BackupFileNameToPath -FileName "$base.7z" $back = Convert-BakNRetBackupFileNameToPath -FileName "$base.7z"
Assert-Equal ($case -replace '[/\\]+', '\') $back "往返 $case" Assert-Equal ($case -replace '[/\\]+', '\') $back "往返 $case"
} }
} }
Test-Case '归档名里不含非法文件名字符(保留 & % +)' { Test-Case '归档名里不含非法文件名字符(保留 & % +)' {
$base = Get-BackupBaseName -RawPath 'C:\a\b?c*d|e' $base = Get-BakNRetBackupBaseName -RawPath 'C:\a\b?c*d|e'
$invalid = [System.IO.Path]::GetInvalidFileNameChars() | Where-Object { $_ -notin @('&', '%', '+') } $invalid = [System.IO.Path]::GetInvalidFileNameChars() | Where-Object { $_ -notin @('&', '%', '+') }
foreach ($ch in $invalid) { foreach ($ch in $invalid) {
Assert-False ($base.Contains([string]$ch)) "归档名里出现了非法字符 $ch" Assert-False ($base.Contains([string]$ch)) "归档名里出现了非法字符 $ch"
} }
Assert-Equal 'keep%and&and+plus' (Format-CatalogName -Name 'keep%and&and+plus') Assert-Equal 'keep%and&and+plus' (Format-BakNRetCatalogName -Name 'keep%and&and+plus')
} }
Test-Case 'Get-BaknretArchiveTopName:取归档内相对路径的第一段' { Test-Case 'Get-BakNRetArchiveTopName:取归档内相对路径的第一段' {
Assert-Equal 'Slot' (Get-BaknretArchiveTopName -ArchivePath 'Slot\a\b') Assert-Equal 'Slot' (Get-BakNRetArchiveTopName -ArchivePath 'Slot\a\b')
Assert-Equal 'Slot' (Get-BaknretArchiveTopName -ArchivePath '\Slot\') Assert-Equal 'Slot' (Get-BakNRetArchiveTopName -ArchivePath '\Slot\')
Assert-Equal 'leaf' (Get-BaknretArchiveTopName -ArchivePath 'leaf') Assert-Equal 'leaf' (Get-BakNRetArchiveTopName -ArchivePath 'leaf')
Assert-Equal '' (Get-BaknretArchiveTopName -ArchivePath ' ') Assert-Equal '' (Get-BakNRetArchiveTopName -ArchivePath ' ')
} }
# ============================================================================ # ============================================================================
@@ -390,8 +390,8 @@ Write-Host "`n== 归档项构造与暂存 ==" -ForegroundColor Cyan
$itemSandbox = Join-Path $env:TEMP ("baknret-item-" + [guid]::NewGuid().ToString('N').Substring(0, 8)) $itemSandbox = Join-Path $env:TEMP ("baknret-item-" + [guid]::NewGuid().ToString('N').Substring(0, 8))
Test-Case 'New-BaknretArchiveItem:归档内路径被清理,TopName 是第一段' { Test-Case 'New-BakNRetArchiveItem:归档内路径被清理,TopName 是第一段' {
$item = New-BaknretArchiveItem -ArchivePath '\AlphaData\' -RealPath 'C:\x' $item = New-BakNRetArchiveItem -ArchivePath '\AlphaData\' -RealPath 'C:\x'
Assert-Equal 'AlphaData' $item.ArchivePath Assert-Equal 'AlphaData' $item.ArchivePath
Assert-Equal 'AlphaData' $item.TopName Assert-Equal 'AlphaData' $item.TopName
Assert-Equal 'slot' $item.Kind Assert-Equal 'slot' $item.Kind
@@ -400,13 +400,13 @@ Test-Case 'New-BaknretArchiveItem:归档内路径被清理,TopName 是第一
Assert-False $item.IsFile Assert-False $item.IsFile
Assert-Equal 0 $item.Exclude.Count Assert-Equal 0 $item.Exclude.Count
$nested = New-BaknretArchiveItem -ArchivePath 'Alpha\sub' -RealPath 'C:\y' -Kind 'include' -Origin 'include' $nested = New-BakNRetArchiveItem -ArchivePath 'Alpha\sub' -RealPath 'C:\y' -Kind 'include' -Origin 'include'
Assert-Equal 'Alpha' $nested.TopName Assert-Equal 'Alpha' $nested.TopName
Assert-Equal 'include' $nested.Kind Assert-Equal 'include' $nested.Kind
} }
Test-Case '归档项的属性集与契约一致(没有旧的 Sources / Dirs / Variants)' { Test-Case '归档项的属性集与契约一致(没有旧的 Sources / Dirs / Variants)' {
$item = New-BaknretArchiveItem -ArchivePath 'A' -RealPath 'C:\a' $item = New-BakNRetArchiveItem -ArchivePath 'A' -RealPath 'C:\a'
foreach ($expected in 'ArchivePath', 'TopName', 'RealPath', 'Kind', 'Slot', 'Description', 'Origin', 'Exists', 'IsFile', 'Exclude') { foreach ($expected in 'ArchivePath', 'TopName', 'RealPath', 'Kind', 'Slot', 'Description', 'Origin', 'Exists', 'IsFile', 'Exclude') {
Assert-True ($item.PSObject.Properties.Name -contains $expected) "缺少属性 $expected" Assert-True ($item.PSObject.Properties.Name -contains $expected) "缺少属性 $expected"
} }
@@ -428,13 +428,13 @@ Test-Case '暂存半途失败会自己清干净(不留指向真实数据的 ju
$items = @( $items = @(
# 第一项会成功挂上,而且是**目录走 junction** —— 这正是危险的那个物件:它指向 # 第一项会成功挂上,而且是**目录走 junction** —— 这正是危险的那个物件:它指向
# 真实数据,而临时目录迟早会被某次 Remove-Item -Recurse 扫到。 # 真实数据,而临时目录迟早会被某次 Remove-Item -Recurse 扫到。
(New-BaknretArchiveItem -ArchivePath 'Good' -RealPath $realDir -Kind 'slot' -Slot 'Good' -Exists $true -IsFile $false) (New-BakNRetArchiveItem -ArchivePath 'Good' -RealPath $realDir -Kind 'slot' -Slot 'Good' -Exists $true -IsFile $false)
# 第二项必然失败:源文件不存在,硬链接与复制都会失败 # 第二项必然失败:源文件不存在,硬链接与复制都会失败
(New-BaknretArchiveItem -ArchivePath 'Missing.txt' -RealPath (Join-Path $base 'does-not-exist.txt') -Kind 'slot' -Slot 'Missing' -Exists $false -IsFile $true) (New-BakNRetArchiveItem -ArchivePath 'Missing.txt' -RealPath (Join-Path $base 'does-not-exist.txt') -Kind 'slot' -Slot 'Missing' -Exists $false -IsFile $true)
) )
$threw = $false $threw = $false
try { New-BaknretArchiveStaging -Items $items -Root (Join-Path $sandbox 'stage') | Out-Null } catch { $threw = $true } try { New-BakNRetArchiveStaging -Items $items -Root (Join-Path $sandbox 'stage') | Out-Null } catch { $threw = $true }
Assert-True $threw '第二项应当抛错(源文件不存在,硬链接与复制都会失败)' Assert-True $threw '第二项应当抛错(源文件不存在,硬链接与复制都会失败)'
$left = @(Get-ChildItem -LiteralPath $sandbox -Recurse -Force -ErrorAction SilentlyContinue) $left = @(Get-ChildItem -LiteralPath $sandbox -Recurse -Force -ErrorAction SilentlyContinue)
@@ -445,18 +445,18 @@ Test-Case '暂存半途失败会自己清干净(不留指向真实数据的 ju
Remove-Item -LiteralPath $base -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $base -Recurse -Force -ErrorAction SilentlyContinue
} }
Test-Case 'New-BaknretArchiveStaging:目录走 junction、文件走硬链接,按归档内名字挂载' { Test-Case 'New-BakNRetArchiveStaging:目录走 junction、文件走硬链接,按归档内名字挂载' {
$root = Join-Path $itemSandbox 'stage-src' $root = Join-Path $itemSandbox 'stage-src'
New-Item -ItemType Directory -Path (Join-Path $root 'App') -Force | Out-Null New-Item -ItemType Directory -Path (Join-Path $root 'App') -Force | Out-Null
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $root 'App\f.txt') -Value 'x' Set-Content -Encoding UTF8 -LiteralPath (Join-Path $root 'App\f.txt') -Value 'x'
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $root 'settings.json') -Value '{}' Set-Content -Encoding UTF8 -LiteralPath (Join-Path $root 'settings.json') -Value '{}'
$items = @( $items = @(
(New-BaknretArchiveItem -ArchivePath 'AlphaData' -RealPath (Join-Path $root 'App') -Exists $true), (New-BakNRetArchiveItem -ArchivePath 'AlphaData' -RealPath (Join-Path $root 'App') -Exists $true),
(New-BaknretArchiveItem -ArchivePath 'BetaFile' -RealPath (Join-Path $root 'settings.json') -Exists $true -IsFile $true) (New-BakNRetArchiveItem -ArchivePath 'BetaFile' -RealPath (Join-Path $root 'settings.json') -Exists $true -IsFile $true)
) )
$staging = New-BaknretArchiveStaging -Items $items -Root (Join-Path $itemSandbox 'stage') $staging = New-BakNRetArchiveStaging -Items $items -Root (Join-Path $itemSandbox 'stage')
try { try {
$dirLink = Get-Item -LiteralPath (Join-Path $staging 'AlphaData') -Force $dirLink = Get-Item -LiteralPath (Join-Path $staging 'AlphaData') -Force
$fileLink = Get-Item -LiteralPath (Join-Path $staging 'BetaFile') -Force $fileLink = Get-Item -LiteralPath (Join-Path $staging 'BetaFile') -Force
@@ -465,44 +465,44 @@ Test-Case 'New-BaknretArchiveStaging:目录走 junction、文件走硬链接
Assert-True (Test-Path -LiteralPath (Join-Path $staging 'AlphaData\f.txt')) '应能穿过 junction 看到内容' Assert-True (Test-Path -LiteralPath (Join-Path $staging 'AlphaData\f.txt')) '应能穿过 junction 看到内容'
} }
finally { finally {
Remove-BaknretArchiveStaging -Root $staging Remove-BakNRetArchiveStaging -Root $staging
} }
Assert-False (Test-Path -LiteralPath $staging) '暂存目录应被清理' Assert-False (Test-Path -LiteralPath $staging) '暂存目录应被清理'
Assert-True (Test-Path -LiteralPath (Join-Path $root 'App\f.txt')) '清理暂存绝不能删到真实数据' Assert-True (Test-Path -LiteralPath (Join-Path $root 'App\f.txt')) '清理暂存绝不能删到真实数据'
} }
Test-Case 'New-BaknretJunction / Remove-BaknretJunction:只删连接点,不动目标' { Test-Case 'New-BakNRetJunction / Remove-BakNRetJunction:只删连接点,不动目标' {
$target = Join-Path $itemSandbox 'j-target' $target = Join-Path $itemSandbox 'j-target'
$link = Join-Path $itemSandbox 'j-link' $link = Join-Path $itemSandbox 'j-link'
New-Item -ItemType Directory -Path $target -Force | Out-Null New-Item -ItemType Directory -Path $target -Force | Out-Null
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $target 'keep.txt') -Value 'keep' Set-Content -Encoding UTF8 -LiteralPath (Join-Path $target 'keep.txt') -Value 'keep'
New-BaknretJunction -Path $link -Target $target | Out-Null New-BakNRetJunction -Path $link -Target $target | Out-Null
Assert-Equal 'Junction' (Get-Item -LiteralPath $link -Force).LinkType Assert-Equal 'Junction' (Get-Item -LiteralPath $link -Force).LinkType
Remove-BaknretJunction -Path $link Remove-BakNRetJunction -Path $link
Assert-False (Test-Path -LiteralPath $link) Assert-False (Test-Path -LiteralPath $link)
Assert-True (Test-Path -LiteralPath (Join-Path $target 'keep.txt')) '目标内容必须留着' Assert-True (Test-Path -LiteralPath (Join-Path $target 'keep.txt')) '目标内容必须留着'
try { try {
New-BaknretJunction -Path $link -Target $target | Out-Null New-BakNRetJunction -Path $link -Target $target | Out-Null
Remove-BaknretJunction -Path $link Remove-BakNRetJunction -Path $link
New-BaknretJunction -Path $link -Target $target | Out-Null New-BakNRetJunction -Path $link -Target $target | Out-Null
Assert-True $true Assert-True $true
} }
finally { finally {
Remove-BaknretJunction -Path $link Remove-BakNRetJunction -Path $link
} }
} }
Test-Case 'New-BaknretJunction:目标已存在时明确抛错' { Test-Case 'New-BakNRetJunction:目标已存在时明确抛错' {
$target = Join-Path $itemSandbox 'j2-target' $target = Join-Path $itemSandbox 'j2-target'
$link = Join-Path $itemSandbox 'j2-link' $link = Join-Path $itemSandbox 'j2-link'
New-Item -ItemType Directory -Path $target -Force | Out-Null New-Item -ItemType Directory -Path $target -Force | Out-Null
New-Item -ItemType Directory -Path $link -Force | Out-Null New-Item -ItemType Directory -Path $link -Force | Out-Null
$threw = $false $threw = $false
try { New-BaknretJunction -Path $link -Target $target | Out-Null } catch { $threw = $true } try { New-BakNRetJunction -Path $link -Target $target | Out-Null } catch { $threw = $true }
Assert-True $threw '同名实体存在时必须抛错,不能静默替换' Assert-True $threw '同名实体存在时必须抛错,不能静默替换'
} }
@@ -515,35 +515,35 @@ Test-Case '旧的 Get-ArchiveExcludeArgument 已经不存在(契约破坏点
} }
Test-Case '相对模式自动补上归档项名(锚定在归档根)' { Test-Case '相对模式自动补上归档项名(锚定在归档根)' {
$item = New-BaknretArchiveItem -ArchivePath 'March7thAssistant' -RealPath 'C:\x' $item = New-BakNRetArchiveItem -ArchivePath 'March7thAssistant' -RealPath 'C:\x'
$exclude = Get-BaknretExcludeArgument -Item $item -Patterns @('logs\') $exclude = Get-BakNRetExcludeArgument -Item $item -Patterns @('logs\')
Assert-Equal 1 $exclude.Arguments.Count Assert-Equal 1 $exclude.Arguments.Count
Assert-Equal '-x!March7thAssistant\logs' $exclude.Arguments[0] Assert-Equal '-x!March7thAssistant\logs' $exclude.Arguments[0]
Assert-Null $exclude.Error Assert-Null $exclude.Error
} }
Test-Case '模式里再写一遍归档项名不会被当成前缀剥掉(新语义:总是拼在项名之后)' { Test-Case '模式里再写一遍归档项名不会被当成前缀剥掉(新语义:总是拼在项名之后)' {
$item = New-BaknretArchiveItem -ArchivePath 'March7thAssistant' -RealPath 'C:\x' $item = New-BakNRetArchiveItem -ArchivePath 'March7thAssistant' -RealPath 'C:\x'
$exclude = Get-BaknretExcludeArgument -Item $item -Patterns @('March7thAssistant\logs\') $exclude = Get-BakNRetExcludeArgument -Item $item -Patterns @('March7thAssistant\logs\')
Assert-Equal '-x!March7thAssistant\March7thAssistant\logs' $exclude.Arguments[0] Assert-Equal '-x!March7thAssistant\March7thAssistant\logs' $exclude.Arguments[0]
} }
Test-Case '! 前缀翻译成任意层级匹配' { Test-Case '! 前缀翻译成任意层级匹配' {
$item = New-BaknretArchiveItem -ArchivePath 'UserData' -RealPath 'C:\x' $item = New-BakNRetArchiveItem -ArchivePath 'UserData' -RealPath 'C:\x'
$exclude = Get-BaknretExcludeArgument -Item $item -Patterns @('!*Cache') $exclude = Get-BakNRetExcludeArgument -Item $item -Patterns @('!*Cache')
Assert-Equal '-xr!*Cache' $exclude.Arguments[0] Assert-Equal '-xr!*Cache' $exclude.Arguments[0]
} }
Test-Case '模式里的空格转成 ? (7z 的模式不支持空格)' { Test-Case '模式里的空格转成 ? (7z 的模式不支持空格)' {
$item = New-BaknretArchiveItem -ArchivePath 'UserData' -RealPath 'C:\x' $item = New-BakNRetArchiveItem -ArchivePath 'UserData' -RealPath 'C:\x'
$exclude = Get-BaknretExcludeArgument -Item $item -Patterns @('Default\Code Cache') $exclude = Get-BakNRetExcludeArgument -Item $item -Patterns @('Default\Code Cache')
Assert-Equal '-x!UserData\Default\Code?Cache' $exclude.Arguments[0] Assert-Equal '-x!UserData\Default\Code?Cache' $exclude.Arguments[0]
} }
Test-Case '生成的参数里绝不出现引号(旧实现 -x!"路径" 让排除全部失效)' { Test-Case '生成的参数里绝不出现引号(旧实现 -x!"路径" 让排除全部失效)' {
$item = New-BaknretArchiveItem -ArchivePath 'UserData' -RealPath 'C:\x' $item = New-BakNRetArchiveItem -ArchivePath 'UserData' -RealPath 'C:\x'
$patterns = @('Default\Code Cache', '!*Cache', 'logs\', 'UserData\3rdparty\WebBrowser\UserProfile\Integrated') $patterns = @('Default\Code Cache', '!*Cache', 'logs\', 'UserData\3rdparty\WebBrowser\UserProfile\Integrated')
$exclude = Get-BaknretExcludeArgument -Item $item -Patterns $patterns $exclude = Get-BakNRetExcludeArgument -Item $item -Patterns $patterns
Assert-Equal 4 $exclude.Arguments.Count Assert-Equal 4 $exclude.Arguments.Count
foreach ($argument in $exclude.Arguments) { foreach ($argument in $exclude.Arguments) {
Assert-False ($argument.Contains('"')) "参数里出现了引号: $argument" Assert-False ($argument.Contains('"')) "参数里出现了引号: $argument"
@@ -551,8 +551,8 @@ Test-Case '生成的参数里绝不出现引号(旧实现 -x!"路径" 让排
} }
Test-Case '空模式被忽略' { Test-Case '空模式被忽略' {
$item = New-BaknretArchiveItem -ArchivePath 'x' -RealPath 'C:\x' $item = New-BakNRetArchiveItem -ArchivePath 'x' -RealPath 'C:\x'
$exclude = Get-BaknretExcludeArgument -Item $item -Patterns @('', ' ', 'real\') $exclude = Get-BakNRetExcludeArgument -Item $item -Patterns @('', ' ', 'real\')
Assert-Equal 1 $exclude.Arguments.Count Assert-Equal 1 $exclude.Arguments.Count
Assert-Equal '-x!x\real' $exclude.Arguments[0] Assert-Equal '-x!x\real' $exclude.Arguments[0]
} }
@@ -562,9 +562,9 @@ Test-Case '!re:<正则> 展开成源目录里命中的精确路径' {
foreach ($dir in 'Cache1', 'Cache2', 'Keep', 'sub\Cache3') { foreach ($dir in 'Cache1', 'Cache2', 'Keep', 'sub\Cache3') {
New-Item -ItemType Directory -Path (Join-Path $root $dir) -Force | Out-Null New-Item -ItemType Directory -Path (Join-Path $root $dir) -Force | Out-Null
} }
$item = New-BaknretArchiveItem -ArchivePath 'Data' -RealPath $root $item = New-BakNRetArchiveItem -ArchivePath 'Data' -RealPath $root
$exclude = Get-BaknretExcludeArgument -Item $item -Patterns @('!re:^Cache\d+$') $exclude = Get-BakNRetExcludeArgument -Item $item -Patterns @('!re:^Cache\d+$')
Assert-Null $exclude.Error Assert-Null $exclude.Error
$sorted = @($exclude.Arguments | Sort-Object) $sorted = @($exclude.Arguments | Sort-Object)
Assert-Equal 3 $sorted.Count Assert-Equal 3 $sorted.Count
@@ -575,34 +575,34 @@ Test-Case '!re:<正则> 展开成源目录里命中的精确路径' {
Test-Case '非法的 !re: 正则给出明确的 Error,而不是静默漏排除' { Test-Case '非法的 !re: 正则给出明确的 Error,而不是静默漏排除' {
$root = Join-Path $itemSandbox 'regex-src' $root = Join-Path $itemSandbox 'regex-src'
$item = New-BaknretArchiveItem -ArchivePath 'Data' -RealPath $root $item = New-BakNRetArchiveItem -ArchivePath 'Data' -RealPath $root
$exclude = Get-BaknretExcludeArgument -Item $item -Patterns @('!re:*Cache') $exclude = Get-BakNRetExcludeArgument -Item $item -Patterns @('!re:*Cache')
Assert-True ($null -ne $exclude.Error) Assert-True ($null -ne $exclude.Error)
Assert-Equal 0 $exclude.Arguments.Count Assert-Equal 0 $exclude.Arguments.Count
} }
Test-Case '空的 !re: 被忽略' { Test-Case '空的 !re: 被忽略' {
$root = Join-Path $itemSandbox 'regex-src' $root = Join-Path $itemSandbox 'regex-src'
$item = New-BaknretArchiveItem -ArchivePath 'Data' -RealPath $root $item = New-BakNRetArchiveItem -ArchivePath 'Data' -RealPath $root
$exclude = Get-BaknretExcludeArgument -Item $item -Patterns @('!re:') $exclude = Get-BakNRetExcludeArgument -Item $item -Patterns @('!re:')
Assert-Equal 0 $exclude.Arguments.Count Assert-Equal 0 $exclude.Arguments.Count
Assert-Null $exclude.Error Assert-Null $exclude.Error
} }
Test-Case '排除参数超过命令行安全长度时明确报错' { Test-Case '排除参数超过命令行安全长度时明确报错' {
$root = Join-Path $itemSandbox 'regex-src' $root = Join-Path $itemSandbox 'regex-src'
$item = New-BaknretArchiveItem -ArchivePath 'Data' -RealPath $root $item = New-BakNRetArchiveItem -ArchivePath 'Data' -RealPath $root
$exclude = Get-BaknretExcludeArgument -Item $item -Patterns @('aaaa\bbbb') -MaxCommandLineChars 5 $exclude = Get-BakNRetExcludeArgument -Item $item -Patterns @('aaaa\bbbb') -MaxCommandLineChars 5
Assert-True ($null -ne $exclude.Error) Assert-True ($null -ne $exclude.Error)
} }
Test-Case 'Split-BaknretPatternScope:`<项名>\` 前缀把模式分配给对应归档项' { Test-Case 'Split-BakNRetPatternScope:`<项名>\` 前缀把模式分配给对应归档项' {
$items = @( $items = @(
(New-BaknretArchiveItem -ArchivePath 'DefaultConfig' -RealPath 'C:\c'), (New-BakNRetArchiveItem -ArchivePath 'DefaultConfig' -RealPath 'C:\c'),
(New-BaknretArchiveItem -ArchivePath 'GlobalPersist' -RealPath 'C:\g'), (New-BakNRetArchiveItem -ArchivePath 'GlobalPersist' -RealPath 'C:\g'),
(New-BaknretArchiveItem -ArchivePath 'UserPersist' -RealPath 'C:\u') (New-BakNRetArchiveItem -ArchivePath 'UserPersist' -RealPath 'C:\u')
) )
$map = Split-BaknretPatternScope -Items $items -Patterns @('GlobalPersist\steam\steamapps', 'plain\one', '!*Cache') $map = Split-BakNRetPatternScope -Items $items -Patterns @('GlobalPersist\steam\steamapps', 'plain\one', '!*Cache')
Assert-Equal 'steam\steamapps' $map[1][0] Assert-Equal 'steam\steamapps' $map[1][0]
Assert-Equal 'plain\one' $map[0][0] '没点名任何项的模式对每一项各展开一份' Assert-Equal 'plain\one' $map[0][0] '没点名任何项的模式对每一项各展开一份'
@@ -612,8 +612,8 @@ Test-Case 'Split-BaknretPatternScope:`<项名>\` 前缀把模式分配给对
} }
} }
Test-Case 'Merge-BaknretExcludeArgument:去重且保序' { Test-Case 'Merge-BakNRetExcludeArgument:去重且保序' {
$merged = Merge-BaknretExcludeArgument -ArgumentLists @(@('-x!a', '-xr!c'), @('-x!a', '-x!b')) $merged = Merge-BakNRetExcludeArgument -ArgumentLists @(@('-x!a', '-xr!c'), @('-x!a', '-x!b'))
Assert-Equal 3 $merged.Count Assert-Equal 3 $merged.Count
Assert-Equal '-x!a' $merged[0] Assert-Equal '-x!a' $merged[0]
Assert-Equal '-xr!c' $merged[1] Assert-Equal '-xr!c' $merged[1]
@@ -625,23 +625,23 @@ Write-Host "`n== 命令行拼接 ==" -ForegroundColor Cyan
# ============================================================================ # ============================================================================
Test-Case '无空格参数原样输出' { Test-Case '无空格参数原样输出' {
Assert-Equal 'a -t7z' (ConvertTo-NativeArgumentString -ArgumentList @('a', '-t7z')) Assert-Equal 'a -t7z' (ConvertTo-BakNRetNativeArgumentString -ArgumentList @('a', '-t7z'))
} }
Test-Case '含空格参数加引号' { Test-Case '含空格参数加引号' {
Assert-Equal '"C:\Program Files\x"' (ConvertTo-NativeArgumentString -ArgumentList @('C:\Program Files\x')) Assert-Equal '"C:\Program Files\x"' (ConvertTo-BakNRetNativeArgumentString -ArgumentList @('C:\Program Files\x'))
} }
Test-Case '引号内的结尾反斜杠翻倍(否则会被当成转义引号)' { Test-Case '引号内的结尾反斜杠翻倍(否则会被当成转义引号)' {
Assert-Equal '"C:\Program Files\\"' (ConvertTo-NativeArgumentString -ArgumentList @('C:\Program Files\')) Assert-Equal '"C:\Program Files\\"' (ConvertTo-BakNRetNativeArgumentString -ArgumentList @('C:\Program Files\'))
} }
Test-Case '内部引号被转义' { Test-Case '内部引号被转义' {
Assert-Equal '"say \"hi\""' (ConvertTo-NativeArgumentString -ArgumentList @('say "hi"')) Assert-Equal '"say \"hi\""' (ConvertTo-BakNRetNativeArgumentString -ArgumentList @('say "hi"'))
} }
Test-Case '空参数输出一对空引号' { Test-Case '空参数输出一对空引号' {
Assert-Equal '""' (ConvertTo-NativeArgumentString -ArgumentList @('')) Assert-Equal '""' (ConvertTo-BakNRetNativeArgumentString -ArgumentList @(''))
} }
# ============================================================================ # ============================================================================
@@ -654,7 +654,7 @@ Test-Case 'manifest 读写往返(含 layouts)' {
New-Item -ItemType Directory -Path $sandbox -Force | Out-Null New-Item -ItemType Directory -Path $sandbox -Force | Out-Null
$path = Join-Path $sandbox 'manifest.json' $path = Join-Path $sandbox 'manifest.json'
$m = Read-BaknretManifest -Path $path $m = Read-BakNRetManifest -Path $path
Assert-Equal 0 $m.items.Count Assert-Equal 0 $m.items.Count
$m.compressor = [pscustomobject]@{ name = '7z'; version = '26.03' } $m.compressor = [pscustomobject]@{ name = '7z'; version = '26.03' }
@@ -667,9 +667,9 @@ Test-Case 'manifest 读写往返(含 layouts)' {
[ordered]@{ name = 'BetaFile'; kind = 'file' } [ordered]@{ name = 'BetaFile'; kind = 'file' }
) )
} }
Write-BaknretManifest -Path $path -Manifest $m | Out-Null Write-BakNRetManifest -Path $path -Manifest $m | Out-Null
$again = Read-BaknretManifest -Path $path $again = Read-BakNRetManifest -Path $path
Assert-Equal 1 $again.items.Count Assert-Equal 1 $again.items.Count
Assert-Equal 'backed-up' $again.items['my-app'].action Assert-Equal 'backed-up' $again.items['my-app'].action
Assert-Equal 2 $again.items['my-app'].layouts.Count Assert-Equal 2 $again.items['my-app'].layouts.Count
@@ -683,12 +683,12 @@ Test-Case 'manifest 读写往返(含 layouts)' {
Test-Case 'manifest 损坏时不抛异常' { Test-Case 'manifest 损坏时不抛异常' {
$path = Join-Path $sandbox 'broken.json' $path = Join-Path $sandbox 'broken.json'
[System.IO.File]::WriteAllText($path, '{ this is not json') [System.IO.File]::WriteAllText($path, '{ this is not json')
$m = Read-BaknretManifest -Path $path $m = Read-BakNRetManifest -Path $path
Assert-Equal 0 $m.items.Count Assert-Equal 0 $m.items.Count
} }
Test-Case '配置缺失时返回默认值' { Test-Case '配置缺失时返回默认值' {
$config = Get-BaknretConfig -Path (Join-Path $sandbox 'nope.psd1') $config = Get-BakNRetConfig -Path (Join-Path $sandbox 'nope.psd1')
Assert-Equal 'Backups' $config.BackupDir Assert-Equal 'Backups' $config.BackupDir
Assert-True ($config.MinFreeSpaceGB -gt 0) Assert-True ($config.MinFreeSpaceGB -gt 0)
} }
@@ -696,7 +696,7 @@ Test-Case '配置缺失时返回默认值' {
Test-Case '配置嵌套段落合并且不丢默认键' { Test-Case '配置嵌套段落合并且不丢默认键' {
$path = Join-Path $sandbox 'cfg.psd1' $path = Join-Path $sandbox 'cfg.psd1'
Set-Content -Encoding UTF8 -LiteralPath $path -Value "@{ Encryption = @{ Enabled = `$true }; CompressionLevel = 5 }" Set-Content -Encoding UTF8 -LiteralPath $path -Value "@{ Encryption = @{ Enabled = `$true }; CompressionLevel = 5 }"
$config = Get-BaknretConfig -Path $path $config = Get-BakNRetConfig -Path $path
Assert-Equal 5 $config.CompressionLevel Assert-Equal 5 $config.CompressionLevel
Assert-True $config.Encryption.Enabled Assert-True $config.Encryption.Enabled
Assert-True $config.Encryption.EncryptHeaders '未覆盖的嵌套键应保留默认值' Assert-True $config.Encryption.EncryptHeaders '未覆盖的嵌套键应保留默认值'
@@ -706,9 +706,9 @@ Test-Case '口令:环境变量可读取,取不到返回 $null' {
$old = $env:BAKNRET_PASSWORD $old = $env:BAKNRET_PASSWORD
try { try {
Remove-Item Env:BAKNRET_PASSWORD -ErrorAction SilentlyContinue Remove-Item Env:BAKNRET_PASSWORD -ErrorAction SilentlyContinue
Assert-Null (Get-BaknretPassword -PasswordFile (Join-Path $sandbox 'nope')) Assert-Null (Get-BakNRetPassword -PasswordFile (Join-Path $sandbox 'nope'))
$env:BAKNRET_PASSWORD = 'from-env' $env:BAKNRET_PASSWORD = 'from-env'
Assert-Equal 'from-env' (Get-BaknretPassword) Assert-Equal 'from-env' (Get-BakNRetPassword)
} }
finally { finally {
Remove-Item Env:BAKNRET_PASSWORD -ErrorAction SilentlyContinue Remove-Item Env:BAKNRET_PASSWORD -ErrorAction SilentlyContinue
@@ -731,7 +731,7 @@ Test-Case '名录解析:裸键、引号键、带 - 与 . 的名字' {
$content = "@{`n FooClolor = @{ DefaultData = @{ Path = '$appDir' } }`n 'scoop-config' = @{ DefaultConfig = @{ Path = '$configDir' } }`n '.ssh' = @{ DefaultData = @{ Path = '$appDir' } }`n}`n" $content = "@{`n FooClolor = @{ DefaultData = @{ Path = '$appDir' } }`n 'scoop-config' = @{ DefaultConfig = @{ Path = '$configDir' } }`n '.ssh' = @{ DefaultData = @{ Path = '$appDir' } }`n}`n"
[System.IO.File]::WriteAllText($catPath, $content, [System.Text.UTF8Encoding]::new($true)) [System.IO.File]::WriteAllText($catPath, $content, [System.Text.UTF8Encoding]::new($true))
$catalog = Get-SoftwareCatalog -Path $catPath -NoCache $catalog = Get-BakNRetSoftwareCatalog -Path $catPath -NoCache
Assert-Equal 3 $catalog.Count Assert-Equal 3 $catalog.Count
Assert-True $catalog.ContainsKey('FooClolor') Assert-True $catalog.ContainsKey('FooClolor')
Assert-True $catalog.ContainsKey('scoop-config') '带连字符的键必须能解析' Assert-True $catalog.ContainsKey('scoop-config') '带连字符的键必须能解析'
@@ -743,7 +743,7 @@ Test-Case '名录:Slot 按名字排序(哈希表顺序不可依赖)' {
$catPath = Join-Path $catalogSandbox 'sorted.psd1' $catPath = Join-Path $catalogSandbox 'sorted.psd1'
[System.IO.File]::WriteAllText($catPath, "@{`n FooClolor = @{ Zeta = @{ Path = '$appDir' }; Alpha = @{ Path = '$configDir' } }`n}`n", [System.Text.UTF8Encoding]::new($true)) [System.IO.File]::WriteAllText($catPath, "@{`n FooClolor = @{ Zeta = @{ Path = '$appDir' }; Alpha = @{ Path = '$configDir' } }`n}`n", [System.Text.UTF8Encoding]::new($true))
$catalog = Get-SoftwareCatalog -Path $catPath -NoCache $catalog = Get-BakNRetSoftwareCatalog -Path $catPath -NoCache
Assert-Equal 'Multi' $catalog['FooClolor'].Kind Assert-Equal 'Multi' $catalog['FooClolor'].Kind
Assert-Equal 2 $catalog['FooClolor'].Slots.Count Assert-Equal 2 $catalog['FooClolor'].Slots.Count
Assert-Equal 'Alpha' $catalog['FooClolor'].Slots[0].Name Assert-Equal 'Alpha' $catalog['FooClolor'].Slots[0].Name
@@ -756,7 +756,7 @@ Test-Case '名录:Slot 对象字段与契约一致' {
Set-Content -Encoding UTF8 -LiteralPath $filePath -Value '{}' Set-Content -Encoding UTF8 -LiteralPath $filePath -Value '{}'
[System.IO.File]::WriteAllText($catPath, "@{`n Rich = @{`n Beta = @{ Path = '$filePath'; Encrypt = `$true; Description = 'beta 说明' }`n Alpha = @{ Path = '$configDir'; Exclude = '!*Cache,sub\one'; Include = 'Modules:$appDir' }`n }`n}`n", [System.Text.UTF8Encoding]::new($true)) [System.IO.File]::WriteAllText($catPath, "@{`n Rich = @{`n Beta = @{ Path = '$filePath'; Encrypt = `$true; Description = 'beta 说明' }`n Alpha = @{ Path = '$configDir'; Exclude = '!*Cache,sub\one'; Include = 'Modules:$appDir' }`n }`n}`n", [System.Text.UTF8Encoding]::new($true))
$catalog = Get-SoftwareCatalog -Path $catPath -NoCache $catalog = Get-BakNRetSoftwareCatalog -Path $catPath -NoCache
$entry = $catalog['Rich'] $entry = $catalog['Rich']
foreach ($expected in 'Name', 'Path', 'Description', 'Slots', 'Kind', 'Error', 'Missing', 'Raw') { foreach ($expected in 'Name', 'Path', 'Description', 'Slots', 'Kind', 'Error', 'Missing', 'Raw') {
Assert-True ($entry.PSObject.Properties.Name -contains $expected) "名录条目缺少 $expected" Assert-True ($entry.PSObject.Properties.Name -contains $expected) "名录条目缺少 $expected"
@@ -789,7 +789,7 @@ Test-Case '名录解析:目录带版本后缀时按前缀补全(legendary_2.
$catPath = Join-Path $catalogSandbox 'suffix.psd1' $catPath = Join-Path $catalogSandbox 'suffix.psd1'
[System.IO.File]::WriteAllText($catPath, "@{`n legendary = @{ DefaultConfig = @{ Path = '$root\legendary' } }`n}`n", [System.Text.UTF8Encoding]::new($true)) [System.IO.File]::WriteAllText($catPath, "@{`n legendary = @{ DefaultConfig = @{ Path = '$root\legendary' } }`n}`n", [System.Text.UTF8Encoding]::new($true))
$catalog = Get-SoftwareCatalog -Path $catPath -NoCache $catalog = Get-BakNRetSoftwareCatalog -Path $catPath -NoCache
Assert-Equal 'Single' $catalog['legendary'].Kind Assert-Equal 'Single' $catalog['legendary'].Kind
Assert-True $catalog['legendary'].Slots[0].Resolved -like '*legendary_2.0.4' Assert-True $catalog['legendary'].Slots[0].Resolved -like '*legendary_2.0.4'
Assert-True $catalog['legendary'].Slots[0].Suffixed '补全出来的路径要标记 Suffixed' Assert-True $catalog['legendary'].Slots[0].Suffixed '补全出来的路径要标记 Suffixed'
@@ -801,20 +801,20 @@ Test-Case '名录解析:不会把 Legendary 误配成 LegendarySomething' {
$catPath = Join-Path $catalogSandbox 'strict.psd1' $catPath = Join-Path $catalogSandbox 'strict.psd1'
[System.IO.File]::WriteAllText($catPath, "@{`n Legendary = @{ DefaultConfig = @{ Path = '$root\Legendary' } }`n}`n", [System.Text.UTF8Encoding]::new($true)) [System.IO.File]::WriteAllText($catPath, "@{`n Legendary = @{ DefaultConfig = @{ Path = '$root\Legendary' } }`n}`n", [System.Text.UTF8Encoding]::new($true))
$catalog = Get-SoftwareCatalog -Path $catPath -NoCache $catalog = Get-BakNRetSoftwareCatalog -Path $catPath -NoCache
Assert-Equal 'Unresolved' $catalog['Legendary'].Kind '必须以下一个字符是 _ 或 - 为界' Assert-Equal 'Unresolved' $catalog['Legendary'].Kind '必须以下一个字符是 _ 或 - 为界'
Assert-False $catalog['Legendary'].Slots[0].Exists Assert-False $catalog['Legendary'].Slots[0].Exists
Assert-Equal 1 $catalog['Legendary'].Missing.Count Assert-Equal 1 $catalog['Legendary'].Missing.Count
} }
Test-Case 'Find-ChildDirectoryByName:_ / - 边界之外不补全' { Test-Case 'Find-BakNRetChildDirectoryByName:_ / - 边界之外不补全' {
$root = Join-Path $catalogSandbox 'boundary' $root = Join-Path $catalogSandbox 'boundary'
New-Item -ItemType Directory -Path (Join-Path $root 'Legendary_1.0') -Force | Out-Null New-Item -ItemType Directory -Path (Join-Path $root 'Legendary_1.0') -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $root 'Legendary-2.0') -Force | Out-Null New-Item -ItemType Directory -Path (Join-Path $root 'Legendary-2.0') -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $root 'LegendarySomething') -Force | Out-Null New-Item -ItemType Directory -Path (Join-Path $root 'LegendarySomething') -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $root 'Unrelated') -Force | Out-Null New-Item -ItemType Directory -Path (Join-Path $root 'Unrelated') -Force | Out-Null
$found = @(Find-ChildDirectoryByName -Parent $root -Name 'Legendary') $found = @(Find-BakNRetChildDirectoryByName -Parent $root -Name 'Legendary')
Assert-Equal 2 $found.Count Assert-Equal 2 $found.Count
Assert-True (@($found | Where-Object { $_ -like '*Legendary_1.0' }).Count -eq 1) Assert-True (@($found | Where-Object { $_ -like '*Legendary_1.0' }).Count -eq 1)
Assert-True (@($found | Where-Object { $_ -like '*Legendary-2.0' }).Count -eq 1) Assert-True (@($found | Where-Object { $_ -like '*Legendary-2.0' }).Count -eq 1)
@@ -827,7 +827,7 @@ Test-Case '名录:一个 Slot 补全出多个目录时明确报错(否则会
$catPath = Join-Path $catalogSandbox 'multicand.psd1' $catPath = Join-Path $catalogSandbox 'multicand.psd1'
[System.IO.File]::WriteAllText($catPath, "@{`n Legendary = @{ DefaultConfig = @{ Path = '$root\Legendary' } }`n}`n", [System.Text.UTF8Encoding]::new($true)) [System.IO.File]::WriteAllText($catPath, "@{`n Legendary = @{ DefaultConfig = @{ Path = '$root\Legendary' } }`n}`n", [System.Text.UTF8Encoding]::new($true))
$catalog = Get-SoftwareCatalog -Path $catPath -NoCache $catalog = Get-BakNRetSoftwareCatalog -Path $catPath -NoCache
$entry = $catalog['Legendary'] $entry = $catalog['Legendary']
Assert-True ($null -ne $entry.Error) Assert-True ($null -ne $entry.Error)
Assert-True ($entry.Error -like '*拆成多个 Slot*') '错误里要给出可操作的指引' Assert-True ($entry.Error -like '*拆成多个 Slot*') '错误里要给出可操作的指引'
@@ -836,7 +836,7 @@ Test-Case '名录:一个 Slot 补全出多个目录时明确报错(否则会
Test-Case '名录:Slot 写法不对 / 缺少 Path 都记成 Error,而不是崩掉' { Test-Case '名录:Slot 写法不对 / 缺少 Path 都记成 Error,而不是崩掉' {
$bad = Join-Path $catalogSandbox 'bad.psd1' $bad = Join-Path $catalogSandbox 'bad.psd1'
[System.IO.File]::WriteAllText($bad, "@{`n A = @{ S = 'C:\x' }`n B = @{ S = @{ Description = 'x' } }`n}`n", [System.Text.UTF8Encoding]::new($true)) [System.IO.File]::WriteAllText($bad, "@{`n A = @{ S = 'C:\x' }`n B = @{ S = @{ Description = 'x' } }`n}`n", [System.Text.UTF8Encoding]::new($true))
$catalog = Get-SoftwareCatalog -Path $bad -NoCache $catalog = Get-BakNRetSoftwareCatalog -Path $bad -NoCache
Assert-Equal 'Invalid' $catalog['A'].Kind Assert-Equal 'Invalid' $catalog['A'].Kind
Assert-True ($catalog['A'].Error -like '*Slot S*') Assert-True ($catalog['A'].Error -like '*Slot S*')
Assert-Equal 'Invalid' $catalog['B'].Kind Assert-Equal 'Invalid' $catalog['B'].Kind
@@ -846,12 +846,12 @@ Test-Case '名录:Slot 写法不对 / 缺少 Path 都记成 Error,而不是
Test-Case '名录:裸字符串 / 数组等旧结构会被 ERROR 跳过(不再当目录用)' { Test-Case '名录:裸字符串 / 数组等旧结构会被 ERROR 跳过(不再当目录用)' {
$legacy = Join-Path $catalogSandbox 'legacy.psd1' $legacy = Join-Path $catalogSandbox 'legacy.psd1'
[System.IO.File]::WriteAllText($legacy, "@{`n A = 'C:\Programs\A'`n B = @('C:\1','C:\2')`n}`n", [System.Text.UTF8Encoding]::new($true)) [System.IO.File]::WriteAllText($legacy, "@{`n A = 'C:\Programs\A'`n B = @('C:\1','C:\2')`n}`n", [System.Text.UTF8Encoding]::new($true))
$catalog = Get-SoftwareCatalog -Path $legacy -NoCache $catalog = Get-BakNRetSoftwareCatalog -Path $legacy -NoCache
Assert-Equal 0 $catalog.Count '旧结构必须被跳过,不能猜成目录' Assert-Equal 0 $catalog.Count '旧结构必须被跳过,不能猜成目录'
$dirs = Join-Path $catalogSandbox 'dirs.psd1' $dirs = Join-Path $catalogSandbox 'dirs.psd1'
[System.IO.File]::WriteAllText($dirs, "@{`n C = @{ Dirs = @('C:\1','C:\2') }`n}`n", [System.Text.UTF8Encoding]::new($true)) [System.IO.File]::WriteAllText($dirs, "@{`n C = @{ Dirs = @('C:\1','C:\2') }`n}`n", [System.Text.UTF8Encoding]::new($true))
$catalog = Get-SoftwareCatalog -Path $dirs -NoCache $catalog = Get-BakNRetSoftwareCatalog -Path $dirs -NoCache
Assert-Equal 1 $catalog.Count Assert-Equal 1 $catalog.Count
Assert-Equal 'Invalid' $catalog['C'].Kind Assert-Equal 'Invalid' $catalog['C'].Kind
Assert-True ($catalog['C'].Error -like '*Dirs*') Assert-True ($catalog['C'].Error -like '*Dirs*')
@@ -869,7 +869,7 @@ Test-Case '名录:Includes 分文件维护会被合并' {
[System.IO.File]::WriteAllText($extraPath, "@{`n 'extra-app' = @{ D = @{ Path = '$appDir' } }`n}`n", [System.Text.UTF8Encoding]::new($true)) [System.IO.File]::WriteAllText($extraPath, "@{`n 'extra-app' = @{ D = @{ Path = '$appDir' } }`n}`n", [System.Text.UTF8Encoding]::new($true))
[System.IO.File]::WriteAllText($basePath, "@{`n Includes = @('Extra.psd1')`n 'base-app' = @{ D = @{ Path = '$configDir' } }`n}`n", [System.Text.UTF8Encoding]::new($true)) [System.IO.File]::WriteAllText($basePath, "@{`n Includes = @('Extra.psd1')`n 'base-app' = @{ D = @{ Path = '$configDir' } }`n}`n", [System.Text.UTF8Encoding]::new($true))
$catalog = Get-SoftwareCatalog -Path $basePath -NoCache $catalog = Get-BakNRetSoftwareCatalog -Path $basePath -NoCache
Assert-True $catalog.ContainsKey('base-app') Assert-True $catalog.ContainsKey('base-app')
Assert-True $catalog.ContainsKey('extra-app') 'Includes 引入的条目也应在' Assert-True $catalog.ContainsKey('extra-app') 'Includes 引入的条目也应在'
} }
@@ -878,20 +878,20 @@ Test-Case '名录:结果按文件指纹缓存,-NoCache 强制重读' {
$catPath = Join-Path $catalogSandbox 'cache.psd1' $catPath = Join-Path $catalogSandbox 'cache.psd1'
[System.IO.File]::WriteAllText($catPath, "@{`n Cached = @{ D = @{ Path = '$appDir' } }`n}`n", [System.Text.UTF8Encoding]::new($true)) [System.IO.File]::WriteAllText($catPath, "@{`n Cached = @{ D = @{ Path = '$appDir' } }`n}`n", [System.Text.UTF8Encoding]::new($true))
$first = Get-SoftwareCatalog -Path $catPath $first = Get-BakNRetSoftwareCatalog -Path $catPath
$second = Get-SoftwareCatalog -Path $catPath $second = Get-BakNRetSoftwareCatalog -Path $catPath
Assert-True ([object]::ReferenceEquals($first, $second)) '同一文件同一指纹应命中缓存' Assert-True ([object]::ReferenceEquals($first, $second)) '同一文件同一指纹应命中缓存'
$fresh = Get-SoftwareCatalog -Path $catPath -NoCache $fresh = Get-BakNRetSoftwareCatalog -Path $catPath -NoCache
Assert-False ([object]::ReferenceEquals($first, $fresh)) '-NoCache 必须重新读' Assert-False ([object]::ReferenceEquals($first, $fresh)) '-NoCache 必须重新读'
[System.IO.File]::WriteAllText($catPath, "@{`n Cached = @{ D = @{ Path = '$configDir' } }`n}`n", [System.Text.UTF8Encoding]::new($true)) [System.IO.File]::WriteAllText($catPath, "@{`n Cached = @{ D = @{ Path = '$configDir' } }`n}`n", [System.Text.UTF8Encoding]::new($true))
$changed = Get-SoftwareCatalog -Path $catPath $changed = Get-BakNRetSoftwareCatalog -Path $catPath
Assert-Equal $configDir $changed['Cached'].Slots[0].Resolved '文件变了缓存必须失效' Assert-Equal $configDir $changed['Cached'].Slots[0].Resolved '文件变了缓存必须失效'
} }
# ============================================================================ # ============================================================================
Write-Host "`n== Resolve-BackupEntry(归档项解析) ==" -ForegroundColor Cyan Write-Host "`n== Resolve-BakNRetBackupEntry(归档项解析) ==" -ForegroundColor Cyan
# ============================================================================ # ============================================================================
$resolveSandbox = Join-Path $env:TEMP ("baknret-resolve-" + [guid]::NewGuid().ToString('N').Substring(0, 8)) $resolveSandbox = Join-Path $env:TEMP ("baknret-resolve-" + [guid]::NewGuid().ToString('N').Substring(0, 8))
@@ -909,7 +909,7 @@ $resolveCatalog = Join-Path $resolveSandbox 'catalog.psd1'
Test-Case '软件名条目:一个 Slot -> 一个归档项,路径是 Slot 名,归档名是软件名' { Test-Case '软件名条目:一个 Slot -> 一个归档项,路径是 Slot 名,归档名是软件名' {
$item = ConvertFrom-BackupListLine -Line 'FooClolor' $item = ConvertFrom-BackupListLine -Line 'FooClolor'
$resolved = Resolve-BackupEntry -Entry $item -CatalogPath $resolveCatalog $resolved = Resolve-BakNRetBackupEntry -Entry $item -CatalogPath $resolveCatalog
Assert-True $resolved.IsName Assert-True $resolved.IsName
Assert-Equal 'name' $resolved.ArchiveFlavor Assert-Equal 'name' $resolved.ArchiveFlavor
@@ -925,9 +925,9 @@ Test-Case '软件名条目:一个 Slot -> 一个归档项,路径是 Slot 名
Assert-True $resolved.Items[0].Exists Assert-True $resolved.Items[0].Exists
} }
Test-Case 'Resolve-BackupEntry 的返回值里没有旧的 Sources / RootName 等属性' { Test-Case 'Resolve-BakNRetBackupEntry 的返回值里没有旧的 Sources / RootName 等属性' {
$item = ConvertFrom-BackupListLine -Line 'FooClolor' $item = ConvertFrom-BackupListLine -Line 'FooClolor'
$resolved = Resolve-BackupEntry -Entry $item -CatalogPath $resolveCatalog $resolved = Resolve-BakNRetBackupEntry -Entry $item -CatalogPath $resolveCatalog
foreach ($expected in 'IsName', 'CatalogEntry', 'BaseName', 'ArchiveFlavor', 'Direction', 'Items', 'Encrypt', 'ExcludePatterns', 'HasExcludeOverride', 'Includes', 'HasIncludeOverride', 'Source', 'Error', 'Blocking') { foreach ($expected in 'IsName', 'CatalogEntry', 'BaseName', 'ArchiveFlavor', 'Direction', 'Items', 'Encrypt', 'ExcludePatterns', 'HasExcludeOverride', 'Includes', 'HasIncludeOverride', 'Source', 'Error', 'Blocking') {
Assert-True ($resolved.PSObject.Properties.Name -contains $expected) "缺少属性 $expected" Assert-True ($resolved.PSObject.Properties.Name -contains $expected) "缺少属性 $expected"
} }
@@ -938,7 +938,7 @@ Test-Case 'Resolve-BackupEntry 的返回值里没有旧的 Sources / RootName
Test-Case '字面路径条目:仍用路径命名算法,归档内是历史布局 <末级名>' { Test-Case '字面路径条目:仍用路径命名算法,归档内是历史布局 <末级名>' {
$item = ConvertFrom-BackupListLine -Line 'C:\Programs\FooClolor' $item = ConvertFrom-BackupListLine -Line 'C:\Programs\FooClolor'
$resolved = Resolve-BackupEntry -Entry $item -CatalogPath $resolveCatalog $resolved = Resolve-BakNRetBackupEntry -Entry $item -CatalogPath $resolveCatalog
Assert-False $resolved.IsName Assert-False $resolved.IsName
Assert-Equal 'path' $resolved.ArchiveFlavor Assert-Equal 'path' $resolved.ArchiveFlavor
Assert-Equal 'FooClolor_from_C_+Programs' $resolved.BaseName Assert-Equal 'FooClolor_from_C_+Programs' $resolved.BaseName
@@ -949,34 +949,34 @@ Test-Case '字面路径条目:仍用路径命名算法,归档内是历史布
Test-Case '@pathname 用名录里的真实路径跑命名算法' { Test-Case '@pathname 用名录里的真实路径跑命名算法' {
$item = ConvertFrom-BackupListLine -Line 'FooClolor @pathname' $item = ConvertFrom-BackupListLine -Line 'FooClolor @pathname'
$resolved = Resolve-BackupEntry -Entry $item -CatalogPath $resolveCatalog $resolved = Resolve-BakNRetBackupEntry -Entry $item -CatalogPath $resolveCatalog
Assert-Equal (Get-BackupBaseName -RawPath $dirA) $resolved.BaseName Assert-Equal (Get-BakNRetBackupBaseName -RawPath $dirA) $resolved.BaseName
Assert-Equal 'DefaultData' $resolved.Items[0].ArchivePath '归档内名字仍由 Slot 决定' Assert-Equal 'DefaultData' $resolved.Items[0].ArchivePath '归档内名字仍由 Slot 决定'
} }
Test-Case ':: / @ Path= 覆盖单 Slot 条目的真实路径' { Test-Case ':: / @ Path= 覆盖单 Slot 条目的真实路径' {
$item = ConvertFrom-BackupListLine -Line "FooClolor :: $dirB" $item = ConvertFrom-BackupListLine -Line "FooClolor :: $dirB"
$resolved = Resolve-BackupEntry -Entry $item -CatalogPath $resolveCatalog $resolved = Resolve-BakNRetBackupEntry -Entry $item -CatalogPath $resolveCatalog
Assert-Equal 1 $resolved.Items.Count Assert-Equal 1 $resolved.Items.Count
Assert-Equal $dirB $resolved.Items[0].RealPath Assert-Equal $dirB $resolved.Items[0].RealPath
Assert-Equal 'DefaultData' $resolved.Items[0].ArchivePath Assert-Equal 'DefaultData' $resolved.Items[0].ArchivePath
Assert-Null $resolved.Blocking Assert-Null $resolved.Blocking
$atItem = ConvertFrom-BackupListLine -Line "FooClolor @ Path='$dirB'" $atItem = ConvertFrom-BackupListLine -Line "FooClolor @ Path='$dirB'"
$atResolved = Resolve-BackupEntry -Entry $atItem -CatalogPath $resolveCatalog $atResolved = Resolve-BakNRetBackupEntry -Entry $atItem -CatalogPath $resolveCatalog
Assert-Equal $dirB $atResolved.Items[0].RealPath Assert-Equal $dirB $atResolved.Items[0].RealPath
} }
Test-Case ':: 覆盖路径时,多 Slot 的条目会 Blocking(说不清是哪个 Slot)' { Test-Case ':: 覆盖路径时,多 Slot 的条目会 Blocking(说不清是哪个 Slot)' {
$item = ConvertFrom-BackupListLine -Line "MultiApp :: $dirB" $item = ConvertFrom-BackupListLine -Line "MultiApp :: $dirB"
$resolved = Resolve-BackupEntry -Entry $item -CatalogPath $resolveCatalog $resolved = Resolve-BakNRetBackupEntry -Entry $item -CatalogPath $resolveCatalog
Assert-True ($null -ne $resolved.Blocking) Assert-True ($null -ne $resolved.Blocking)
Assert-True ($resolved.Blocking -like '*2 个 Slot*') Assert-True ($resolved.Blocking -like '*2 个 Slot*')
} }
Test-Case '名录里没有该名字:BaseName 退回可读目录名,并给出 Error' { Test-Case '名录里没有该名字:BaseName 退回可读目录名,并给出 Error' {
$item = ConvertFrom-BackupListLine -Line 'no-such-thing' $item = ConvertFrom-BackupListLine -Line 'no-such-thing'
$resolved = Resolve-BackupEntry -Entry $item -CatalogPath $resolveCatalog $resolved = Resolve-BakNRetBackupEntry -Entry $item -CatalogPath $resolveCatalog
Assert-Equal 'no-such-thing' $resolved.BaseName Assert-Equal 'no-such-thing' $resolved.BaseName
Assert-True ($null -ne $resolved.Error) '应给出错误说明' Assert-True ($null -ne $resolved.Error) '应给出错误说明'
Assert-Equal 0 $resolved.Items.Count Assert-Equal 0 $resolved.Items.Count
@@ -986,7 +986,7 @@ Test-Case '名录里的路径不存在时仍给出归档项(恢复要靠它还
$catPath = Join-Path $resolveSandbox 'gone.psd1' $catPath = Join-Path $resolveSandbox 'gone.psd1'
[System.IO.File]::WriteAllText($catPath, "@{`n 'gone-app' = @{ DefaultData = @{ Path = 'C:\definitely\not\here' } }`n}`n", [System.Text.UTF8Encoding]::new($true)) [System.IO.File]::WriteAllText($catPath, "@{`n 'gone-app' = @{ DefaultData = @{ Path = 'C:\definitely\not\here' } }`n}`n", [System.Text.UTF8Encoding]::new($true))
$item = ConvertFrom-BackupListLine -Line 'gone-app' $item = ConvertFrom-BackupListLine -Line 'gone-app'
$resolved = Resolve-BackupEntry -Entry $item -CatalogPath $catPath $resolved = Resolve-BakNRetBackupEntry -Entry $item -CatalogPath $catPath
Assert-Equal 1 $resolved.Items.Count '恢复端据 Items 还原' Assert-Equal 1 $resolved.Items.Count '恢复端据 Items 还原'
Assert-Equal 'C:\definitely\not\here' $resolved.Items[0].RealPath Assert-Equal 'C:\definitely\not\here' $resolved.Items[0].RealPath
Assert-False $resolved.Items[0].Exists Assert-False $resolved.Items[0].Exists
@@ -995,7 +995,7 @@ Test-Case '名录里的路径不存在时仍给出归档项(恢复要靠它还
Test-Case ':+ 追加项:<归档内相对路径>:<宿主机绝对路径>' { Test-Case ':+ 追加项:<归档内相对路径>:<宿主机绝对路径>' {
$item = ConvertFrom-BackupListLine -Line "FooClolor :+ Modules:$dirExtra" $item = ConvertFrom-BackupListLine -Line "FooClolor :+ Modules:$dirExtra"
$resolved = Resolve-BackupEntry -Entry $item -CatalogPath $resolveCatalog $resolved = Resolve-BakNRetBackupEntry -Entry $item -CatalogPath $resolveCatalog
Assert-Equal 2 $resolved.Items.Count Assert-Equal 2 $resolved.Items.Count
Assert-Equal 'DefaultData' $resolved.Items[0].ArchivePath Assert-Equal 'DefaultData' $resolved.Items[0].ArchivePath
Assert-Equal 'Modules' $resolved.Items[1].ArchivePath Assert-Equal 'Modules' $resolved.Items[1].ArchivePath
@@ -1011,11 +1011,11 @@ Test-Case '条目级 :+ 覆盖名录里的 Include(不是叠加)' {
$catPath = Join-Path $resolveSandbox 'withinc.psd1' $catPath = Join-Path $resolveSandbox 'withinc.psd1'
[System.IO.File]::WriteAllText($catPath, "@{`n IncApp = @{ DefaultData = @{ Path = '$dirA'; Include = 'CatalogInc:$incDir' } }`n}`n", [System.Text.UTF8Encoding]::new($true)) [System.IO.File]::WriteAllText($catPath, "@{`n IncApp = @{ DefaultData = @{ Path = '$dirA'; Include = 'CatalogInc:$incDir' } }`n}`n", [System.Text.UTF8Encoding]::new($true))
$plain = Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line 'IncApp') -CatalogPath $catPath $plain = Resolve-BakNRetBackupEntry -Entry (ConvertFrom-BackupListLine -Line 'IncApp') -CatalogPath $catPath
Assert-Equal 2 $plain.Items.Count Assert-Equal 2 $plain.Items.Count
Assert-Equal 'CatalogInc' $plain.Items[1].ArchivePath Assert-Equal 'CatalogInc' $plain.Items[1].ArchivePath
$overridden = Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line "IncApp :+ EntryInc:$dirExtra") -CatalogPath $catPath $overridden = Resolve-BakNRetBackupEntry -Entry (ConvertFrom-BackupListLine -Line "IncApp :+ EntryInc:$dirExtra") -CatalogPath $catPath
Assert-Equal 2 $overridden.Items.Count Assert-Equal 2 $overridden.Items.Count
Assert-Equal 'EntryInc' $overridden.Items[1].ArchivePath Assert-Equal 'EntryInc' $overridden.Items[1].ArchivePath
Assert-Equal $dirExtra $overridden.Items[1].RealPath Assert-Equal $dirExtra $overridden.Items[1].RealPath
@@ -1023,45 +1023,45 @@ Test-Case '条目级 :+ 覆盖名录里的 Include(不是叠加)' {
Test-Case '归档内路径冲突:同名追加项 -> Blocking' { Test-Case '归档内路径冲突:同名追加项 -> Blocking' {
$item = ConvertFrom-BackupListLine -Line "FooClolor :+ DefaultData:$dirExtra" $item = ConvertFrom-BackupListLine -Line "FooClolor :+ DefaultData:$dirExtra"
$resolved = Resolve-BackupEntry -Entry $item -CatalogPath $resolveCatalog $resolved = Resolve-BakNRetBackupEntry -Entry $item -CatalogPath $resolveCatalog
Assert-True ($null -ne $resolved.Blocking) Assert-True ($null -ne $resolved.Blocking)
Assert-True ($resolved.Blocking -like '*冲突*') Assert-True ($resolved.Blocking -like '*冲突*')
} }
Test-Case '归档内路径冲突:父子关系 -> Blocking' { Test-Case '归档内路径冲突:父子关系 -> Blocking' {
$item = ConvertFrom-BackupListLine -Line "FooClolor :+ DefaultData\sub:$dirExtra" $item = ConvertFrom-BackupListLine -Line "FooClolor :+ DefaultData\sub:$dirExtra"
$resolved = Resolve-BackupEntry -Entry $item -CatalogPath $resolveCatalog $resolved = Resolve-BakNRetBackupEntry -Entry $item -CatalogPath $resolveCatalog
Assert-True ($null -ne $resolved.Blocking) Assert-True ($null -ne $resolved.Blocking)
Assert-True ($resolved.Blocking -like '*父子*') Assert-True ($resolved.Blocking -like '*父子*')
} }
Test-Case '加密:名录 Slot 的 Encrypt 之和,条目级覆盖优先' { Test-Case '加密:名录 Slot 的 Encrypt 之和,条目级覆盖优先' {
$catalogEncrypt = Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line 'FooClolor') -CatalogPath $resolveCatalog $catalogEncrypt = Resolve-BakNRetBackupEntry -Entry (ConvertFrom-BackupListLine -Line 'FooClolor') -CatalogPath $resolveCatalog
Assert-True $catalogEncrypt.Encrypt Assert-True $catalogEncrypt.Encrypt
$off = Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line 'FooClolor :!encrypt') -CatalogPath $resolveCatalog $off = Resolve-BakNRetBackupEntry -Entry (ConvertFrom-BackupListLine -Line 'FooClolor :!encrypt') -CatalogPath $resolveCatalog
Assert-False $off.Encrypt Assert-False $off.Encrypt
$on = Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line 'MultiApp :encrypt') -CatalogPath $resolveCatalog $on = Resolve-BakNRetBackupEntry -Entry (ConvertFrom-BackupListLine -Line 'MultiApp :encrypt') -CatalogPath $resolveCatalog
Assert-True $on.Encrypt '名录没开、条目开了 -> 开' Assert-True $on.Encrypt '名录没开、条目开了 -> 开'
Assert-False $on.HasExcludeOverride Assert-False $on.HasExcludeOverride
} }
Test-Case '方向标记随条目一路传到 Resolve-BackupEntry' { Test-Case '方向标记随条目一路传到 Resolve-BakNRetBackupEntry' {
Assert-Equal 'backup' (Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line '+ FooClolor') -CatalogPath $resolveCatalog).Direction Assert-Equal 'backup' (Resolve-BakNRetBackupEntry -Entry (ConvertFrom-BackupListLine -Line '+ FooClolor') -CatalogPath $resolveCatalog).Direction
Assert-Equal 'restore' (Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line '- FooClolor') -CatalogPath $resolveCatalog).Direction Assert-Equal 'restore' (Resolve-BakNRetBackupEntry -Entry (ConvertFrom-BackupListLine -Line '- FooClolor') -CatalogPath $resolveCatalog).Direction
} }
Test-Case '条目级 :- 覆盖名录里的 Exclude(不是叠加)' { Test-Case '条目级 :- 覆盖名录里的 Exclude(不是叠加)' {
$catPath = Join-Path $resolveSandbox 'withexc.psd1' $catPath = Join-Path $resolveSandbox 'withexc.psd1'
[System.IO.File]::WriteAllText($catPath, "@{`n ExcApp = @{ DefaultData = @{ Path = '$dirA'; Exclude = '!*CatalogCache' } }`n}`n", [System.Text.UTF8Encoding]::new($true)) [System.IO.File]::WriteAllText($catPath, "@{`n ExcApp = @{ DefaultData = @{ Path = '$dirA'; Exclude = '!*CatalogCache' } }`n}`n", [System.Text.UTF8Encoding]::new($true))
$plain = Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line 'ExcApp') -CatalogPath $catPath $plain = Resolve-BakNRetBackupEntry -Entry (ConvertFrom-BackupListLine -Line 'ExcApp') -CatalogPath $catPath
Assert-False $plain.HasExcludeOverride Assert-False $plain.HasExcludeOverride
Assert-Equal 0 $plain.ExcludePatterns.Count Assert-Equal 0 $plain.ExcludePatterns.Count
Assert-Equal '!*CatalogCache' $plain.Items[0].Exclude[0] Assert-Equal '!*CatalogCache' $plain.Items[0].Exclude[0]
$overridden = Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line 'ExcApp :- !*Listed') -CatalogPath $catPath $overridden = Resolve-BakNRetBackupEntry -Entry (ConvertFrom-BackupListLine -Line 'ExcApp :- !*Listed') -CatalogPath $catPath
Assert-True $overridden.HasExcludeOverride Assert-True $overridden.HasExcludeOverride
Assert-Equal '!*Listed' $overridden.ExcludePatterns[0] Assert-Equal '!*Listed' $overridden.ExcludePatterns[0]
} }
@@ -1093,8 +1093,8 @@ else {
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $source 'Default\IndexedDB\i.bin') 'i' Set-Content -Encoding UTF8 -LiteralPath (Join-Path $source 'Default\IndexedDB\i.bin') 'i'
$patterns = @('!*Cache', 'component_crx_cache', 'Default\Code Cache', 'Default\Extensions', 'Default\IndexedDB') $patterns = @('!*Cache', 'component_crx_cache', 'Default\Code Cache', 'Default\Extensions', 'Default\IndexedDB')
$archiveItem = New-BaknretArchiveItem -ArchivePath $itemName -RealPath $source -Kind 'path' -Exists $true $archiveItem = New-BakNRetArchiveItem -ArchivePath $itemName -RealPath $source -Kind 'path' -Exists $true
$excludeArgs = @((Get-BaknretExcludeArgument -Item $archiveItem -Patterns $patterns).Arguments) $excludeArgs = @((Get-BakNRetExcludeArgument -Item $archiveItem -Patterns $patterns).Arguments)
Assert-True ($excludeArgs.Count -ge 5) '排除参数应被翻译出来' Assert-True ($excludeArgs.Count -ge 5) '排除参数应被翻译出来'
$archive = Join-Path $sandbox 'excl.7z' $archive = Join-Path $sandbox 'excl.7z'
@@ -1124,8 +1124,8 @@ else {
Set-Content -Encoding UTF8 -LiteralPath (Join-Path $source "$d\x.bin") 'x' Set-Content -Encoding UTF8 -LiteralPath (Join-Path $source "$d\x.bin") 'x'
} }
$archiveItem = New-BaknretArchiveItem -ArchivePath $itemName -RealPath $source -Kind 'slot' -Exists $true $archiveItem = New-BakNRetArchiveItem -ArchivePath $itemName -RealPath $source -Kind 'slot' -Exists $true
$excludeArgs = @((Get-BaknretExcludeArgument -Item $archiveItem -Patterns @('!re:^Cache\d+$')).Arguments) $excludeArgs = @((Get-BakNRetExcludeArgument -Item $archiveItem -Patterns @('!re:^Cache\d+$')).Arguments)
Assert-Equal 3 $excludeArgs.Count '两个顶层 Cache 目录 + 嵌套的 sub\Cache3' Assert-Equal 3 $excludeArgs.Count '两个顶层 Cache 目录 + 嵌套的 sub\Cache3'
$archive = Join-Path $sandbox 're.7z' $archive = Join-Path $sandbox 're.7z'
@@ -1159,7 +1159,7 @@ else {
Assert-True (Test-Path -LiteralPath (Join-Path $verify "$itemName\Default\Cache\c.bin")) '对照组应当包含被排除的那个文件' Assert-True (Test-Path -LiteralPath (Join-Path $verify "$itemName\Default\Cache\c.bin")) '对照组应当包含被排除的那个文件'
} }
Test-Case 'Get-ArchiveTopLevelNames 能列出归档内的顶层条目名' { Test-Case 'Get-BakNRetArchiveTopLevelNames 能列出归档内的顶层条目名' {
$root = Join-Path $sandbox 'topnames' $root = Join-Path $sandbox 'topnames'
New-Item -ItemType Directory -Path (Join-Path $root 'Alpha') -Force | Out-Null New-Item -ItemType Directory -Path (Join-Path $root 'Alpha') -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $root 'Beta') -Force | Out-Null New-Item -ItemType Directory -Path (Join-Path $root 'Beta') -Force | Out-Null
@@ -1169,7 +1169,7 @@ else {
$code = Invoke-ExternalCommand -FilePath $sevenZip -ArgumentList @('a', '-t7z', '-mx=1', '-bso0', '-bsp0', $archive, 'Alpha', 'Beta') -WorkingDirectory $root $code = Invoke-ExternalCommand -FilePath $sevenZip -ArgumentList @('a', '-t7z', '-mx=1', '-bso0', '-bsp0', $archive, 'Alpha', 'Beta') -WorkingDirectory $root
Assert-Equal 0 $code Assert-Equal 0 $code
$names = @(Get-ArchiveTopLevelNames -ArchivePath $archive -SevenZip $sevenZip) $names = @(Get-BakNRetArchiveTopLevelNames -ArchivePath $archive -SevenZip $sevenZip)
$sorted = @($names | Sort-Object) $sorted = @($names | Sort-Object)
Assert-Equal 2 $sorted.Count Assert-Equal 2 $sorted.Count
Assert-Equal 'Alpha' $sorted[0] Assert-Equal 'Alpha' $sorted[0]
@@ -1203,13 +1203,13 @@ Test-Case '口令不会进日志:DEBUG 下打印的命令行要遮蔽 -p 参
$sentinel = 'SENTINEL-PW-9f3a' $sentinel = 'SENTINEL-PW-9f3a'
$logPath = $null $logPath = $null
try { try {
$logPath = Start-BaknretLog -Directory $sandbox -Prefix 'secret' $logPath = Start-BakNRetLog -Directory $sandbox -Prefix 'secret'
Set-BaknretDebug Set-BakNRetDebug
$null = Invoke-ExternalCommand -FilePath 'cmd.exe' -ArgumentList @('/c', 'exit 0', "-p$sentinel") $null = Invoke-ExternalCommand -FilePath 'cmd.exe' -ArgumentList @('/c', 'exit 0', "-p$sentinel")
} }
finally { finally {
Set-BaknretDebug -Enabled:$false Set-BakNRetDebug -Enabled:$false
Stop-BaknretLog Stop-BakNRetLog
} }
$logText = Get-Content -Encoding UTF8 -LiteralPath $logPath -Raw $logText = Get-Content -Encoding UTF8 -LiteralPath $logPath -Raw
@@ -1236,7 +1236,7 @@ Test-Case '空目录的摘要给 0 而不是 $null(否则空间守卫会静默
$empty = Join-Path $env:TEMP ("bnr-empty-" + [guid]::NewGuid().ToString('N').Substring(0, 8)) $empty = Join-Path $env:TEMP ("bnr-empty-" + [guid]::NewGuid().ToString('N').Substring(0, 8))
New-Item -ItemType Directory -Path $empty -Force | Out-Null New-Item -ItemType Directory -Path $empty -Force | Out-Null
try { try {
$summary = Get-FolderSummary -FolderPath $empty $summary = Get-BakNRetFolderSummary -FolderPath $empty
Assert-Equal 0 $summary.FileCount Assert-Equal 0 $summary.FileCount
Assert-True ($null -ne $summary.TotalSize) 'TotalSize 不能是 $null' Assert-True ($null -ne $summary.TotalSize) 'TotalSize 不能是 $null'
Assert-True ($summary.TotalSize -is [long]) 'TotalSize 应当是整数' Assert-True ($summary.TotalSize -is [long]) 'TotalSize 应当是整数'
@@ -1252,11 +1252,11 @@ Test-Case '原子替换:成功时新内容到位且不留 .tmp;失败时旧
New-Item -ItemType Directory -Path $base -Force | Out-Null New-Item -ItemType Directory -Path $base -Force | Out-Null
$target = Join-Path $base 'text.json' $target = Join-Path $base 'text.json'
try { try {
Write-BaknretAtomicText -Path $target -Text 'FIRST' | Out-Null Write-BakNRetAtomicText -Path $target -Text 'FIRST' | Out-Null
Assert-Equal 'FIRST' (Get-Content -Encoding UTF8 -LiteralPath $target -Raw) Assert-Equal 'FIRST' (Get-Content -Encoding UTF8 -LiteralPath $target -Raw)
# 目标已存在时走 File.Replace # 目标已存在时走 File.Replace
Write-BaknretAtomicText -Path $target -Text 'SECOND' | Out-Null Write-BakNRetAtomicText -Path $target -Text 'SECOND' | Out-Null
Assert-Equal 'SECOND' (Get-Content -Encoding UTF8 -LiteralPath $target -Raw) Assert-Equal 'SECOND' (Get-Content -Encoding UTF8 -LiteralPath $target -Raw)
Assert-FileMissing "$target.tmp" Assert-FileMissing "$target.tmp"
@@ -1264,7 +1264,7 @@ Test-Case '原子替换:成功时新内容到位且不留 .tmp;失败时旧
# 后者失败时旧文件已经没了,而 manifest 丢了就只能靠文件名反推。 # 后者失败时旧文件已经没了,而 manifest 丢了就只能靠文件名反推。
(Get-Item -LiteralPath $target).IsReadOnly = $true (Get-Item -LiteralPath $target).IsReadOnly = $true
$threw = $false $threw = $false
try { Write-BaknretAtomicText -Path $target -Text 'SHOULD-NOT-LAND' | Out-Null } catch { $threw = $true } try { Write-BakNRetAtomicText -Path $target -Text 'SHOULD-NOT-LAND' | Out-Null } catch { $threw = $true }
Assert-True $threw '目标只读时替换应当抛错' Assert-True $threw '目标只读时替换应当抛错'
Assert-Equal 'SECOND' (Get-Content -Encoding UTF8 -LiteralPath $target -Raw) Assert-Equal 'SECOND' (Get-Content -Encoding UTF8 -LiteralPath $target -Raw)
} }
@@ -1286,22 +1286,22 @@ Test-Case '运行锁:同一份备份目录同时只能有一个持有者' {
$first = $null $first = $null
$second = $null $second = $null
try { try {
$first = Enter-BaknretRunLock -Directory $dir $first = Enter-BakNRetRunLock -Directory $dir
Assert-True ($null -ne $first) '第一次应当拿到锁' Assert-True ($null -ne $first) '第一次应当拿到锁'
$second = Enter-BaknretRunLock -Directory $dir $second = Enter-BakNRetRunLock -Directory $dir
Assert-True ($null -eq $second) '同一目录的第二次不应当拿到锁' Assert-True ($null -eq $second) '同一目录的第二次不应当拿到锁'
Assert-FileExists (Get-BaknretRunLockPath -Directory $dir) Assert-FileExists (Get-BakNRetRunLockPath -Directory $dir)
} }
finally { finally {
Exit-BaknretRunLock -Lock $second Exit-BakNRetRunLock -Lock $second
Exit-BaknretRunLock -Lock $first Exit-BakNRetRunLock -Lock $first
} }
# 锁文件是**独占**打开的(FileShare.None),内容只能在释放之后读 —— 而它本来就该 # 锁文件是**独占**打开的(FileShare.None),内容只能在释放之后读 —— 而它本来就该
# 留在那里给排查的人看:"到底是谁占着"这个问题不该靠猜。 # 留在那里给排查的人看:"到底是谁占着"这个问题不该靠猜。
$info = Get-Content -Encoding UTF8 -LiteralPath (Get-BaknretRunLockPath -Directory $dir) -Raw $info = Get-Content -Encoding UTF8 -LiteralPath (Get-BakNRetRunLockPath -Directory $dir) -Raw
Assert-True ($info -match "pid=$PID") "锁文件里应当写明持有进程,实际内容是:$info" Assert-True ($info -match "pid=$PID") "锁文件里应当写明持有进程,实际内容是:$info"
Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction SilentlyContinue
@@ -1310,13 +1310,13 @@ Test-Case '运行锁:同一份备份目录同时只能有一个持有者' {
Test-Case '运行锁:释放之后可以重新取得' { Test-Case '运行锁:释放之后可以重新取得' {
$dir = Join-Path $env:TEMP ("bnr-lock-" + [guid]::NewGuid().ToString('N').Substring(0, 8)) $dir = Join-Path $env:TEMP ("bnr-lock-" + [guid]::NewGuid().ToString('N').Substring(0, 8))
try { try {
$first = Enter-BaknretRunLock -Directory $dir $first = Enter-BakNRetRunLock -Directory $dir
Assert-True ($null -ne $first) '第一次应当拿到锁' Assert-True ($null -ne $first) '第一次应当拿到锁'
Exit-BaknretRunLock -Lock $first Exit-BakNRetRunLock -Lock $first
$second = Enter-BaknretRunLock -Directory $dir $second = Enter-BakNRetRunLock -Directory $dir
Assert-True ($null -ne $second) '释放之后应当能重新拿到锁' Assert-True ($null -ne $second) '释放之后应当能重新拿到锁'
Exit-BaknretRunLock -Lock $second Exit-BakNRetRunLock -Lock $second
} }
finally { finally {
Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction SilentlyContinue
+7 -7
View File
@@ -51,14 +51,14 @@ if (-not $BackupListPath) { $BackupListPath = Join-Path $projectRoot 'BackupList
Import-Module (Join-Path $projectRoot 'BakNRet\BakNRet.psd1') -Force Import-Module (Join-Path $projectRoot 'BakNRet\BakNRet.psd1') -Force
if (-not $ConfigPath) { $ConfigPath = Join-Path $projectRoot 'BackupConfig.psd1' } if (-not $ConfigPath) { $ConfigPath = Join-Path $projectRoot 'BackupConfig.psd1' }
$config = Get-BaknretConfig -Path $ConfigPath $config = Get-BakNRetConfig -Path $ConfigPath
if (-not $BackupDir) { if (-not $BackupDir) {
$BackupDir = $config.BackupDir $BackupDir = $config.BackupDir
if (-not [System.IO.Path]::IsPathRooted($BackupDir)) { $BackupDir = Join-Path $projectRoot $BackupDir } if (-not [System.IO.Path]::IsPathRooted($BackupDir)) { $BackupDir = Join-Path $projectRoot $BackupDir }
} }
$catalogPath = Resolve-CatalogPath -Configured $config.SoftwareCatalog -Root $projectRoot $catalogPath = Resolve-BakNRetCatalogPath -Configured $config.SoftwareCatalog -Root $projectRoot
$manifestPath = Join-Path $BackupDir 'manifest.json' $manifestPath = Join-Path $BackupDir 'manifest.json'
$supportedFormats = @('.7z', '.rar', '.zip', '.tar') $supportedFormats = @('.7z', '.rar', '.zip', '.tar')
@@ -85,7 +85,7 @@ function Find-ArchiveByBaseName {
# 阶段一:规划 # 阶段一:规划
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
$manifestOld = Read-BaknretManifest -Path $manifestPath $manifestOld = Read-BakNRetManifest -Path $manifestPath
# manifest 里的历史归档名按 source / resolvedSource 建索引: # manifest 里的历史归档名按 source / resolvedSource 建索引:
# 条目写法改过(软件名 -> 手写路径、改名、合并)之后,键对不上了, # 条目写法改过(软件名 -> 手写路径、改名、合并)之后,键对不上了,
@@ -121,7 +121,7 @@ foreach ($line in (Get-Content -Encoding UTF8 -LiteralPath $BackupListPath)) {
$item = ConvertFrom-BackupListLine -Line $line $item = ConvertFrom-BackupListLine -Line $line
if (-not $item) { continue } if (-not $item) { continue }
$resolved = Resolve-BackupEntry -Entry $item -CatalogPath $catalogPath -MaxDepth $config.CatalogMaxDepth $resolved = Resolve-BakNRetBackupEntry -Entry $item -CatalogPath $catalogPath -MaxDepth $config.CatalogMaxDepth
$newName = $resolved.BaseName $newName = $resolved.BaseName
if (-not $newName) { continue } if (-not $newName) { continue }
@@ -143,11 +143,11 @@ foreach ($line in (Get-Content -Encoding UTF8 -LiteralPath $BackupListPath)) {
$pathSource = if ($slots.Count -eq 1) { $slots[0].Declared } else { $null } $pathSource = if ($slots.Count -eq 1) { $slots[0].Declared } else { $null }
} }
if ($pathSource) { if ($pathSource) {
$derived = Get-BackupBaseName -RawPath $pathSource $derived = Get-BakNRetBackupBaseName -RawPath $pathSource
if ($derived) { $candidates += $derived } if ($derived) { $candidates += $derived }
} }
if ($resolved.IsName) { if ($resolved.IsName) {
$candidates += (Format-CatalogName -Name $item.Path) $candidates += (Format-BakNRetCatalogName -Name $item.Path)
} }
if ($manifestOld.items.Contains($newName)) { if ($manifestOld.items.Contains($newName)) {
$recorded = $manifestOld.items[$newName] $recorded = $manifestOld.items[$newName]
@@ -316,7 +316,7 @@ $manifestNew = [pscustomobject]@{
compressor = $manifestOld.compressor compressor = $manifestOld.compressor
items = $rebuilt items = $rebuilt
} }
Write-BaknretManifest -Path $manifestPath -Manifest $manifestNew | Out-Null Write-BakNRetManifest -Path $manifestPath -Manifest $manifestNew | Out-Null
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 阶段四:报告 # 阶段四:报告
+3 -3
View File
@@ -1,4 +1,4 @@
# tools\lab —— BakNRet 的隔离测试环境(Hyper-V 真机级 VM) # tools\lab —— BakNRet 的隔离测试环境(Hyper-V 真机级 VM)
在**宿主机之外的 Windows 虚拟机**里跑 BakNRet 的备份 / 恢复 / 测试。宿主机仓库、`Backups\`、 在**宿主机之外的 Windows 虚拟机**里跑 BakNRet 的备份 / 恢复 / 测试。宿主机仓库、`Backups\`、
`logs\` 在本环境里只被读取,从不写入;VM 内也没有挂载宿主机的任何目录(一切交互走 `logs\` 在本环境里只被读取,从不写入;VM 内也没有挂载宿主机的任何目录(一切交互走
@@ -138,14 +138,14 @@ gsudo pwsh -NoProfile -File .\tools\lab\Lab.ps1 reset # 秒回 clean-baseli
### 在 VM 里直接跑 `tests\Run-Pester.ps1` 会红 8 项(都是中文断言) ### 在 VM 里直接跑 `tests\Run-Pester.ps1` 会红 8 项(都是中文断言)
`tests\BakNRet*.Tests.ps1` 里的 `Invoke-BaknretScript` 这样抓子进程输出: `tests\BakNRet*.Tests.ps1` 里的 `Invoke-BakNRetScript` 这样抓子进程输出:
``` ```
cmd /c pwsh -File Backup.ps1 ... > out.txt 2>&1 cmd /c pwsh -File Backup.ps1 ... > out.txt 2>&1
Get-Content -LiteralPath out.txt -Encoding UTF8 Get-Content -LiteralPath out.txt -Encoding UTF8
``` ```
而 `Backup.ps1` / `Restore.ps1` 的 `Write-Log` 走 `Write-Host`,写进 `out.txt` 的**字节编码取自 而 `Backup.ps1` / `Restore.ps1` 的 `Write-BakNRetLog` 走 `Write-Host`,写进 `out.txt` 的**字节编码取自
`[Console]::OutputEncoding`**: `[Console]::OutputEncoding`**:
| 环境 | `[Console]::OutputEncoding` | 结果 | | 环境 | `[Console]::OutputEncoding` | 结果 |
+5 -5
View File
@@ -101,7 +101,7 @@ function Get-SecurityFingerprint {
return ('O={0} G={1} P={2} [{3}]' -f $acl.GetOwner($sid).Value, $acl.GetGroup($sid).Value, $acl.AreAccessRulesProtected, ($aces -join ' ')) return ('O={0} G={1} P={2} [{3}]' -f $acl.GetOwner($sid).Value, $acl.GetGroup($sid).Value, $acl.AreAccessRulesProtected, ($aces -join ' '))
} }
function Invoke-BaknretChild { function Invoke-BakNRetChild {
<# <#
.SYNOPSIS .SYNOPSIS
用独立进程跑 Backup.ps1 / Restore.ps1(两个脚本结尾都会 exit)。 用独立进程跑 Backup.ps1 / Restore.ps1(两个脚本结尾都会 exit)。
@@ -190,7 +190,7 @@ function Remove-TreeHard {
Where-Object { $_.Attributes -band [IO.FileAttributes]::ReparsePoint }) Where-Object { $_.Attributes -band [IO.FileAttributes]::ReparsePoint })
foreach ($link in $links) { foreach ($link in $links) {
$null = Invoke-NativeTolerant -FilePath 'cmd.exe' -ArgumentList @('/c', ('rmdir "{0}"' -f $link.FullName)) $null = Invoke-NativeTolerant -FilePath 'cmd.exe' -ArgumentList @('/c', ('rmdir "{0}"' -f $link.FullName))
Remove-BaknretJunction -Path $link.FullName Remove-BakNRetJunction -Path $link.FullName
} }
$null = Invoke-NativeTolerant -FilePath 'cmd.exe' -ArgumentList @('/c', ('rmdir /s /q "{0}"' -f $Path)) $null = Invoke-NativeTolerant -FilePath 'cmd.exe' -ArgumentList @('/c', ('rmdir /s /q "{0}"' -f $Path))
@@ -217,7 +217,7 @@ else {
$BackupDir = Join-Path $WorkRoot 'backups' $BackupDir = Join-Path $WorkRoot 'backups'
New-Item -ItemType Directory -Path $BackupDir -Force | Out-Null New-Item -ItemType Directory -Path $BackupDir -Force | Out-Null
$privileges = Enable-BaknretPrivilege -Name @('SeRestorePrivilege', 'SeBackupPrivilege') $privileges = Enable-BakNRetPrivilege -Name @('SeRestorePrivilege', 'SeBackupPrivilege')
if ($privileges.Missing.Count -gt 0) { if ($privileges.Missing.Count -gt 0) {
Write-Host ('[acl] 警告:{0} 不在令牌里 —— 属主无法恢复,B 段会失败(应以管理员/SYSTEM 运行)' -f ($privileges.Missing -join '、')) -ForegroundColor Yellow Write-Host ('[acl] 警告:{0} 不在令牌里 —— 属主无法恢复,B 段会失败(应以管理员/SYSTEM 运行)' -f ($privileges.Missing -join '、')) -ForegroundColor Yellow
} }
@@ -398,7 +398,7 @@ $configText = @"
Write-Host '' Write-Host ''
Write-Host '[备份] Backup.ps1' -ForegroundColor Yellow Write-Host '[备份] Backup.ps1' -ForegroundColor Yellow
$backup = Invoke-BaknretChild -Script (Join-Path $RepoPath 'Backup.ps1') -Parameters @{ $backup = Invoke-BakNRetChild -Script (Join-Path $RepoPath 'Backup.ps1') -Parameters @{
BackupListPath = $listPath BackupListPath = $listPath
ConfigPath = $configPath ConfigPath = $configPath
BackupDir = $BackupDir BackupDir = $BackupDir
@@ -420,7 +420,7 @@ Test-Scenario '源已删除(模拟真的丢了)' ($leftovers.Count -eq 0) ($
Write-Host '' Write-Host ''
Write-Host '[恢复] Restore.ps1' -ForegroundColor Yellow Write-Host '[恢复] Restore.ps1' -ForegroundColor Yellow
$restore = Invoke-BaknretChild -Script (Join-Path $RepoPath 'Restore.ps1') -Parameters @{ $restore = Invoke-BakNRetChild -Script (Join-Path $RepoPath 'Restore.ps1') -Parameters @{
BackupListPath = $listPath BackupListPath = $listPath
ConfigPath = $configPath ConfigPath = $configPath
BackupDir = $BackupDir BackupDir = $BackupDir
+2 -2
View File
@@ -3,12 +3,12 @@
在 VM 内以 UTF-8 控制台编码运行一个测试套件(不改仓库里的任何测试代码)。 在 VM 内以 UTF-8 控制台编码运行一个测试套件(不改仓库里的任何测试代码)。
.DESCRIPTION .DESCRIPTION
为什么需要它 —— tests\BakNRet*.Tests.ps1 的 Invoke-BaknretScript 是这么抓子进程输出的: 为什么需要它 —— tests\BakNRet*.Tests.ps1 的 Invoke-BakNRetScript 是这么抓子进程输出的:
cmd /c pwsh -File Backup.ps1 ... > out.txt 2>&1 cmd /c pwsh -File Backup.ps1 ... > out.txt 2>&1
Get-Content -LiteralPath out.txt -Encoding UTF8 Get-Content -LiteralPath out.txt -Encoding UTF8
而 Backup.ps1 / Restore.ps1 的 Write-Log 走 Write-Host,写进 out.txt 的字节用的是 而 Backup.ps1 / Restore.ps1 的 Write-BakNRetLog 走 Write-Host,写进 out.txt 的字节用的是
`[Console]::OutputEncoding`: `[Console]::OutputEncoding`:
* 宿主机上它是 utf-8 -> 文件是 UTF-8 -> 按 UTF-8 读回,中文正确,套件全绿; * 宿主机上它是 utf-8 -> 文件是 UTF-8 -> 按 UTF-8 读回,中文正确,套件全绿;