diff --git a/.gitignore b/.gitignore index 554d2dd..ce50799 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,12 @@ logs/ # 测试用的本地依赖(Pester 等,见 tools/Install-TestDependencies.ps1) .tools/ +# 口令与私钥文件绝不进版本库。 +# BackupConfig.psd1 的 PasswordFile 默认值为空:口令应放在仓库之外 +# (用 $env:BAKNRET_PASSWORD,或用 -KeyFile 指向仓库外的文件)。 +*.key +*.pfx + # 编辑器 / 系统杂项 .vscode/ *.swp diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..bf28ee2 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,15 @@ +# AGENTS.md + +本文件是本仓库给编码 agent 的入口约定。人看的说明在 `README.md`。 + +## Agent skills + +### Issue tracker + +议题与 spec 是 `.scratch/` 下的 markdown 文件(一个特性一个目录,issue 一个 ticket 一个文件)。 +见 `docs/agents/issue-tracker.md`。 + +### Domain docs + +单上下文:根目录 `CONTEXT.md` + `docs/adr/`(两个都还不存在,属于正常——按需懒创建)。 +见 `docs/agents/domain.md`。 diff --git a/Backup.ps1 b/Backup.ps1 index 5647937..fe51df4 100644 --- a/Backup.ps1 +++ b/Backup.ps1 @@ -16,6 +16,12 @@ 跳过和失败从此有据可查,而不是只剩一行滚过去的控制台告警。 5. 结尾按失败数 exit,并写日志文件,计划任务能正确判断成败。 6. 磁盘空间守卫:放不下就拒绝该条目,低于阈值则告警。 + + 与 SoftwareCatalog.psd1 的 Slot 结构配套: + * 一个软件 = 一个归档,归档内是 `\<该 Path 的内容>`; + * 打包前用暂存目录 + junction / 硬链接把 Slot 名变成包里真实的目录名 + (7z 没有"入库时改名"的能力),打包后立刻拆掉暂存目录; + * 清单行首 `+` = 仅备份、`-` = 仅恢复。 #> [CmdletBinding()] @@ -116,7 +122,11 @@ if (-not (Test-Path -LiteralPath $BackupDir)) { } if (-not (Test-Path -LiteralPath $BackupListPath)) { - $template = "# BackupList.txt`n# 语法: <路径> [ :: <排除模式>[,<排除模式>...] ] [ @<标记> ]`n# 示例: %UserProfile%\.ssh`n" + $template = "# BackupList.txt`n" + + "# 语法: [+|-] <软件名 或 绝对路径> [:: <路径>] [:- <排除模式>[,<排除模式>...]] [:+ <追加项>[,<追加项>...]] [:encrypt | :!encrypt] [@ ='<值>'] [# 说明]`n" + + "# 示例: Edge`n" + + "# %UserProfile%\.ssh :encrypt`n" + + "# 完整语法见 README 与 BackupList.txt 自身的注释。`n" [System.IO.File]::WriteAllText($BackupListPath, $template, [System.Text.UTF8Encoding]::new($false)) Write-Log '模板 BackupList.txt 已创建,请编辑后重试。' -Level INFO Stop-BaknretLog @@ -148,6 +158,8 @@ $toolQuietArgument = if ($showToolOutput) { @() } else { @('-bso0', '-bsp0') } $lines = Get-Content -LiteralPath $BackupListPath $seenBaseNames = @{} $processed = 0; $skipped = 0; $failed = 0; $planned = 0 +$securityErrorCount = 0 # 有条目"安全描述符里有读不到的对象" +$securityFailed = 0 # 有条目"安全描述符完全没存下来" $failures = @() $freeSpaceGB = Get-BaknretFreeSpaceGB -Path $BackupDir if ($freeSpaceGB -ge 0) { @@ -179,6 +191,7 @@ function New-ItemRecord { source = $Source resolvedSource = $ResolvedSource roots = @() + layouts = @() catalog = $null archive = $null action = $null @@ -192,6 +205,7 @@ function New-ItemRecord { warnings = $false attemptWarnings = $false encrypted = $false + security = $null sourceFiles = $null sourceBytes = $null archiveBytes = $null @@ -221,6 +235,12 @@ function Save-ItemRecord { $Record.warnings = [bool]$previous.warnings } + # security 描述的是"当前在位的归档"的旁挂文件,和 warnings 同理: + # 只有真的换了归档才更新它,否则跳过的那次会把已有记录清成 $null。 + if ($Action -ne 'backed-up' -and $previous -and ($previous.PSObject.Properties.Name -contains 'security')) { + $Record.security = $previous.security + } + if ($previous) { if ($previous.PSObject.Properties.Name -contains 'lastSuccessAt') { $Record.lastSuccessAt = $previous.lastSuccessAt } if ($previous.PSObject.Properties.Name -contains 'successCount') { $Record.successCount = [int]$previous.successCount } @@ -240,14 +260,17 @@ function Save-ItemRecord { # 压缩 + 校验 + 原子替换;返回 @{ Ok; ExitCode; Warnings; Reason } # -# $SourceGroups 支持"一个软件包含多个目录":每个元素是 -# @{ ParentDir; RelativePaths; Label }。7z/RAR 对同一归档多次 `a` 会把内容并入, -# 所以按父目录分组、逐组追加,归档里每个目录仍保留自己的名字与层级。 +# 归档内容由调用方决定:它已经用 New-BaknretArchiveStaging 把每个归档项按"归档内的名字" +# 挂进了 $StagingRoot(目录走 junction、文件走硬链接/复制),所以这里只做三件事: +# 1. 以暂存目录为工作目录调用压缩工具,把项名加进去; +# 2. 用 7z t 校验,并核对多 Slot 条目的每个顶层名字都真的在包里; +# 3. 有警告时按保护策略决定是否原子替换。 function Invoke-BackupItem { param( - [Parameter(Mandatory = $true)][array]$SourceGroups, + [Parameter(Mandatory = $true)][array]$SourceItems, + [Parameter(Mandatory = $true)][string]$StagingRoot, [Parameter(Mandatory = $true)][string]$FinalPath, - [string[]]$ExcludePatterns, + [string[]]$ExcludePatterns = @(), [switch]$UseEncryption, [switch]$ProtectPrevious, [switch]$AcceptWarnings @@ -258,87 +281,62 @@ function Invoke-BackupItem { $warnings = $false $lastExitCode = 0 - $lastParentDir = $null + $itemNames = @($SourceItems | ForEach-Object { [string]$_.ArchivePath }) + $realPaths = @($SourceItems | ForEach-Object { [string]$_.RealPath }) try { - if ($SourceGroups.Count -eq 0) { + if ($SourceItems.Count -eq 0) { return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '没有可打包的源目录' } } - $groupIndex = 0 - foreach ($group in $SourceGroups) { - $groupIndex++ - $parentDir = $group.ParentDir - $relativePaths = @($group.RelativePaths) - if ($relativePaths.Count -eq 0) { continue } - $lastParentDir = $parentDir + if ($tool.Name -eq '7z') { + $optimized = Get-Optimized7zArgument -SourcePath $realPaths -Level $script:Config.CompressionLevel + $argument = @($optimized.Argument) + $toolQuietArgument + @($ExcludePatterns) - # 排除模式的**前缀用这一组的源目录名**(归档里就是这个层级)。 - $prefixName = if ($group.Label) { $group.Label } else { Split-Path -Path $relativePaths[0] -Leaf } - $excludeArgument = Get-ArchiveExcludeArgument -ItemName $prefixName -Patterns $ExcludePatterns - - if ($tool.Name -eq '7z') { - $probePath = "$($parentDir.TrimEnd('\'))\$($relativePaths[0])" - $optimized = Get-Optimized7zArgument -SourcePath $probePath -Level $script:Config.CompressionLevel - $argument = @($optimized.Argument) + $toolQuietArgument + $excludeArgument - - if ($UseEncryption) { - if (-not $password) { - return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '需要加密但取不到口令(见 README「加密」)' } - } - $argument += "-p$password" - if ($script:Config.Encryption.EncryptHeaders) { $argument += '-mhe=on' } + if ($UseEncryption) { + if (-not $password) { + return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '需要加密但取不到口令(见 README「加密」)' } } - - $argument += $tempPath - foreach ($relative in $relativePaths) { $argument += $relative } - - $exitCode = Invoke-ExternalCommand -FilePath $tool.Command -ArgumentList $argument -WorkingDirectory $parentDir - $lastExitCode = $exitCode - # 7z: 0 成功;1 警告(有文件读不到或跳过);2 及以上为失败 - if ($exitCode -ne 0 -and $exitCode -ne 1) { - return [pscustomobject]@{ Ok = $false; ExitCode = $exitCode; Warnings = $warnings; Reason = "第 $groupIndex 组($prefixName)压缩工具退出码 $exitCode" } - } - if ($exitCode -eq 1) { $warnings = $true } + $argument += "-p$password" + if ($script:Config.Encryption.EncryptHeaders) { $argument += '-mhe=on' } } - elseif ($tool.Name -eq 'RAR') { - $argument = @('a', '-m5', '-idp', '-idn') + $toolQuietArgument + $excludeArgument - if ($UseEncryption) { - if (-not $password) { - return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '需要加密但取不到口令' } - } - $argument += "-p$password" - } - $argument += $tempPath - foreach ($relative in $relativePaths) { $argument += $relative } - $exitCode = Invoke-ExternalCommand -FilePath $tool.Command -ArgumentList $argument -WorkingDirectory $parentDir - $lastExitCode = $exitCode - if ($exitCode -ne 0) { - return [pscustomobject]@{ Ok = $false; ExitCode = $exitCode; Warnings = $warnings; Reason = "第 $groupIndex 组($prefixName)压缩工具退出码 $exitCode" } - } + $argument += $tempPath + $argument += $itemNames + + $exitCode = Invoke-ExternalCommand -FilePath $tool.Command -ArgumentList $argument -WorkingDirectory $StagingRoot + $lastExitCode = $exitCode + # 7z: 0 成功;1 警告(有文件读不到或跳过);2 及以上为失败 + if ($exitCode -ne 0 -and $exitCode -ne 1) { + return [pscustomobject]@{ Ok = $false; ExitCode = $exitCode; Warnings = $warnings; Reason = "压缩工具退出码 $exitCode" } } - else { - if ($UseEncryption) { - return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '内置 ZIP 不支持加密,请改用 7z 或去掉 encrypt 标记' } - } - # Compress-Archive 不能追加;多组时逐组重打(先把已有临时归档解开再合并会让代码复杂得多, - # 而 ZIP 本来就是降级路径,这里只保证内容完整) - $fullPaths = @($relativePaths | ForEach-Object { Join-Path $parentDir $_ }) - if ($groupIndex -gt 1 -and (Test-Path -LiteralPath $tempPath)) { - $staging = Join-Path $env:TEMP ("bnr-zip-" + [guid]::NewGuid().ToString('N')) - New-Item -ItemType Directory -Path $staging -Force | Out-Null - try { - Expand-Archive -LiteralPath $tempPath -DestinationPath $staging -Force - $fullPaths += @(Get-ChildItem -LiteralPath $staging -Force | Select-Object -ExpandProperty FullName) - Compress-Archive -Path $fullPaths -DestinationPath $tempPath -CompressionLevel Optimal -Force - } finally { - Remove-Item -LiteralPath $staging -Recurse -Force -ErrorAction SilentlyContinue - } - } else { - Compress-Archive -Path $fullPaths -DestinationPath $tempPath -CompressionLevel Optimal -Force + if ($exitCode -eq 1) { $warnings = $true } + } + elseif ($tool.Name -eq 'RAR') { + $argument = @('a', '-m5', '-idp', '-idn') + $toolQuietArgument + @($ExcludePatterns) + if ($UseEncryption) { + if (-not $password) { + return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '需要加密但取不到口令' } } + $argument += "-p$password" } + $argument += $tempPath + $argument += $itemNames + + $exitCode = Invoke-ExternalCommand -FilePath $tool.Command -ArgumentList $argument -WorkingDirectory $StagingRoot + $lastExitCode = $exitCode + if ($exitCode -ne 0) { + return [pscustomobject]@{ Ok = $false; ExitCode = $exitCode; Warnings = $warnings; Reason = "压缩工具退出码 $exitCode" } + } + } + else { + if ($UseEncryption) { + return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '内置 ZIP 不支持加密,请改用 7z 或去掉加密' } + } + # Compress-Archive 没有排除开关,也没有加密;这是降级路径,只保证内容完整。 + # 暂存目录里的名字就是归档内的名字,所以布局与 7z 分支一致。 + $fullPaths = @($SourceItems | ForEach-Object { Join-Path $StagingRoot $_.ArchivePath }) + Compress-Archive -Path $fullPaths -DestinationPath $tempPath -CompressionLevel Optimal -Force } if (-not (Test-Path -LiteralPath $tempPath)) { @@ -351,22 +349,22 @@ function Invoke-BackupItem { if ($UseEncryption -and $password) { $verifyArgument += "-p$password" } $verifyArgument += $tempPath - $verifyCode = Invoke-ExternalCommand -FilePath $tool.Command -ArgumentList $verifyArgument -WorkingDirectory $lastParentDir + $verifyCode = Invoke-ExternalCommand -FilePath $tool.Command -ArgumentList $verifyArgument -WorkingDirectory $StagingRoot if ($verifyCode -ne 0) { Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue return [pscustomobject]@{ Ok = $false; ExitCode = $verifyCode; Warnings = $false; Reason = "归档校验失败(7z t 退出码 $verifyCode),已丢弃临时文件" } } Write-Log '归档校验通过(7z t)' -Level DEBUG - # 多目录时确认每个目录都真的进了归档:7z 的"警告"可能只体现在某一组里 - if ($SourceGroups.Count -gt 1) { + # 多个 Slot / 追加项时确认每一个顶层名字都真的进了归档:7z 的"警告"可能只体现在某一部分上 + if ($SourceItems.Count -gt 1) { $listed = @(Get-ArchiveTopLevelNames -ArchivePath $tempPath -SevenZip $tool.Command -Password $(if ($UseEncryption) { $password } else { $null })) if ($listed.Count -gt 0) { - $expected = @($SourceGroups | ForEach-Object { Split-Path -Path $_.RelativePaths[0] -Leaf }) + $expected = @($SourceItems | ForEach-Object { [string]$_.TopName } | Select-Object -Unique) $absent = @($expected | Where-Object { $_ -notin $listed }) if ($absent.Count -gt 0) { Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue - return [pscustomobject]@{ Ok = $false; ExitCode = $lastExitCode; Warnings = $true; Reason = ("归档缺少这些目录:{0}(归档内实际有:{1})" -f ($absent -join '、'), ($listed -join '、')) } + return [pscustomobject]@{ Ok = $false; ExitCode = $lastExitCode; Warnings = $true; Reason = ("归档缺少这些顶层条目:{0}(归档内实际有:{1})" -f ($absent -join '、'), ($listed -join '、')) } } } } @@ -419,16 +417,17 @@ foreach ($planLine in $lines) { $planResolved = Resolve-BackupEntry -Entry $planItem -CatalogPath $catalogPath -MaxDepth $script:Config.CatalogMaxDepth if (-not $planResolved.BaseName) { continue } if (-not (Test-ItemSelected -DisplayPath $planDisplayPath -BaseName $planResolved.BaseName)) { continue } + if ($planResolved.Direction -eq 'restore') { continue } if ($planResolved.Blocking) { continue } - $planSources = @($planResolved.Sources | Where-Object { Test-Path -LiteralPath $_.SourcePath }) - if ($planSources.Count -eq 0) { $spaceNoSource++; continue } + $planItems = @($planResolved.Items | Where-Object { Test-Path -LiteralPath $_.RealPath }) + if ($planItems.Count -eq 0) { $spaceNoSource++; continue } $planSourceBytes = [int64]0 $planSourceFiles = 0 $planLatest = $null - foreach ($planSource in $planSources) { - $planSummary = Get-FolderSummary -FolderPath $planSource.SourcePath + foreach ($planSource in $planItems) { + $planSummary = Get-FolderSummary -FolderPath $planSource.RealPath $planSourceBytes += [int64]$planSummary.TotalSize $planSourceFiles += [int]$planSummary.FileCount if ($planSummary.LatestModifiedTime -and (-not $planLatest -or $planSummary.LatestModifiedTime -gt $planLatest)) { @@ -533,29 +532,35 @@ foreach ($line in $lines) { continue } - $record = New-ItemRecord -BaseName $baseName -Source $displayPath -ResolvedSource $sourcePath -Phase 'backup' - $record.archive = $baseName + $tool.Extension - if ($resolved.CatalogEntry) { $record.catalog = $resolved.CatalogEntry.Path } - $finalPath = Join-Path $BackupDir $record.archive - - # root= 在 README 里被列为可用标记,但归档内的根目录实际上始终是源目录名 - # (见 README「设计取舍」:不套一层软件名目录)。7z 命令行也没有"入库时改名" - # 的能力,所以这里明确告警而不是让它静默失效——静默失效正是本次重构要消灭的东西。 - if (@($item.Flags | Where-Object { $_ -like 'root=*' }).Count -gt 0) { - Write-Log "警告: $displayPath 使用了 root= 标记,该功能尚未实现(归档内的根目录始终是源目录名),本次忽略" -Level WARN - } - - # 备份列表里写重了会生成两个同名归档,互相覆盖 —— 直接报错,不猜。 + # 归档名登记必须在方向过滤**之前**:`-`(仅恢复)的条目不会产生归档, + # 但它对应的归档是有主的,不能被下游的孤儿审计当成没人要的孤儿。 + # 备份列表里写重了会生成两个同名归档、互相覆盖 —— 直接报错,不猜。 if ($seenBaseNames.ContainsKey($baseName)) { $reason = "归档名 '$baseName' 与清单中的 '$($seenBaseNames[$baseName])' 重复(由 '$displayPath' 生成),两者会互相覆盖" Write-Log "失败: $displayPath,$reason" -Level ERROR + $record = New-ItemRecord -BaseName $baseName -Source $displayPath -ResolvedSource $sourcePath -Phase 'backup' Save-ItemRecord -Record $record -Action 'failed' -Reason $reason | Out-Null $failed++; $failures += $displayPath continue } $seenBaseNames[$baseName] = $displayPath - # 归档内顶层同名冲突:明确失败,绝不把两个目录静默搅进同一棵树 + if ($resolved.Direction -eq 'restore') { + Write-Log "跳过(行首 -,仅恢复): $displayPath" -Level INFO + continue + } + + $record = New-ItemRecord -BaseName $baseName -Source $displayPath -ResolvedSource $sourcePath -Phase 'backup' + $record.archive = $baseName + $tool.Extension + if ($resolved.CatalogEntry) { $record.catalog = $resolved.CatalogEntry.Path } + $finalPath = Join-Path $BackupDir $record.archive + + # root= 是历史标记:包内的一层目录现在由名录里的 Slot 决定,这里只告警不静默忽略。 + if (@($item.Flags | Where-Object { $_ -like 'root=*' }).Count -gt 0) { + Write-Log "警告: $displayPath 使用了 root= 标记。归档内的一层目录现在由 Slot 决定,该标记已废弃,本次忽略" -Level WARN + } + + # 归档内路径冲突:明确失败,绝不把两块内容静默搅进同一棵树 if ($resolved.Blocking) { Write-Log "失败: $displayPath,$($resolved.Blocking)" -Level ERROR Save-ItemRecord -Record $record -Action 'failed' -Reason $resolved.Blocking | Out-Null @@ -563,16 +568,22 @@ foreach ($line in $lines) { continue } - # 动手之前先把"这条会打包哪些目录、排除了什么、为什么"讲清楚 + # 动手之前先把"这条会打包哪些目录、归档里叫什么、排除了什么、为什么"讲清楚 + $planListExcludes = @() + $planCatalogExcludes = @() + if ($resolved.HasExcludeOverride) { + $planListExcludes = @($resolved.ExcludePatterns) + } else { + $planCatalogExcludes = @($resolved.Items | ForEach-Object { @($_.Exclude) } | Where-Object { $_ } | Select-Object -Unique) + } Write-BackupEntryPlan -Resolved $resolved -DisplayPath $displayPath ` - -ListExcludes @($item.ExcludePatterns) -ConfigExcludes @($script:Config.DefaultExcludes) ` - -Comment $item.Comment + -ListExcludes $planListExcludes -CatalogExcludes $planCatalogExcludes ` + -ConfigExcludes @($script:Config.DefaultExcludes) -Comment $item.Comment - # Sources 为空 = 解析不出任何源(名录里没这个软件名、或路径拆不出父/子级)。 + # Items 为空 = 解析不出任何归档项(名录里没这个软件名、或路径拆不出末级名)。 # 注意不能用 $resolved.Error 判断:名录里的路径不存在时 Error 有值, - # 但 Sources 是给出的(恢复端要靠它把内容还原回原位),备份端由下面的 - # 存在性检查统一处理。 - if ($resolved.Sources.Count -eq 0) { + # 但 Items 是给出的(恢复端要靠它把内容还原回原位),备份端由下面的存在性检查统一处理。 + if ($resolved.Items.Count -eq 0) { $reason = if ($resolved.Error) { $resolved.Error } else { '解析不出任何源路径' } Write-Log "跳过: $displayPath,$reason" -Level WARN Save-ItemRecord -Record $record -Action 'missing-source' -Reason $reason | Out-Null @@ -582,65 +593,50 @@ foreach ($line in $lines) { # 源存在性检查必须在 Get-FolderSummary / Get-Item 之前: # 两者对不存在的路径要么抛异常、要么返回会误导判断的空摘要。 - # 注意不能用 Join-Path 探测:目标盘符不存在时它会直接抛异常。 - # 源路径存在性以 SourcePath 为准:RelativePaths 是"归档里的名字", - # 目前两者一致,但 SourcePath 才是磁盘上的真实位置。 - $expectedRoots = 0 - $missingRoots = @() - foreach ($source in $resolved.Sources) { - $expectedRoots++ - if (-not (Test-Path -LiteralPath $source.SourcePath)) { $missingRoots += $source.SourcePath } - } + $missingItems = @($resolved.Items | Where-Object { -not (Test-Path -LiteralPath $_.RealPath) }) - if ($missingRoots.Count -ge $expectedRoots) { + if ($missingItems.Count -ge $resolved.Items.Count) { + $missingText = @($missingItems | ForEach-Object { $_.RealPath }) -join ';' Write-Log "跳过: $displayPath,源路径不存在" -Level WARN - Save-ItemRecord -Record $record -Action 'missing-source' -Reason ('源路径不存在:' + ($missingRoots -join ';')) | Out-Null + Save-ItemRecord -Record $record -Action 'missing-source' -Reason ('源路径不存在:' + $missingText) | Out-Null $skipped++ continue } - if ($missingRoots.Count -gt 0) { - Write-Log ("警告: {0} 有 {1} 个源路径不存在,本次只备份存在的部分:{2}" -f $displayPath, $missingRoots.Count, ($missingRoots -join ';')) -Level WARN + if ($missingItems.Count -gt 0) { + Write-Log ("警告: {0} 有 {1} 个归档项的源路径不存在,本次只打包存在的部分:{2}" -f ` + $displayPath, $missingItems.Count, (@($missingItems | ForEach-Object { $_.RealPath }) -join ';')) -Level WARN } # 归档里只放真实存在的源 - $liveSources = @() - foreach ($source in $resolved.Sources) { - if (Test-Path -LiteralPath $source.SourcePath) { - $liveSources += [pscustomobject]@{ - RootName = $source.RootName - ParentDir = $source.ParentDir - RelativePaths = @($source.RelativePaths) - SourcePath = $source.SourcePath - Description = $source.Description - Origin = $source.Origin + $liveItems = @($resolved.Items | Where-Object { Test-Path -LiteralPath $_.RealPath }) + + # 归档内的顶层条目名 = 真实存在的归档项在包内的第一层名字(Slot 名 / 源目录名)。 + # 这里记录可核对的事实,备份成功后还会用 Get-ArchiveTopLevelNames 与归档内容对账。 + $record.roots = @($liveItems | ForEach-Object { $_.TopName } | Select-Object -Unique) + + # 恢复端要知道每个项在归档里是目录还是文件:记在 manifest 里, + # 这样目标机器上目标还不存在(全新恢复)时也判断得出来。 + $record.layouts = @($liveItems | ForEach-Object { + [ordered]@{ + name = $_.ArchivePath + kind = $(if ($_.IsFile) { 'file' } else { 'dir' }) } - } - } + }) - # 归档内的顶层条目名 = 每个**真实存在**的源在归档里的第一层名字,也就是源目录 - # (或源文件)自己的名字。刻意不用 $resolved.Sources[].RootName:那套"归档内套一层 - # 软件名"的设想已按设计取舍放弃,实际布局始终是 <源目录名>\...。 - # 这里记录可核对的事实,之前写成软件名会让 Edge(实际是 "User Data")之类的条目对不上。 - $record.roots = @($liveSources | ForEach-Object { - $_.RelativePaths | ForEach-Object { ($_ -split '[\\/]')[0] } - } | Select-Object -Unique) - - $primarySource = $liveSources[0].SourcePath - $parentDir = $liveSources[0].ParentDir - # 排除模式的前缀始终用**源目录名**(归档里就是这个层级) - $itemName = Split-Path -Path $primarySource -Leaf - - if (-not $parentDir -or -not $itemName) { - Write-Log "跳过: $displayPath,无法处理根目录" -Level WARN - Save-ItemRecord -Record $record -Action 'invalid-path' -Reason '无法拆出父目录或末级名' | Out-Null + $primarySource = $liveItems[0].RealPath + if ([string]::IsNullOrWhiteSpace($primarySource)) { + Write-Log "跳过: $displayPath,无法确定主源路径" -Level WARN + Save-ItemRecord -Record $record -Action 'invalid-path' -Reason '归档项没有可用路径' | Out-Null $skipped++ continue } $summary = Get-FolderSummary -FolderPath $primarySource - foreach ($source in $liveSources[1..($liveSources.Count - 1)]) { - $extra = Get-FolderSummary -FolderPath $source.SourcePath + # 从第二个归档项开始累加。刻意不用 `$liveItems[1..($liveItems.Count-1)]`: + # 只有一项时 `1..0` 会退化成 `1,0`,把同一份源数两遍(旧实现的隐蔽 bug)。 + for ($index = 1; $index -lt $liveItems.Count; $index++) { + $extra = Get-FolderSummary -FolderPath $liveItems[$index].RealPath $summary.FileCount += $extra.FileCount $summary.TotalSize += $extra.TotalSize if ($extra.LatestModifiedTime -and $extra.LatestModifiedTime -gt $summary.LatestModifiedTime) { @@ -693,13 +689,38 @@ foreach ($line in $lines) { continue } - $useEncryption = $encryptAll -or ($item.Flags -contains 'encrypt') + $useEncryption = $encryptAll -or [bool]$resolved.Encrypt $record.encrypted = [bool]$useEncryption $startedAt = Get-Date $record.attemptedAt = $startedAt.ToString('o') - # 配置里的全局排除 + 本条目的排除 - $effectiveExcludes = @($script:Config.DefaultExcludes) + @($item.ExcludePatterns) + # 排除参数:条目级 `:-` / `@ Exclude` 覆盖优先,否则用名录里各 Slot 自己的 Exclude; + # 再叠上 BackupConfig.psd1 的 DefaultExcludes。 + # 模式先用 `<顶层名>\` 前缀分配到对应归档项上(`Scoop :- GlobalPersist\steam`), + # 再逐项翻译成 7z 的 -x! / -xr!,最后去重合并成一次调用的参数。 + $patternSource = if ($resolved.HasExcludeOverride) { + @($resolved.ExcludePatterns) + } else { + @($resolved.Items | ForEach-Object { @($_.Exclude) } | Where-Object { $_ } | Select-Object -Unique) + } + $allPatterns = @($script:Config.DefaultExcludes) + $patternSource + $scopeMap = Split-BaknretPatternScope -Items $liveItems -Patterns $allPatterns + + $excludeLists = @() + $excludeError = $null + for ($index = 0; $index -lt $liveItems.Count; $index++) { + $expanded = Get-BaknretExcludeArgument -Item $liveItems[$index] -Patterns @($scopeMap[$index]) + if ($expanded.Error) { $excludeError = $expanded.Error } + $excludeLists += , @($expanded.Arguments) + } + $effectiveExcludes = @(Merge-BaknretExcludeArgument -ArgumentLists $excludeLists) + + if ($excludeError) { + Write-Log "失败: $displayPath,$excludeError" -Level ERROR + Save-ItemRecord -Record $record -Action 'failed' -Reason $excludeError | Out-Null + $failed++; $failures += $displayPath + continue + } # 只有在"现有归档是完整的"时才值得保护它。没有 manifest 记录 # (本次重构之前留下的归档)时按完整处理——宁可保守。 @@ -711,21 +732,19 @@ foreach ($line in $lines) { } } - # 多目录:每个源组各带自己的父目录与相对名。7z 会对同一归档逐组追加。 - # Label 刻意留空:排除模式的前缀必须是**归档里的那一层名字**,也就是源目录名 - # (归档内布局是 `<源目录名>\...`)。若把软件名当 Label 传下去, - # 排除模式就会变成 `软件名\skip.bin`,与实际路径对不上而静默失效。 - $sourceGroups = @($liveSources | ForEach-Object { - [pscustomobject]@{ - ParentDir = $_.ParentDir - RelativePaths = @($_.RelativePaths) - Label = $null - } - }) - - $result = Invoke-BackupItem -SourceGroups $sourceGroups ` - -FinalPath $finalPath -ExcludePatterns $effectiveExcludes -UseEncryption:$useEncryption ` - -ProtectPrevious:$protectPrevious -AcceptWarnings:$AcceptWarnings + # 归档内的一层目录名由 Slot / 追加项的归档内路径决定,所以先把它们以正确的名字 + # 挂进暂存目录(junction / 硬链接),再让压缩工具以暂存目录为工作目录打包。 + $stagingRoot = $null + try { + $stagingRoot = New-BaknretArchiveStaging -Items $liveItems + $result = Invoke-BackupItem -SourceItems $liveItems -StagingRoot $stagingRoot ` + -FinalPath $finalPath -ExcludePatterns $effectiveExcludes -UseEncryption:$useEncryption ` + -ProtectPrevious:$protectPrevious -AcceptWarnings:$AcceptWarnings + } catch { + $result = [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = "准备归档内容失败:$_" } + } finally { + Remove-BaknretArchiveStaging -Root $stagingRoot + } $record.exitCode = $result.ExitCode $record.attemptWarnings = [bool]$result.Warnings @@ -748,6 +767,58 @@ foreach ($line in $lines) { Write-Log "备份成功: $baseName" -Level INFO } + # ------------------------------------------------------------------ + # 安全描述符(属主 / ACL)写进旁挂文件 <归档名>.acl.json + # ------------------------------------------------------------------ + # 归档格式装不下它(7z 的 -sni 官方说明是"只能写进 WIM 归档"),所以放在归档旁边, + # 和归档一样先写 .tmp 再原子替换。属主必须一起存:C:\ProgramData 的 ACL 里有 + # (A;OICIIO;GA;;;CO) CREATOR OWNER + inherit-only + GENERIC_ALL + # 而 CREATOR OWNER 是访问检查时才替换的占位符 —— 替换成"被检查对象的属主"。 + # 只回放 ACE 文本、不恢复属主,等于把"谁创建的东西谁有全权"里的"谁"换成跑脚本的账户。 + $securityMode = [string]$script:Config.Security.Mode + $securityFatal = $false + if ($securityMode -and ($securityMode -ne 'Off')) { + $sidecarName = "$baseName.acl.json" + $sidecarPath = Join-Path $BackupDir $sidecarName + try { + $capture = Get-BaknretSecurityRecords -Items $liveItems -ScopeMap $scopeMap -Mode $securityMode ` + -IncludeSacl:([bool]$script:Config.Security.IncludeSacl) + Save-BaknretSecuritySidecar -Path $sidecarPath -Records $capture.Records -Mode $securityMode ` + -IncludeSacl:([bool]$script:Config.Security.IncludeSacl) ` + -Errors $capture.Errors -Scanned $capture.Scanned | Out-Null + + $record.security = [ordered]@{ + file = $sidecarName + mode = $securityMode + objects = $capture.Kept + scanned = $capture.Scanned + errors = $capture.Errors + capturedAt = (Get-Date).ToString('o') + } + Write-Log ("安全描述符:{0} 个对象写进 {1}(扫描 {2} 个,读不到 {3} 个)" -f ` + $capture.Kept, $sidecarName, $capture.Scanned, $capture.Errors) -Level INFO + + if ($capture.Errors -gt 0) { + $securityErrorCount++ + $unreadable = @($capture.Records | Where-Object { $_.e } | Select-Object -First 3 -ExpandProperty p) + Write-Log (" {0} 个对象的安全描述符读不到(恢复后它们的属主/ACL 会是新建对象的默认值),例如:{1}" -f ` + $capture.Errors, ($unreadable -join '、')) -Level WARN + } + } catch { + $securityFailed++ + Write-Log "安全描述符采集/写盘失败:$displayPath —— $_" -Level WARN + $record.security = [ordered]@{ file = $sidecarName; error = "$_" } + if ([bool]$script:Config.Security.FailOnError) { $securityFatal = $true } + } + + if ($securityFatal) { + Write-Log "失败: $displayPath,归档已替换,但安全描述符没能存下来(Security.FailOnError = \$true)" -Level ERROR + Save-ItemRecord -Record $record -Action 'failed' -Reason '归档已替换,但安全描述符没能存下来' | Out-Null + $failed++; $failures += $displayPath + continue + } + } + if ($Hash -or $script:Config.ComputeHash) { $record.sha256 = (Get-FileHash -LiteralPath $finalPath -Algorithm SHA256).Hash Write-Log "SHA256: $($record.sha256)" -Level DEBUG @@ -790,6 +861,7 @@ if ($DryRun) { # 判据只用清单,**不能用 manifest**:manifest 会一直留着历史条目, # 于是"从清单里删掉某个条目(或把它合并进另一个条目)"留下的归档会被历史记录遮住, # 审计就永远不会报——那正是最需要报出来的情况。 +# $seenBaseNames 在方向过滤之前就登记,所以"行首 + / -"的条目也算有主。 # 只在整表运行时做:带 -Only/-Skip 时未选中的条目本来就不在 $seenBaseNames 里, # 那种情况下报出来的全是假孤儿。 if (-not $DryRun -and $Only.Count -eq 0 -and $Skip.Count -eq 0) { @@ -815,6 +887,13 @@ if ($failures.Count -gt 0) { foreach ($failure in $failures) { Write-Log " - $failure" -Level ERROR } } +if ($securityFailed -gt 0) { + Write-Log ("有 {0} 个条目的安全描述符完全没能存下来(manifest 的 security.error 里有原文)" -f $securityFailed) -Level WARN +} +if ($securityErrorCount -gt 0) { + Write-Log ("有 {0} 个条目存在'读不到安全描述符'的对象;恢复后这些对象的属主/ACL 是新建对象的默认值,可查 manifest 的 security.errors" -f $securityErrorCount) -Level WARN +} + $summaryText = "备份完成。成功: $processed, 跳过: $skipped, 失败: $failed" if ($DryRun) { $summaryText += ", 试运行计划: $planned" } Write-Log $summaryText -Level INFO diff --git a/BackupConfig.psd1 b/BackupConfig.psd1 index bed3097..390ec59 100644 --- a/BackupConfig.psd1 +++ b/BackupConfig.psd1 @@ -48,7 +48,8 @@ # # **本仓库不存放任何口令**,这里只记"去哪儿找": # Encryption.Enabled = $true -> 所有条目都加密 - # 或 BackupList.txt 里给单个条目加 @encrypt(如 .ssh @encrypt) + # SoftwareCatalog.psd1 的 Slot 写 Encrypt = $true(如 OpenSSH) + # 或 BackupList.txt 里给单个条目加 :encrypt(如 Edge :encrypt) # # 口令本身按以下优先级获取(见 README「加密」): # 1. -Password 命令行参数 @@ -60,10 +61,36 @@ # 注意 7z 只接受命令行口令,口令在本机进程列表里短暂可见,这是 7z 本身的限制。 Encryption = @{ Enabled = $false - PasswordFile = '' + PasswordFile = 'baknret.key' EncryptHeaders = $true } + # 安全描述符(NTFS 属主 / ACL)。 + # + # 归档格式装不下它:7-Zip 的 -sni 官方说明是"当前版本只能写进 WIM 归档", + # .7z 里一个字节的 ACL 都没有。所以每个归档旁边多一个 <归档名>.acl.json, + # 恢复时按它把属主 + 属组 + DACL 回放回去。 + # + # 为什么非要不可:C:\ProgramData 的 ACL 里有 (A;OICIIO;GA;;;CO) —— CREATOR OWNER + # 不是账户,是访问检查时才替换的占位符,替换成"被检查对象的属主"。只回放 ACE 文本、 + # 不恢复属主,等于把"谁创建的东西谁有全权"里的"谁"换成跑恢复脚本的那个账户, + # 原程序(服务账户 / 专用用户)反而没了读写权限。 + # + # Mode Off —— 完全不采集:恢复出来的属主/ACL 是新建对象的默认值 + # Full —— 每个对象都存(默认;正确性优先,几万文件的树 sidecar 几 MB) + # Smart —— 只存"继承复现不出来"的对象(体积优化,判据见代码,终究是启发式) + # Roots —— 只存每个归档项的根(最省,适合权限只在根上的场景) + # IncludeSacl 是否连审计规则(SACL)一起存取;读/写它需要 SeSecurityPrivilege + # SidMap 跨机恢复时的 SID 映射,例如: + # @{ 'S-1-5-21-1-2-3-1001' = 'S-1-5-21-9-8-7-1001' } + # FailOnError 安全描述符写盘失败时,是否把该条目算作失败(默认只告警并记进 manifest) + Security = @{ + Mode = 'Full' + IncludeSacl = $false + SidMap = @{} + FailOnError = $false + } + # 所有条目都生效的排除模式,语法同 BackupList.txt(! 开头 = 任意层级匹配组件名) DefaultExcludes = @( '!Thumbs.db' diff --git a/BackupList.txt b/BackupList.txt index 4c9fa2d..2be240b 100644 --- a/BackupList.txt +++ b/BackupList.txt @@ -1,96 +1,119 @@ -# BackupList.txt —— 备份 / 恢复共用清单 +########### +# BackupList.txt —— 备份清单 +########### # -# 每一行支持**两种写法**,混用没问题: +# 语法: +# [+|-] <目标> [修饰符...] [# 说明] # -# 1. 软件名(推荐)—— 去 SoftwareCatalog.psd1 查目录,归档名就是软件名 -# FooClolor -# scoop -# Kazumi :- !*Cache -# -# 2. 用户手写的目录 —— 含 `\`、`/` 或 `%` 就按路径处理,归档名沿用 <名>_from_<路径> -# %UserProfile%\Documents\PowerShell -# C:\Programs\MiFlash :- MiFlash\logs\ -# -# 3. 软件名 + @pathname —— 强制用旧的路径命名算法(归档名从路径算) -# FooClolor @pathname -# -# 两种写法都支持**追加**与**排除**: -# -# :+ 追加一个目录;写成软件名时会按名录展开成它的全部目录 -# %UserProfile%\Documents\PowerShell :+ D:\backup\ps-extra -# MiFlash :+ MiFlash_Unlock -# :+ 可以出现多次、位置随意;追加进来的目录与主目录一起打进同一个归档。 -# -# :- 排除模式(`::` 是它的历史别名,两者等价) -# Edge :- !*Cache,Default\Extensions -# `,` 与 `;` 都当分隔符。 -# -# 行尾可以写 `# 说明` 讲清这条为什么这么配;运行时会把它和目录介绍一起打印出来: -# Edge :- !*Cache # 缓存可再生,不进归档 -# -# 排除模式:相对归档根目录。以 ! 开头表示"任意层级下匹配这个组件名"(7z 的 -xr!)。 -# 不要自己写引号;模式里的空格会被自动转成 ?(7z 的模式不支持空格)。 # 标记: -# encrypt 用 7z 加密该归档(口令来自 BAKNRET_PASSWORD 或 -KeyFile) -# pathname 用路径命名算法而不是软件名 -# root=<名> 尚未实现(归档内根目录始终是源目录名),用了会告警 +# + 仅备份,不恢复。 +# - 仅恢复,不备份。 # -# 归档名 = 软件名,所以:**同一个软件不要写两遍**,脚本会直接报重复错误。 -# 软件名(连同排除规则、加密标记)都维护在 SoftwareCatalog.psd1 和本文件里, -# 两边都进 git,改动可追溯。 - -# ---- 用户配置 / 开发环境 ---- -legendary -opencode -# scoop 是一个软件名 + 对象数组(见 SoftwareCatalog.psd1):一个 scoop.7z 里 -# 同时装 %UserProfile%\scoop\persist 与 %UserProfile%\.config\scoop。 -scoop # scoop 各应用的持久化数据 + scoop 自身配置 -# .ssh 里是私钥。想加密就把下面那行 @encrypt 的注释互换(见 README「加密」) -.ssh -CodeSpace :- Shuery-Shuai\ImmortalWrt-BPI-R4-Firmware\immortalwrt\ # 排除同一仓库里的源码树 -PowerShell -WindowsPowerShell - -# ---- 应用数据 ---- -AutoDarkMode -Kazumi -piliplus -fnm -twinkle-tray - -# ---- 浏览器:排除可再生的缓存、遥测与扩展本体 ---- -# 解压后 4.22 GB / 25030 个文件里,下面这组排除会留下约 431 MB / 2164 个文件, -# 排除掉的 3.79 GB 全部可以重新生成:缓存、组件缓存、Service Worker、 -# 扩展本体(可从商店重装)、遥测与优化数据。 -# 书签/密码/偏好/历史,以及站点数据(IndexedDB / Local Storage)都保留。 -# 想再省 230 MB,可以把 Default\IndexedDB、Default\Local Storage、 -# Default\Session Storage、Default\blob_storage、Default\WebStorage 也加进来。 -# !*Cache 一次覆盖 Cache / Code Cache / GPUCache / DawnCache / GrShaderCache 等一批。 +# 目标(二选一): +# 软件名。查 SoftwareCatalog.psd1,归档名 = 软件名。 +# 绝对路径。含 `\`、`/` 或 `%` 时按路径处理。 # -# 注意:不带 ! 的普通模式是**相对归档根目录锚定**的(会展开成 `-x!User?Data\<模式>`), -# 所以它只排除根目录下那一份。Edge 的 OneAuth\WebView2\EBWebView\ 里还有一整套 -# 自己的 Crashpad / BrowserMetrics / ProvenanceData / optimization_guide, -# 根锚定模式碰不到它们 —— 这些可再生的东西一律用 ! 形式按组件名排除(-xr!),任意层级都命中。 -# 实测:根锚定的 Edge 归档 1781 MB / 27961 项 -> 改成 ! 形式后 72 MB / 2303 项, -# 书签、密码(Login Data)、Cookies、偏好、历史、IndexedDB / Local Storage 全部保留。 +# 修饰符(可多个,前后必须有空格): +# :: 覆盖 Path。同 `@ Path=''`。 +# 同一行中,:- / :+ 的模式相对覆盖后的 Path。 +# :- <模式>[,...] 排除。同 `@ Exclude='<模式>'`。 +# :+ <模式>[,...] 追加。同 `@ Include='<模式>'`。 +# 模式为两段式:<归档内相对路径>:<宿主机绝对路径>。 +# :encrypt 加密。同 `@ Encrypt='$true'`。 +# :!encrypt 不加密。同 `@ Encrypt='$false'`。 +# @ ='' 覆盖 SoftwareCatalog 中的默认字段。 # -# 注意:Edge 常驻时打包会有上百个文件读不到(含 Login Data / Cookies), -# 脚本检测到警告后不会用这份不完整的归档覆盖已有的完整归档。备份前建议先退出 Edge。 -Edge :- !*Cache,!component_crx_cache,!ProvenanceData,!optimization_guide,!Crashpad,!BrowserMetrics,Default\Service Worker,Default\Extensions,Default\ExtensionActivityEdge,Snapshots,Edge Sidebar,Edge Shopping # 下面这些全是可再生数据:缓存/组件缓存/SW/扩展本体/遥测与优化 -WindowsTerminal +# 说明: +# 行尾 `# 说明` 会在运行时与目录介绍一起打印。 +# 归档名 = 软件名,同一软件不要写两遍。 +# +# ---------------------------------------------------------------- # +# 模式(Pattern) +# ---------------------------------------------------------------- # +# +# 「模式」是传给 7z 的排除 / 包含匹配式,匹配的是**归档内的相对路径**, +# 不是宿主机上的绝对路径。 +# +# 例:Edge 的 Path 是 `%LocalAppData%\Microsoft\Edge\User Data`, +# 归档根就是 `User Data\` 内部的内容。 +# 模式 `Default\Extensions` 匹配的是归档内的 +# `Default\Extensions\...`, +# 而不是宿主机上的 `C:\Users\...\Edge\User Data\Default\Extensions\...`。 +# +# 两种形态: +# +# <模式> 锚定在归档根。展开为 7z 的 `-x!\<模式>`。 +# 只匹配根下这一份。 +# +# !<模式> 任意层级。展开为 7z 的 `-xr!<模式>`。 +# 只要路径中任意一段命中,就排除。 +# +# 多数情况应使用 `!` 形式:根锚定常常够不着嵌套层级里的目标。 +# 例:Edge 的 `OneAuth\WebView2\EBWebView\` 里还有一整套 +# Crashpad / BrowserMetrics / ProvenanceData / optimization_guide, +# 根锚定模式碰不到,必须用 `!` 形式按组件名排除。 +# +# 通配符(7z 语法,非正则): +# +# * 任意多个字符(不含 `\`)。 +# ? 任意单个字符。 +# +# 不支持:正则、[] 字符类、{} 花括号扩展。 +# +# `!*Cache` 一次覆盖:Cache / Code Cache / GPUCache / DawnCache / +# GrShaderCache 等一批以 Cache 结尾的组件名。 +# +# 引号与空格: +# +# 模式里**不要自己写引号**,引号会被当成模式的一部分。 +# 模式里的空格会被自动转成 `?`(7z 的 -x! 参数不接受带空格的模式)。 +# 例:`Default\Service Worker` 会变成 `Default\Service?Worker`。 +# +# 多个模式: +# +# 用 `,` 或 `;` 分隔,等价于给 7z 传多个 -x! / -xr! 参数。 +# +# :+ 的两段式: +# +# <归档内相对路径>:<宿主机绝对路径> +# 把宿主机的目录追加到归档内的指定位置。 +# 例:`D:\extra\ps-modules:Modules` → 把 D:\extra\ps-modules +# 追加到归档内 `Modules\` 位置。 +# +# ---------------------------------------------------------------- # -# ---- 系统 ---- -Startup +# ---- 软件名 ---- -# ---- C:\Programs ---- -BaiduNetdisk -FooClolor -March7thAssistant :- 3rdparty\WebBrowser\UserProfile\Integrated,March7thAssistant\logs\ # 内置浏览器的缓存与日志,可再生 -MiFlash -MiFlash_Unlock -QuarkCloudDrive -translucenttb -ScoopApps-persist :- persist\ariang-native\UserData\DawnCache,persist\ariang-native\UserData\GPUCache,persist\ariang-native\UserData\Local Storage,persist\ariang-native\UserData\Session Storage # ariang 的缓存/会话数据,可再生 ++ AutoDarkMode # 备份文件还在,但目前不再使用 ++MicrosoftEdge ++FastNodeManager ++INZONEHub ++Kazumi ++Legendary @ Exclude='DefaultConfig\tmp' # 忽略临时文件夹 ++Mnemon ++OpenCode ++OpenSSH ++PiliPlus ++PowerShell @ Encrypt='$false' # 目前无敏感文件,无需加密 ++PowerToys +Scoop :- GlobalPersist\steam\steamapps # 忽略 Steam 安装的软件,可重下载 ++Startup ++SteamRomManager ++TranslucentTB ++ TwinkleTray # 备份文件还在,但目前不再使用 ++WindowsPowerShell :!encrypt # 目前无敏感文件,无需加密 ++WindowsTerminal -# ---- 其它盘 ---- -Aria +# ---- 自定义目录 ---- + ++ C:\Programs\BaiduNetdisk ++C:\Programs\FooColor ++C:\Programs\March7thAssistant :- '3rdparty\WebBrowser\UserProfile\Integrated,March7thAssistant\logs\' # 内置浏览器的缓存与日志,可再生 ++C:\Programs\MiFlash ++C:\Programs\MiFlash_Unlock ++C:\Programs\QuarkCloudDrive ++C:\Programs\ScoopApps\persist :- 'persist\ariang-native\UserData\DawnCache,persist\ariang-native\UserData\GPUCache,persist\ariang-native\UserData\Local Storage,persist\ariang-native\UserData\Session Storage' # ariang 的缓存/会话数据,可再生 + ++D:\UserData\Documents\Aria +- D:\UserData\Documents\CodeSpace :- 'Shuery-Shuai\ImmortalWrt-BPI-R4-Firmware\immortalwrt' # 仅在必要时备份 +-D:\Workspace # 仅在必要时备份 diff --git a/Common.psm1 b/Common.psm1 index 9cf44fa..5113d64 100644 --- a/Common.psm1 +++ b/Common.psm1 @@ -13,11 +13,26 @@ 模块内出现的备份清单语法(BackupList.txt 每一行): - <路径> [ :: <排除模式>[,<排除模式>...] ] [ @<标记>[,<标记>...] ] + [+|-] <软件名 或 绝对路径> [修饰符...] [# 说明] + [:: ] [:- <模式>[,...]] [:+ <包含项>[,...]] + [:encrypt | :!encrypt] [@ =''] - 路径可以用双引号包起来(引号只包路径)。分隔符统一以 `::` 为界, - 因为 `:` 在 Windows 路径里只可能作为盘符出现,`::` 不可能出现在真实路径中。 - 排除模式分隔符同时接受 `,` 和 `;`(历史文件两种都出现过)。 + 标记(必须是独立的空白分隔记号,前后都要有空格): + + 仅备份,不恢复(Restore.ps1 跳过) + - 仅恢复,不备份(Backup.ps1 跳过) + :: 覆盖 Path,等价于 `@ Path='...'` + :- 排除模式,等价于 `@ Exclude='...'` + :+ 追加包含项(<归档内相对路径>:<宿主机绝对路径>),等价于 `@ Include='...'` + :encrypt 该条目加密(`@ Encrypt='$true'`) + :!encrypt 该条目不加密(`@ Encrypt='$false'`) + @ Key='值' 覆盖 SoftwareCatalog.psd1 里的同名默认字段 + + 兼容的历史写法:`@encrypt` / `@!encrypt` / `@pathname` / `@root=<名>`, + 以及用双引号包住路径或模式值。 + + 归档内布局(SoftwareCatalog.psd1 的 Slot 是包内的一层目录): + 软件名条目 -> \<该 Path 的内容>(Path 是文件时就是名为 的文件) + 手写路径 -> <路径末级名>\...(历史布局,不变) #> $script:LogConfig = @{ @@ -27,6 +42,11 @@ $script:LogConfig = @{ } $script:LogEncoding = [System.Text.UTF8Encoding]::new($false) +# 名录读取缓存:一次运行里同一个文件只 Import 一次,`$( ... )` 也只求值一次。 +# 键是文件路径,值里带内容指纹,文件被改过就自然失效。 +$script:CatalogCache = @{} +$script:CatalogExpressionCache = @{} + # ============================================================================ # 日志 # ============================================================================ @@ -291,28 +311,36 @@ function Get-Optimized7zArgument { <# .SYNOPSIS 根据源目录规模生成 7z 压缩参数(字典大小、线程数、快速字节数)。 + + .DESCRIPTION + SourcePath 可以是多个(一个条目可能有多个 Slot / 追加项),字典大小按合计规模算。 #> param( - [Parameter(Mandatory = $true)][string]$SourcePath, + [Parameter(Mandatory = $true)][string[]]$SourcePath, [int]$Level = 9 ) - $item = Get-Item -LiteralPath $SourcePath -ErrorAction Stop $totalSize = 0 $fileCount = 0 - if ($item.PSIsContainer) { - $files = Get-ChildItem -LiteralPath $SourcePath -File -Recurse -ErrorAction SilentlyContinue - $fileCount = @($files).Count - $totalSize = ($files | Measure-Object -Property Length -Sum).Sum - } else { - $fileCount = 1 - $totalSize = $item.Length + foreach ($path in $SourcePath) { + if ([string]::IsNullOrWhiteSpace($path)) { continue } + $item = Get-Item -LiteralPath $path -ErrorAction Stop + + if ($item.PSIsContainer) { + $files = @(Get-ChildItem -LiteralPath $path -File -Recurse -ErrorAction SilentlyContinue) + $fileCount += $files.Count + $totalSize += [int64](@($files | Measure-Object -Property Length -Sum).Sum) + } else { + $fileCount++ + $totalSize += [int64]$item.Length + } + Write-Log ("分析路径 '{0}':已累计 {1} 个文件,{2} MB" -f $path, $fileCount, [math]::Round($totalSize / 1MB, 2)) -Level DEBUG } if ($null -eq $totalSize) { $totalSize = 0 } $totalSizeMB = [math]::Round($totalSize / 1MB, 2) - Write-Log ("分析路径 '{0}':{1} 个文件,总大小 {2} MB" -f $SourcePath, $fileCount, $totalSizeMB) -Level DEBUG + Write-Log ("合计分析:{0} 个文件,总大小 {1} MB" -f $fileCount, $totalSizeMB) -Level DEBUG if ($totalSizeMB -gt 1024) { $dictSize = '1024m' } elseif ($totalSizeMB -gt 100) { $dictSize = '256m' } @@ -343,27 +371,106 @@ function Get-Optimized7zArgument { # BackupList.txt 解析 # ============================================================================ -function Split-TrailingFlags { +function Split-BaknretToken { <# .SYNOPSIS - 从文本尾部摘出 `@标记`,返回剩余文本与标记数组。 + 把清单的一行切成空白分隔的记号;引号内的空白不切分,引号本身留在记号里。 .DESCRIPTION - 只有在行首或空白之后的 `@token` 才算标记,避免误伤路径里本来就带 @ 的目录名。 - 标记可以连续出现(`@a @b`),也可以写成 `@a,b`。 + 保留引号是为了让调用方分得清 `:- 'a,b'`(一个带逗号的值)与 `:- a,b`(两个值)。 + 引号不配对时按"引号一直延伸到行尾"处理,不抛异常——清单是手写的, + 解析器要能给出可读的结果,而不是崩在半个引号上。 #> param([AllowEmptyString()][string]$Text) - $flags = @() - $remainder = ([string]$Text).Trim() + $tokens = New-Object System.Collections.Generic.List[string] + $builder = New-Object System.Text.StringBuilder + $quote = [char]0 - while ($remainder -match '(?:^|\s)@([^\s]+)\s*$') { - $token = $matches[1] - $flags = @($token -split '[,;]' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + $flags - $remainder = $remainder.Substring(0, $remainder.Length - $matches[0].Length).Trim() + foreach ($ch in ([string]$Text).ToCharArray()) { + if ($quote -ne [char]0) { + [void]$builder.Append($ch) + if ($ch -eq $quote) { $quote = [char]0 } + continue + } + if ($ch -eq "'" -or $ch -eq '"') { + $quote = $ch + [void]$builder.Append($ch) + continue + } + if ([char]::IsWhiteSpace($ch)) { + if ($builder.Length -gt 0) { + $tokens.Add($builder.ToString()) + [void]$builder.Clear() + } + continue + } + [void]$builder.Append($ch) } - return [pscustomobject]@{ Remainder = $remainder; Flags = $flags } + if ($builder.Length -gt 0) { $tokens.Add($builder.ToString()) } + # 刻意不用 `,$array` 包一层:调用方都用 @(...) 收结果,包了反而会变成"数组套数组"。 + return $tokens.ToArray() +} + +function Remove-BaknretQuote { + <# + .SYNOPSIS + 去掉值两端成对的引号(单双都认);不成对时原样返回。 + #> + param([AllowEmptyString()][string]$Text) + + $value = ([string]$Text).Trim() + if ($value.Length -ge 2) { + $first = $value[0] + $last = $value[$value.Length - 1] + if (($first -eq $last) -and ($first -eq "'" -or $first -eq '"')) { + return $value.Substring(1, $value.Length - 2) + } + } + return $value +} + +function Test-BaknretMarker { + <# + .SYNOPSIS + 判断一个记号是不是清单修饰符,返回它的种类;不是则返回 $null。 + + .DESCRIPTION + 修饰符必须是**独立记号**(前后都有空白),所以这里做的是全等比较, + 不是前缀匹配:`C:\a:-b` 仍然是一个路径,不会被看成 `:-`。 + #> + param([AllowEmptyString()][string]$Token) + + $text = ([string]$Token).Trim() + if (-not $text) { return $null } + + switch -CaseSensitive ($text) { + '::' { return 'path' } + ':-' { return 'exclude' } + ':+' { return 'include' } + ':encrypt' { return 'encrypt' } + ':!encrypt' { return 'noencrypt' } + } + + if ($text.StartsWith('@')) { return 'at' } + return $null +} + +function ConvertFrom-BaknretPatternList { + <# + .SYNOPSIS + 把修饰符的值列表拼成字符串并按 `,` / `;` 拆成多个模式。 + #> + param([string[]]$Values = @()) + + $parts = @() + foreach ($value in @($Values)) { + $text = Remove-BaknretQuote -Text ([string]$value) + if ([string]::IsNullOrWhiteSpace($text)) { continue } + $parts += @($text -split '[,;]' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + } + return @($parts) } function ConvertFrom-BackupListLine { @@ -373,16 +480,21 @@ function ConvertFrom-BackupListLine { .DESCRIPTION 返回 $null 表示注释 / 空行。正常返回包含: - Path —— 未展开环境变量的原始路径(归档命名依赖它保持可移植) - ExcludePatterns —— 排除模式数组 - Flags —— @ 标记数组(如 encrypt) - Raw —— 原始行 - 与旧实现的区别(旧写法在这些地方静默出错,导致排除规则从未生效): - 1. 先按第一个 `::` 切开,再处理引号。旧实现用 ^"([^"]+)"\s*(.*)$ 贪婪匹配, - `"路径 :: 排除表"` 这种整行加引号的写法会把排除表吞进路径里。 - 2. 排除模式分隔符同时接受 `,` 与 `;`;旧解析器只认 `;`,而 - BackupList.txt 里写的是 `,`,于是整串被当成一个模式,等于没有排除。 + Direction —— 'both' | 'backup'(行首 +,仅备份)| 'restore'(行首 -,仅恢复) + Path —— 目标原文(软件名或字面路径),**归档命名以它为准** + IsName —— 是否按软件名去名录里查 + Overrides —— 显式给出的覆盖字段(hashtable,用 ContainsKey 判断有没有写) + Path / Exclude / Include / Encrypt + ExcludePatterns / Includes —— Overrides 的便捷视图(没写时是空数组) + Flags —— 兼容的历史标记(pathname / root=<名>) + Comment —— 行尾 `# 说明` + Raw —— 原始行 + + 与旧实现的区别: + * `::` 现在表示"覆盖 Path"(旧版是 `:-` 的历史别名),排除一律写 `:-`; + * 新增行首 `+` / `-` 方向、`:encrypt` / `:!encrypt`、`@ Key='Value'` 覆盖; + * 修饰符必须是独立记号(前后加空格),所以 `C:\a:-b` 仍然是路径。 #> param([Parameter(ValueFromPipeline = $true)][AllowEmptyString()][string]$Line) @@ -403,91 +515,170 @@ function ConvertFrom-BackupListLine { if ([string]::IsNullOrEmpty($content)) { return $null } } - # 行内记号(都用到 `:`,因为 `:` 在 Windows 路径里只可能是盘符, - # 而 `::` `:+` `:-` 都不可能出现在真实路径里,所以切分不受引号位置影响): - # :: 排除模式(历史写法,等价于 :-) - # :+ 追加一个目录(等价于名录里的 Dirs 数组) - # :- 排除模式 - # 记号可以出现多次、顺序任意:`Foo :+ D:\a D:\b :- logs\ !*Cache` - # 第一段(第一个记号之前)是主路径/软件名。 - $segments = [System.Collections.Generic.List[object]]::new() - $cursor = 0 - $currentKind = 'main' - $currentText = '' - $contentLength = $content.Length + $tokens = @(Split-BaknretToken -Text $content) + if ($tokens.Count -eq 0) { return $null } - while ($cursor -lt $contentLength) { - $colonIndex = $content.IndexOf(':', $cursor) - if ($colonIndex -lt 0) { - $currentText += $content.Substring($cursor) - break - } - - $currentText += $content.Substring($cursor, $colonIndex - $cursor) - - # 记号必须是 `:` 后紧跟 `:` `+` `-` 之一 - if ($colonIndex + 1 -lt $contentLength -and $content[$colonIndex + 1] -in @(':', '+', '-')) { - $kind = switch ($content[$colonIndex + 1]) { - '+' { 'add' } - '-' { 'exclude' } - default { 'exclude' } # `::` 等同排除 + # 整行被一对引号包住是**历史写法**(`"C:\a b\CodeSpace :: X\"`)。 + # 现在修饰符必须是独立记号,所以引号里的 `::` / `:-` 不再是修饰符。 + # 这里刻意**不**替用户重新切分:老写法里的 `::` 当年是"排除",现在 `::` 是 + # "覆盖 Path"——猜着切会把排除表当成新的源路径,比报错更糟。只告警。 + if ($tokens.Count -eq 1) { + $raw = $tokens[0] + if ($raw.Length -ge 2) { + $first = $raw[0] + $last = $raw[$raw.Length - 1] + if ($first -eq $last -and ($first -eq '"' -or $first -eq "'")) { + $inner = $raw.Substring(1, $raw.Length - 2) + foreach ($innerToken in @(Split-BaknretToken -Text $inner)) { + if (Test-BaknretMarker -Token $innerToken) { + Write-Log "整行被引号包住,引号里的修饰符不会被识别(历史写法)。请去掉外层引号,并注意现在 `:-` 才是排除、`::` 是覆盖 Path:$Line" -Level WARN + break + } + } } - $segments.Add([pscustomobject]@{ Kind = $currentKind; Text = $currentText.Trim() }) - $currentKind = $kind - $currentText = '' - $cursor = $colonIndex + 2 - } else { - # 单个 `:`(盘符)属于内容 - $currentText += ':' - $cursor = $colonIndex + 1 } } - $segments.Add([pscustomobject]@{ Kind = $currentKind; Text = $currentText.Trim() }) - $pathPart = '' - $addedPaths = @() - $excludes = @() + # 行首方向标记:`+` 仅备份、`-` 仅恢复 + $direction = 'both' + if ($tokens[0] -eq '+') { + $direction = 'backup' + $tokens = @($tokens | Select-Object -Skip 1) + } elseif ($tokens[0] -eq '-') { + $direction = 'restore' + $tokens = @($tokens | Select-Object -Skip 1) + } + if ($tokens.Count -eq 0) { return $null } + + # 第一个修饰符之前是目标。目标可以带空格(比如带引号的 "C:\Program Files\App"), + # 所以这里取"第一个修饰符记号之前的全部记号",而不是只取第一个记号。 + $firstMarker = -1 + for ($index = 0; $index -lt $tokens.Count; $index++) { + if (Test-BaknretMarker -Token $tokens[$index]) { $firstMarker = $index; break } + } + + if ($firstMarker -eq 0) { + Write-Log "清单行缺少目标,已忽略:$Line" -Level WARN + return $null + } + + if ($firstMarker -lt 0) { + $targetText = ($tokens -join ' ') + $markerTokens = @() + } else { + $targetText = (($tokens[0..($firstMarker - 1)]) -join ' ') + $markerTokens = @($tokens[$firstMarker..($tokens.Count - 1)]) + } + + $target = Remove-BaknretQuote -Text $targetText + if ([string]::IsNullOrWhiteSpace($target)) { return $null } + + $overrides = @{} $flags = @() + $unknownKeys = @() + $index = 0 - # 标记(@encrypt 等)可能挂在任意段的末尾,所以每一段都先摘标记: - # 历史写法 `C:\x :: a,b @encrypt` 里标记就是跟在排除表后面的。 - foreach ($segment in $segments) { - if ([string]::IsNullOrWhiteSpace($segment.Text)) { continue } + while ($index -lt $markerTokens.Count) { + $kind = Test-BaknretMarker -Token $markerTokens[$index] + $inline = $null + if ($kind -eq 'at') { $inline = $markerTokens[$index].Substring(1) } + $index++ - $split = Split-TrailingFlags -Text $segment.Text - if ($split.Flags.Count -gt 0) { $flags = @($flags) + @($split.Flags) } - $body = $split.Remainder - if ([string]::IsNullOrWhiteSpace($body)) { continue } + $values = @() + if (-not [string]::IsNullOrWhiteSpace($inline)) { $values += $inline } + while ($index -lt $markerTokens.Count -and -not (Test-BaknretMarker -Token $markerTokens[$index])) { + $values += $markerTokens[$index] + $index++ + } - switch ($segment.Kind) { - 'main' { $pathPart = $body } - 'add' { $addedPaths += $body } + switch ($kind) { + 'path' { + $value = Remove-BaknretQuote -Text ($values -join ' ') + if (-not [string]::IsNullOrWhiteSpace($value)) { + if ($overrides.ContainsKey('Path')) { + Write-Log "同一条目里给了多次路径覆盖,用最后一个:$Line" -Level WARN + } + $overrides['Path'] = $value + } + } + # 同类记号可以出现多次(`Foo :- a :- b`),**累积**而不是后者覆盖前者: + # 静默丢掉前一条排除规则正是这个工具最不该犯的错。 'exclude' { - $excludes += @($body -split '[,;]' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + $parsed = @(ConvertFrom-BaknretPatternList -Values $values) + if ($overrides.ContainsKey('Exclude')) { $overrides['Exclude'] = @($overrides['Exclude']) + $parsed } + else { $overrides['Exclude'] = $parsed } + } + 'include' { + $parsed = @(ConvertFrom-BaknretPatternList -Values $values) + if ($overrides.ContainsKey('Include')) { $overrides['Include'] = @($overrides['Include']) + $parsed } + else { $overrides['Include'] = $parsed } + } + 'encrypt' { + if ($overrides.ContainsKey('Encrypt')) { Write-Log "同一条目里给了多次加密开关,用最后一个:$Line" -Level WARN } + $overrides['Encrypt'] = $true + } + 'noencrypt' { + if ($overrides.ContainsKey('Encrypt')) { Write-Log "同一条目里给了多次加密开关,用最后一个:$Line" -Level WARN } + $overrides['Encrypt'] = $false + } + 'at' { + $text = Remove-BaknretQuote -Text ($values -join ' ') + if ([string]::IsNullOrWhiteSpace($text)) { continue } + + $equals = $text.IndexOf('=') + if ($equals -lt 0) { + # 兼容历史写法:`@encrypt` / `@!encrypt` / `@pathname` / `@root=名` + foreach ($legacy in @(ConvertFrom-BaknretPatternList -Values @($text))) { + $name = $legacy.Trim().TrimStart('@') + if ($name -ieq 'encrypt') { $overrides['Encrypt'] = $true } + elseif ($name -ieq '!encrypt') { $overrides['Encrypt'] = $false } + elseif ($name) { $flags += $name } + } + continue + } + + $key = $text.Substring(0, $equals).Trim() + $value = Remove-BaknretQuote -Text $text.Substring($equals + 1) + switch -Regex ($key) { + '(?i)^path$' { $overrides['Path'] = $value } + '(?i)^exclude$' { + $parsed = @(ConvertFrom-BaknretPatternList -Values @($value)) + if ($overrides.ContainsKey('Exclude')) { $overrides['Exclude'] = @($overrides['Exclude']) + $parsed } + else { $overrides['Exclude'] = $parsed } + } + '(?i)^include$' { + $parsed = @(ConvertFrom-BaknretPatternList -Values @($value)) + if ($overrides.ContainsKey('Include')) { $overrides['Include'] = @($overrides['Include']) + $parsed } + else { $overrides['Include'] = $parsed } + } + '(?i)^encrypt$' { $overrides['Encrypt'] = [bool]($value -match '(?i)^(\$?true|1|yes|on)$') } + '(?i)^root$' { $flags += "root=$value" } + default { $unknownKeys += $key } + } } } } - # 引号只应包住路径。整行被一对引号包住时(历史写法), - # 切分后主路径这半只剩开引号、闭引号留在了尾段,因此两侧各剥一次,不要求成对。 - $pathPart = $pathPart.Trim() - if ($pathPart.StartsWith('"')) { $pathPart = $pathPart.Substring(1) } - if ($pathPart.EndsWith('"')) { $pathPart = $pathPart.Substring(0, $pathPart.Length - 1) } - $pathPart = $pathPart.Trim() - if ([string]::IsNullOrEmpty($pathPart)) { return $null } - - # 尾段可能残留闭引号(历史 `"路径 :: 排除表"` 写法) - $excludes = @($excludes | ForEach-Object { $_.TrimEnd('"').Trim() } | Where-Object { $_ }) + foreach ($unknown in $unknownKeys) { + Write-Log "清单里的 @ 字段 '$unknown' 不是已知字段(Path / Exclude / Include / Encrypt),已忽略:$Line" -Level WARN + } + $resolvedExclude = @() + if ($overrides.ContainsKey('Exclude')) { $resolvedExclude = @($overrides['Exclude']) } + $resolvedInclude = @() + if ($overrides.ContainsKey('Include')) { $resolvedInclude = @($overrides['Include']) } return [pscustomobject]@{ - Path = $pathPart + Direction = $direction + Path = $target # 目录名或文件名,需要靠 SoftwareCatalog 换成真实路径; - # 带分隔符或 %变量% 的写法按字面路径处理(并给出警告)。 - IsName = (-not (Test-LiteralPath -Path $pathPart)) - AddedPaths = $addedPaths - ExcludePatterns = $excludes - Flags = $flags + # 带分隔符或 %变量% 的写法按字面路径处理。 + IsName = (-not (Test-LiteralPath -Path $target)) + Overrides = $overrides + ExcludePatterns = $resolvedExclude + Includes = $resolvedInclude + Flags = @($flags) + UnknownKeys = @($unknownKeys) Comment = $comment Raw = $Line } @@ -511,52 +702,221 @@ function Test-LiteralPath { return $false } -function Get-ArchiveExcludeArgument { +function Get-BaknretRegexExclude { <# .SYNOPSIS - 把清单里的排除模式翻译成 7z 的 -x 参数。 + 把一条 `!re:<正则>` 展开成若干 `-x!<归档内路径>` 参数。 .DESCRIPTION - 7z 排除语义(已实测确认): - * `-x!<完整归档内路径>` 匹配对象的完整路径,且**包含归档根目录名** - (源是 C:\Programs\Foo 时,归档里的路径是 Foo\...),所以必须加前缀; - * 模式里**不能出现空格**——`-x!root\Code Cache` 匹配不到任何东西, - 正确的写法是 `-xr!Code?Cache` 或 `-xr!*Cache`。因此这里把模式里的 - 空格自动换成 `?`(单字符通配符,恰好对应一个空格); - * 模式里**不能手工加引号**——旧实现写成 -x!"路径",引号会成为模式的 - 一部分导致永不匹配; - * 以 `!` 开头的模式按"任意层级下的组件名"处理,翻译成 `-xr!`。 + 7z 本身只认通配符,不认正则,所以正则只能由脚本自己遍历源目录后翻译成 + 一条条精确的 `-x!<完整归档内路径>`: + * 逐层遍历,命中"目录名或相对路径"就把该目录整个排除,并且**不再往下走** + (否则一个命中会产生成千上万条参数); + * 展开结果有上限(MaxMatches),超过就明确报错,而不是悄悄漏排除或写出超长命令行。 + + 注意:`!<通配>`(例如 `!*Cache`)不走这里——它在 .NET 里是非法正则 + (`*` 前没有可重复的表达式),仍然按"任意层级匹配组件名"翻译成 `-xr!`。 #> param( - [Parameter(Mandatory = $true)][string]$ItemName, - [string[]]$Patterns = @() + [Parameter(Mandatory = $true)]$Item, + [Parameter(Mandatory = $true)][string]$Pattern, + [int]$MaxMatches = 300 ) - $result = @() - foreach ($pattern in $Patterns) { - if ([string]::IsNullOrWhiteSpace($pattern)) { continue } + $arguments = @() + $errorText = $null - if ($pattern.StartsWith('!')) { - $component = $pattern.Substring(1).Trim() - if (-not $component) { continue } - $component = $component -replace ' ', '?' - $result += "-xr!$component" + try { + $regex = [System.Text.RegularExpressions.Regex]::new( + $Pattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) + } catch { + return [pscustomobject]@{ Arguments = @(); Matches = 0; Error = "排除正则非法:$Pattern —— $($_.Exception.Message)" } + } + + $real = [string]$Item.RealPath + if (-not $real -or -not (Test-Path -LiteralPath $real)) { + return [pscustomobject]@{ Arguments = @(); Matches = 0; Error = $null } + } + + $root = Get-Item -LiteralPath $real -Force -ErrorAction SilentlyContinue + if (-not $root) { return [pscustomobject]@{ Arguments = @(); Matches = 0; Error = $null } } + + if (-not $root.PSIsContainer) { + if ($regex.IsMatch($root.Name)) { $arguments += "-x!$($Item.ArchivePath)" } + return [pscustomobject]@{ Arguments = @($arguments); Matches = $arguments.Count; Error = $null } + } + + # 用显式栈做深度优先遍历:命中就整棵剪掉,所以匹配数是"命中的最浅层数"。 + $stack = New-Object System.Collections.Generic.Stack[object] + foreach ($child in @(Get-ChildItem -LiteralPath $root.FullName -Force -ErrorAction SilentlyContinue)) { + $stack.Push(@{ Relative = $child.Name; Item = $child }) + } + + while ($stack.Count -gt 0) { + $node = $stack.Pop() + $relative = [string]$node.Relative + $entry = $node.Item + + if ($regex.IsMatch($entry.Name) -or $regex.IsMatch($relative)) { + $arguments += ('-x!{0}\{1}' -f $Item.ArchivePath, ($relative -replace '/', '\')) + if ($arguments.Count -gt $MaxMatches) { + $errorText = "排除正则 $Pattern 命中的路径超过 $MaxMatches 条,7z 命令行会过长;请改用更粗的通配模式(例如 !*Cache)" + break + } continue } - $full = $pattern.Trim().Trim([char[]]@('\', '/')).TrimEnd([char[]]@('\', '/')) - if (-not $full) { continue } - - if (-not $full.StartsWith("$ItemName\", [System.StringComparison]::OrdinalIgnoreCase)) { - $full = "$ItemName\$full" + if ($entry.PSIsContainer) { + foreach ($child in @(Get-ChildItem -LiteralPath $entry.FullName -Force -ErrorAction SilentlyContinue)) { + $stack.Push(@{ Relative = ('{0}\{1}' -f $relative, $child.Name); Item = $child }) + } } - $full = $full -replace ' ', '?' - $result += "-x!$full" } - # 必须用逗号包一层:只有一个元素时 PowerShell 会把数组拆成标量, - # 调用方拿到的就是字符串而不是数组(`$x[0]` 会变成首字符 "-")。 - return ,$result + return [pscustomobject]@{ Arguments = @($arguments); Matches = $arguments.Count; Error = $errorText } +} + +function Get-BaknretExcludeArgument { + <# + .SYNOPSIS + 把一个归档项的模式列表翻译成 7z 的 `-x!` / `-xr!` 参数。 + + .DESCRIPTION + 传进来的模式**已经按项分配好**(见 Split-BaknretPatternScope),因此这里 + 拿到的模式一律是"相对该项归档根"的: + + * `<相对路径>` -> `-x!\<相对路径>`(锚定在归档根) + * `!<通配>` -> `-xr!<通配>`(任意层级,模式里的空格自动转 `?`) + * `!re:<正则>` -> 遍历源目录翻译成若干 `-x!<完整路径>`(见 Get-BaknretRegexExclude) + + 7z 排除语义(已实测确认): + * `-x!<完整归档内路径>` 匹配对象的完整路径,所以要带上项自己的归档根名; + * 模式里不能有空格,也不能自己写引号; + * 参数总长度有上限,超了明确报错,不静默丢规则。 + #> + param( + [Parameter(Mandatory = $true)]$Item, + [string[]]$Patterns = @(), + [int]$MaxRegexMatches = 300, + [int]$MaxCommandLineChars = 15000 + ) + + $arguments = @() + $errorText = $null + + foreach ($pattern in @($Patterns)) { + if ([string]::IsNullOrWhiteSpace($pattern)) { continue } + $text = ([string]$pattern).Trim() + + if ($text.StartsWith('!re:')) { + $regexText = $text.Substring(4).Trim() + if (-not $regexText) { continue } + $expanded = Get-BaknretRegexExclude -Item $Item -Pattern $regexText -MaxMatches $MaxRegexMatches + if ($expanded.Error) { $errorText = $expanded.Error; continue } + $arguments += @($expanded.Arguments) + continue + } + + if ($text.StartsWith('!')) { + $component = $text.Substring(1).Trim() + if (-not $component) { continue } + $arguments += ('-xr!{0}' -f ($component -replace ' ', '?')) + continue + } + + $relative = $text.Trim([char[]]@('\', '/')) + if (-not $relative) { continue } + $arguments += ('-x!{0}\{1}' -f $Item.ArchivePath, ($relative -replace ' ', '?')) + } + + $totalChars = 0 + foreach ($argument in $arguments) { $totalChars += $argument.Length + 1 } + if (-not $errorText -and $totalChars -gt $MaxCommandLineChars) { + $errorText = "排除参数合计约 $totalChars 字符,超过命令行安全长度;请用更粗的通配模式(例如 !*Cache)" + } + + return , [pscustomobject]@{ Arguments = @($arguments); Error = $errorText } +} + +function Split-BaknretPatternScope { + <# + .SYNOPSIS + 把条目级的模式按 `<归档项名>\` 前缀分配到各个归档项上。 + + .DESCRIPTION + 软件目录里的一个软件可以有多个 Slot(各是一个归档内的顶层目录), + 所以 `:-` / `Exclude` 里的模式要用第一段点名它作用在哪个 Slot 上: + + Scoop :- GlobalPersist\steam\steamapps + + 这里把 `GlobalPersist\` 摘掉、只把 `steam\steamapps` 交给 GlobalPersist 这一项; + 第一段没点名任何项时,普通模式对每个项各展开一份(`<项>\<模式>`), + `!` 开头与 `!re:` 开头本来就是"任意层级"的,直接广播到每一项,由调用方去重。 + + 返回 hashtable:项的下标 -> 模式数组。 + #> + param( + [Parameter(Mandatory = $true)][array]$Items, + [string[]]$Patterns = @() + ) + + $map = @{} + for ($index = 0; $index -lt $Items.Count; $index++) { $map[$index] = @() } + + # 归档项的名字(顶层目录名)。同一个条目里不允许重名,Resolve-BackupEntry 会拦。 + $topIndex = @{} + for ($index = 0; $index -lt $Items.Count; $index++) { + $name = [string]$Items[$index].ArchivePath + if (-not $name) { continue } + $topIndex[$name.ToLower()] = $index + } + + foreach ($pattern in @($Patterns)) { + if ([string]::IsNullOrWhiteSpace($pattern)) { continue } + $text = ([string]$pattern).Trim() + + if ($text.StartsWith('!re:') -or $text.StartsWith('!')) { + for ($index = 0; $index -lt $Items.Count; $index++) { $map[$index] += $text } + continue + } + + $head = $text + $separator = $text.IndexOfAny([char[]]@('\', '/')) + $rest = '' + if ($separator -ge 0) { + $head = $text.Substring(0, $separator) + $rest = $text.Substring($separator + 1).Trim([char[]]@('\', '/')) + } + + if ($rest -and $topIndex.ContainsKey($head.ToLower())) { + $map[$topIndex[$head.ToLower()]] += $rest + continue + } + + for ($index = 0; $index -lt $Items.Count; $index++) { $map[$index] += $text } + } + + return $map +} + +function Merge-BaknretExcludeArgument { + <# + .SYNOPSIS + 合并多个归档项展开出来的排除参数并去重(保序)。 + #> + param([string[][]]$ArgumentLists = @()) + + $seen = @{} + $merged = @() + foreach ($list in @($ArgumentLists)) { + foreach ($argument in @($list)) { + if ([string]::IsNullOrWhiteSpace($argument)) { continue } + if ($seen.ContainsKey($argument)) { continue } + $seen[$argument] = $true + $merged += $argument + } + } + return @($merged) } # ============================================================================ @@ -606,188 +966,329 @@ function Format-CatalogName { return $clean.Trim() } +function Test-BaknretMapKey { + <# .SYNOPSIS 判断一个数据对象(哈希表或 JSON 对象)里有没有某个键。 #> + param($Map, [string]$Key) + if ($null -eq $Map) { return $false } + if ($Map -is [System.Collections.IDictionary]) { return $Map.Contains($Key) } + return @($Map.PSObject.Properties.Name) -contains $Key +} + +function Get-BaknretMapValue { + <# .SYNOPSIS 从哈希表或 JSON 对象里按键取值。 #> + param($Map, [string]$Key) + if ($null -eq $Map) { return $null } + if ($Map -is [System.Collections.IDictionary]) { + if ($Map.Contains($Key)) { return $Map[$Key] } + return $null + } + if (@($Map.PSObject.Properties.Name) -contains $Key) { return $Map.$Key } + return $null +} + +function Get-BaknretMapKeys { + <# .SYNOPSIS 列出哈希表或 JSON 对象的全部键。 #> + param($Map) + if ($null -eq $Map) { return @() } + if ($Map -is [System.Collections.IDictionary]) { return @($Map.Keys) } + return @($Map.PSObject.Properties.Name) +} + +function Expand-CatalogPathText { + <# + .SYNOPSIS + 展开名录里写的路径:`%环境变量%` 与 `$( ... )` 子表达式。 + + .DESCRIPTION + 名录就是一份受信任的本地 PowerShell 配置,所以 `$( ... )` 直接按 PowerShell 求值, + 够写这两类东西: + + Path = '$(if ($env:SCOOP) { $env:SCOOP } else { Join-Path $env:USERPROFILE "scoop" })\persist' + Path = '$(scoop prefix translucenttb)\settings.json' + + 求值结果按原字符串缓存(`scoop prefix` 要起一个进程,不能每个条目跑一遍)。 + 括号不配对时原样保留,不抛异常——手写配置要的是可读的告警,不是崩掉。 + #> + param([AllowEmptyString()][string]$Text) + + $value = [string]$Text + if ([string]::IsNullOrEmpty($value)) { return '' } + + if ($script:CatalogExpressionCache.ContainsKey($value)) { + return $script:CatalogExpressionCache[$value] + } + + $original = $value + $guard = 0 + while ($guard -lt 32) { + $guard++ + # 从最后一个 `$(` 开始处理,这样嵌套在外层的表达式最后才展开 + $start = $value.LastIndexOf('$(') + if ($start -lt 0) { break } + + $depth = 0 + $end = -1 + for ($index = $start + 1; $index -lt $value.Length; $index++) { + if ($value[$index] -eq '(') { $depth++ } + elseif ($value[$index] -eq ')') { + $depth-- + if ($depth -eq 0) { $end = $index; break } + } + } + if ($end -lt 0) { break } + + $expression = $value.Substring($start + 2, $end - $start - 2) + $replacement = '' + try { + $evaluated = [scriptblock]::Create($expression).Invoke() + if ($null -ne $evaluated) { $replacement = ([string]($evaluated)).Trim() } + } catch { + Write-Log "名录路径里的表达式求值失败:$expression —— $($_.Exception.Message)" -Level WARN + } + $value = $value.Substring(0, $start) + $replacement + $value.Substring($end + 1) + } + + $value = [Environment]::ExpandEnvironmentVariables($value) + $script:CatalogExpressionCache[$original] = $value + return $value +} + +function Import-BaknretDataFile { + <# + .SYNOPSIS + 读取 .psd1 / .json 配置数据。 + + .DESCRIPTION + 先用 Import-PowerShellDataFile(受限语法,不执行任意代码);它对 psd1 里 + 常见的字符串拼接(`'a,' + 'b'`)会直接报 + "Cannot generate a PowerShell object for a ScriptBlock evaluating dynamic expressions", + 这种情况下退回 `[scriptblock]::Create(...).Invoke()` 求值。 + + 这个退路是可信的:名录与配置本来就是仓库里的本地文件,跟脚本同级, + 而且 Slot 的 Path 里已经允许写 `$( ... )` 子表达式(同样是要执行的)。 + #> + param([Parameter(Mandatory = $true)][string]$Path) + + if ($Path.ToLower().EndsWith('.json')) { + return (Get-Content -LiteralPath $Path -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop) + } + + try { + return Import-PowerShellDataFile -LiteralPath $Path -ErrorAction Stop + } catch { + $firstLine = ([string]$_.Exception.Message) -split "`r?`n" | Select-Object -First 1 + Write-Log "psd1 里有 Import-PowerShellDataFile 不接受的表达式($firstLine),改用 PowerShell 求值:$Path" -Level DEBUG + $raw = [System.IO.File]::ReadAllText($Path) + return [scriptblock]::Create($raw).Invoke() + } +} + function Get-SoftwareCatalog { <# .SYNOPSIS - 载入"软件名 -> 目录"名录。 + 载入"软件名 -> Slot 组"名录。 .DESCRIPTION - 返回按名字索引的哈希表,每项是 @{ Name; Path; ResolvedPath; Kind; Raw }。 - Kind 取值:Single(一个目录)| Variant(有 variants 的同名目录)| Unresolved(没找到目录)。 + 新结构(SoftwareCatalog.psd1): - 名录文件可以是 .psd1 或 .json——默认用 .psd1,因为路径这种东西很需要写注释。 - .psd1 里可以用 `Includes` 键引入其它名录文件,多个游戏/多个盘的目录可以分文件维护。 + @{ + <软件名> = @{ + = @{ + Path = '宿主机绝对路径' + Exclude = '!*Cache,Default\Extensions' # 可选 + Include = 'Modules:D:\extra\ps-modules' # 可选 + Encrypt = $true # 可选,默认 $false + Description = '这个 Slot 是干什么的' # 可选 + } + } + } + + Slot 是**归档内的一层目录**:`\<该 Path 的内容>`。一个软件一个归档, + 因此同名的目录(例如 scoop 的用户 persist 与全局 persist)只要放在不同 Slot 里就不会撞。 + + 返回按软件名索引的哈希表,每项: + + Name / Path / Description / Slots / Kind / Missing / Error / Raw + + Slot 对象:Name / Declared / Resolved / Exists / IsFile / Suffixed / + Description / Exclude / Include / Encrypt + + 读取结果按"文件路径 + 时间戳 + 长度 + 内容 MD5"缓存:一次运行里名录只会真正 + 读一次(旧实现每解析一个条目就重新 Import 一遍,还会把 `$( ... )` 反复求值)。 #> param( [Parameter(Mandatory = $true)][string]$Path, - [int]$MaxDepth = 5 + [int]$MaxDepth = 5, + [switch]$NoCache ) $result = @{} if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { return $result } + $stamp = $null + if (-not $NoCache) { + try { + $item = Get-Item -LiteralPath $Path -ErrorAction Stop + $hash = (Get-FileHash -LiteralPath $Path -Algorithm MD5 -ErrorAction Stop).Hash + $stamp = '{0}-{1}-{2}' -f $item.LastWriteTimeUtc.Ticks, $item.Length, $hash + if ($script:CatalogCache.ContainsKey($Path) -and $script:CatalogCache[$Path].Stamp -eq $stamp) { + return $script:CatalogCache[$Path].Data + } + } catch { + $stamp = $null + } + } + $data = $null try { - if ($Path.ToLower().EndsWith('.json')) { - $data = Get-Content -LiteralPath $Path -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop - } else { - $data = Import-PowerShellDataFile -LiteralPath $Path -ErrorAction Stop - } + $data = Import-BaknretDataFile -Path $Path } catch { Write-Log "软件名录读取失败:$Path —— $_" -Level ERROR return $result } - # 递归引入其它名录文件 - if ($data -is [System.Collections.IDictionary] -and $data.Contains('Includes')) { - $includeList = @($data['Includes']) - $baseDir = Split-Path -Parent $Path - foreach ($include in $includeList) { - if (-not $include) { continue } - $includePath = [string]$include - if (-not [System.IO.Path]::IsPathRooted($includePath)) { $includePath = Join-Path $baseDir $includePath } - $included = Get-SoftwareCatalog -Path $includePath -MaxDepth $MaxDepth - foreach ($includedName in $included.Keys) { - if ($result.ContainsKey($includedName)) { continue } - $result[$includedName] = $included[$includedName] - } + # 递归引入其它名录文件(路径相对本文件) + $includeValue = Get-BaknretMapValue -Map $data -Key 'Includes' + if ($includeValue) { + $baseDir = Split-Path -Parent $Path + foreach ($include in @($includeValue)) { + if (-not $include) { continue } + $includePath = [string]$include + if (-not [System.IO.Path]::IsPathRooted($includePath)) { $includePath = Join-Path $baseDir $includePath } + $included = Get-SoftwareCatalog -Path $includePath -MaxDepth $MaxDepth + foreach ($includedName in $included.Keys) { + if ($result.ContainsKey($includedName)) { continue } + $result[$includedName] = $included[$includedName] } } - - # 顶层除 Includes 外的每个键都是一个软件名 - $keys = @() - if ($data -is [System.Collections.IDictionary]) { - $keys = @($data.Keys | Where-Object { $_ -ne 'Includes' }) - } else { - $keys = @($data.PSObject.Properties.Name | Where-Object { $_ -ne 'Includes' }) } - foreach ($key in $keys) { + foreach ($key in @(Get-BaknretMapKeys -Map $data | Where-Object { $_ -ne 'Includes' })) { $name = Format-CatalogName -Name ([string]$key) if (-not $name) { continue } - $entry = if ($data -is [System.Collections.IDictionary]) { $data[$key] } else { $data.$key } - - # 一个软件可以对应**多个目录**,写成数组;数组元素两种都认: - # * 对象(推荐):@{ Path = '<目录>'; Description = '<这个目录是干什么的>' } - # * 纯字符串: '<目录>' - # 也兼容字典写法:@{ Dirs = @(...) } / @{ Variants = @(...) } / @{ Path = '<目录>' } - $rawPath = $null - $candidates = @() - $entryDescription = $null - - if ($entry -is [System.Collections.IDictionary]) { - foreach ($key in 'Description', 'Note', 'Desc') { - if ($entry.Contains($key)) { $entryDescription = [string]$entry[$key]; break } - } - if ($entry.Contains('Dirs')) { $candidates = @($entry['Dirs']) } - elseif ($entry.Contains('Variants')) { $candidates = @($entry['Variants']) } - if ($entry.Contains('Path')) { $rawPath = [string]$entry['Path'] } - } elseif ($entry -is [string]) { - $rawPath = $entry - } elseif ($entry -is [System.Collections.IEnumerable]) { - $candidates = @($entry) - } elseif ($null -ne $entry) { - $rawPath = [string]$entry + $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)) { + Write-Log "名录条目 '$key' 格式不对:应写成 @{ = @{ Path = '...' } }" -Level ERROR + continue } - # 单目录写法:包成对象,好让"目录说明"跟目录一起走下去 - if ($candidates.Count -eq 0 -and $rawPath) { - $candidates = @([pscustomobject]@{ Path = $rawPath; Description = $entryDescription }) - } - if ($candidates.Count -eq 0) { continue } + $slots = @() + $errors = @() - # 逐个候选目录解析。**声明了几个就记几个**,找不到的也留着: - # 备份端按存在性跳过它们,恢复端要靠它们把内容还原回原位。 - $items = @() - foreach ($candidate in $candidates) { - $candidatePath = $null - $candidateDescription = $null + foreach ($slotKey in @(Get-BaknretMapKeys -Map $raw)) { + $slotName = ([string]$slotKey).Trim() + if (-not $slotName) { continue } - if ($candidate -is [string]) { - $candidatePath = $candidate - } elseif ($candidate -is [System.Collections.IDictionary]) { - foreach ($key in 'Path', 'Dir', 'Directory') { - if ($candidate.Contains($key)) { $candidatePath = [string]$candidate[$key]; break } - } - foreach ($key in 'Description', 'Note', 'Desc', 'Reason', 'Why') { - if ($candidate.Contains($key)) { $candidateDescription = [string]$candidate[$key]; break } - } - } elseif ($null -ne $candidate) { - # JSON 里是对象、.psd1 里一般是哈希表,两种都认 - $names = @($candidate.PSObject.Properties.Name) - foreach ($key in 'Path', 'Dir', 'Directory') { - if ($names -contains $key) { $candidatePath = [string]$candidate.$key; break } - } - foreach ($key in 'Description', 'Note', 'Desc', 'Reason', 'Why') { - if ($names -contains $key) { $candidateDescription = [string]$candidate.$key; break } - } - } - - if ([string]::IsNullOrWhiteSpace($candidatePath)) { continue } - $declared = ([Environment]::ExpandEnvironmentVariables([string]$candidatePath)).Trim() - if (-not $declared) { continue } - - if (Test-Path -LiteralPath $declared) { - $items += [pscustomobject]@{ Declared = $declared; Resolved = $declared; Exists = $true; Suffixed = $false; Description = $candidateDescription } + $slotRaw = Get-BaknretMapValue -Map $raw -Key $slotKey + if ($slotRaw -isnot [System.Collections.IDictionary] -and -not ($slotRaw -is [psobject])) { + $errors += "Slot $slotName 的写法不对,应写成 @{ Path = '...' }" continue } - # 名录里写的是父目录,实际目录带版本号之类后缀(如 legendary 的 _2.0.4) - $parent = Split-Path -Path $declared -Parent - $leaf = Split-Path -Path $declared -Leaf - $found = $null - if ($parent -and $leaf -and (Test-Path -LiteralPath $parent)) { - $found = @(Find-ChildDirectoryByName -Parent $parent -Name $leaf -MaxDepth $MaxDepth) + $declaredRaw = [string](Get-BaknretMapValue -Map $slotRaw -Key 'Path') + if ([string]::IsNullOrWhiteSpace($declaredRaw)) { + $errors += "Slot $slotName 缺少 Path" + continue } - if ($found -and $found.Count -gt 0) { - foreach ($match in $found) { - $items += [pscustomobject]@{ Declared = $declared; Resolved = $match; Exists = $true; Suffixed = $true; Description = $candidateDescription } - } - Write-Log "名录:$name 的 $declared -> $($found -join '、')(按前缀补全)" -Level DEBUG + $declared = (Expand-CatalogPathText -Text $declaredRaw).Trim() + if ([string]::IsNullOrWhiteSpace($declared)) { + $errors += "Slot $slotName 的 Path 展开成空:$declaredRaw" + continue + } + + # 逐个候选目录解析。一个 Slot 是归档内的一层目录,只能对应一个目录: + # 补全出多个候选(同名目录分散在多处)时必须拆成多个 Slot,否则会混成一棵树。 + $candidates = @() + if (Test-Path -LiteralPath $declared) { + $candidates = @($declared) } else { - # 找不到也留着:恢复时这正是"要把数据放回去"的那个位置 - $items += [pscustomobject]@{ Declared = $declared; Resolved = $declared; Exists = $false; Suffixed = $false; Description = $candidateDescription } + $parent = Split-Path -Path $declared -Parent + $leafName = Split-Path -Path $declared -Leaf + if ($parent -and $leafName -and (Test-Path -LiteralPath $parent)) { + $candidates = @(Find-ChildDirectoryByName -Parent $parent -Name $leafName -MaxDepth $MaxDepth) + } + } + + if ($candidates.Count -gt 1) { + $errors += ("Slot {0} 的 Path 匹配到 {1} 个目录:{2};一个 Slot 只能对应一个目录,请拆成多个 Slot" -f ` + $slotName, $candidates.Count, ($candidates -join '、')) + } + + $exists = $candidates.Count -ge 1 + $resolved = if ($exists) { $candidates[0] } else { $declared } + $isFile = $false + $suffixed = $false + if ($exists) { + $suffixed = -not ($resolved -ieq $declared) + $resolvedItem = Get-Item -LiteralPath $resolved -Force -ErrorAction SilentlyContinue + if ($resolvedItem) { $isFile = -not $resolvedItem.PSIsContainer } + } + + $excludeText = [string](Get-BaknretMapValue -Map $slotRaw -Key 'Exclude') + $includeText = [string](Get-BaknretMapValue -Map $slotRaw -Key 'Include') + $encryptValue = Get-BaknretMapValue -Map $slotRaw -Key 'Encrypt' + $description = [string](Get-BaknretMapValue -Map $slotRaw -Key 'Description') + + $slots += [pscustomobject]@{ + Name = $slotName + Declared = $declared + Resolved = $resolved + Exists = $exists + IsFile = $isFile + Suffixed = $suffixed + Description = $description + Exclude = @(ConvertFrom-BaknretPatternList -Values @($excludeText)) + Include = @(ConvertFrom-BaknretPatternList -Values @($includeText)) + Encrypt = [bool]$encryptValue } } - if ($items.Count -eq 0) { continue } - $existing = @($items | Where-Object { $_.Exists } | ForEach-Object { $_.Resolved }) - $missing = @($items | Where-Object { -not $_.Exists } | ForEach-Object { $_.Declared }) - $declaredList = @($items | ForEach-Object { $_.Declared }) + if ($slots.Count -eq 0 -and $errors.Count -eq 0) { continue } - $kind = if ($existing.Count -eq 0) { 'Unresolved' } + # PowerShell 的哈希表不保留书写顺序,而 Slot 的顺序会影响归档内条目顺序与 + # "第一个 Slot" 的取值,所以这里按名字排序,保证每次运行完全一致。 + $slots = @($slots | Sort-Object -Property Name) + + $existing = @($slots | Where-Object { $_.Exists }) + $missing = @($slots | Where-Object { -not $_.Exists }) + $kind = if ($slots.Count -eq 0) { 'Invalid' } + elseif ($existing.Count -eq 0) { 'Unresolved' } elseif ($missing.Count -gt 0) { 'Partial' } - elseif ($existing.Count -gt 1) { 'Multi' } + elseif ($slots.Count -gt 1) { 'Multi' } else { 'Single' } - if ($missing.Count -gt 0) { - # 多目录条目里少了一个目录值得告警(整包少了一块); - # 单目录条目少目录是常规情况(软件没装),备份端会明确说"跳过: X,源路径不存在", - # 这里降成 DEBUG,免得每个条目都刷两遍同样的警告。 - $missingText = "名录:{0} 有 {1} 个目录找不到:{2}" -f $name, $missing.Count, ($missing -join ';') - if ($items.Count -gt 1) { Write-Log $missingText -Level WARN } else { Write-Log $missingText -Level DEBUG } + if ($errors.Count -gt 0) { + Write-Log ("名录条目 {0} 有问题:{1}" -f $name, ($errors -join ';')) -Level ERROR + } elseif ($missing.Count -gt 0) { + Write-Log ("名录:{0} 有 {1} 个 Slot 的路径当前不存在:{2}" -f $name, $missing.Count, (($missing | ForEach-Object { $_.Declared }) -join ';')) -Level DEBUG + } + + if ($slots.Count -gt 0) { + Write-Log ("名录:{0} -> {1} 个 Slot,其中存在 {2} 个" -f $name, $slots.Count, $existing.Count) -Level DEBUG + } + + if ($result.ContainsKey($name)) { + Write-Log ("名录里有两条规范化之后同名的条目:{0}(后者覆盖前者)" -f $name) -Level WARN } - Write-Log ("名录:{0} -> 声明 {1} 个目录,其中存在 {2} 个" -f $name, $items.Count, $existing.Count) -Level DEBUG $result[$name] = [pscustomobject]@{ - Name = $name - # Path 保留"名录里写的那个字符串"。数组写法没有唯一字符串,取第一个候选项。 - Path = $(if ($rawPath) { $rawPath } else { $declaredList[0] }) - ResolvedPath = $(if ($existing.Count -gt 0) { $existing[0] } else { $missing[0] }) - Variants = $existing - Dirs = $existing - Declared = $declaredList - Items = $items - Missing = $missing - Description = $entryDescription - Kind = $kind - Raw = $entry - Suffixed = [bool](@($items | Where-Object { $_.Suffixed }).Count) + Name = $name + Path = $(if ($slots.Count -gt 0) { $slots[0].Declared } else { $null }) + Description = $(if ($slots.Count -gt 0) { $slots[0].Description } else { $null }) + Slots = @($slots) + Kind = $kind + Missing = @($missing | ForEach-Object { $_.Declared }) + Error = $(if ($errors.Count -gt 0) { $errors -join ';' } else { $null }) + Raw = $raw } } + if ($stamp) { + $script:CatalogCache[$Path] = [pscustomobject]@{ Stamp = $stamp; Data = $result } + } + return $result } @@ -909,63 +1410,202 @@ function Get-ItemArchiveName { return Get-BackupBaseName -RawPath $Entry.Path } -function New-BackupSourceItem { +function Get-BaknretArchiveTopName { + <# .SYNOPSIS 取归档内相对路径的第一段(顶层名字)。 #> + param([AllowEmptyString()][string]$ArchivePath) + + $clean = ([string]$ArchivePath).Trim().Trim([char[]]@('\', '/')) + if (-not $clean) { return '' } + $separator = $clean.IndexOfAny([char[]]@('\', '/')) + if ($separator -lt 0) { return $clean } + return $clean.Substring(0, $separator) +} + +function New-BaknretArchiveItem { <# .SYNOPSIS - 把一条路径整理成"要打包的一个源",并带上给人看的说明。 + 构造一个"归档项":宿主机上的一个目录 / 文件,对应归档内的一条路径。 .DESCRIPTION - 返回 @{ RootName; ParentDir; RelativePaths; SourcePath; Description; Origin }, - 拆不出父目录或末级名时返回 $null(相对路径、盘符根目录之类)。 + ArchivePath 是**归档内的相对路径**,语义分两种: + * 目录项 -> `\<目录内容>`(ArchivePath 是容器) + * 文件项 -> `` 就是那个文件本身 + 这样"是目录还是文件"只看归档就能判断,恢复端不必猜。 - 归档里的布局是"以 ParentDir 为工作目录、把 RelativePaths 加进去", - 所以 RelativePaths 既是**文件系统上的名字**,也是**归档里的顶层名字**。 - 7z 命令行没有"入库时改名"的能力,这两个名字只能是同一个 —— 因此 - Resolve-BackupEntry 会拦下"同一条目里两个同名目录"的情况。 - - Origin 说明这个源是怎么来的(catalog / path / append-catalog / append-path), - 运行时会打印出来,方便回答"这个目录为什么会被备份"。 + Origin 说明这个项是怎么来的(catalog / path / include),运行时会逐条打印, + 方便回答"这个目录为什么会在包里"。 #> param( - [string]$Path, - $RootName = $null, - $Description = $null, - [string]$Origin = 'catalog' + [Parameter(Mandatory = $true)][string]$ArchivePath, + [Parameter(Mandatory = $true)][string]$RealPath, + [ValidateSet('slot', 'path', 'include')][string]$Kind = 'slot', + [string]$Slot = $null, + [string]$Description = $null, + [string]$Origin = 'catalog', + [bool]$Exists = $false, + [bool]$IsFile = $false, + [string[]]$Exclude = @() ) - if ([string]::IsNullOrWhiteSpace($Path)) { return $null } - - $parent = Split-Path -Path $Path -Parent - $leaf = Split-Path -Path $Path -Leaf - if (-not $parent -or -not $leaf) { return $null } - + $clean = ([string]$ArchivePath).Trim().Trim([char[]]@('\', '/')) return [pscustomobject]@{ - RootName = $RootName - ParentDir = $parent - RelativePaths = @($leaf) - SourcePath = $Path - Description = $Description - Origin = $Origin + ArchivePath = $clean + TopName = (Get-BaknretArchiveTopName -ArchivePath $clean) + RealPath = $RealPath + Kind = $Kind + Slot = $Slot + Description = $Description + Origin = $Origin + Exists = $Exists + IsFile = $IsFile + Exclude = @($Exclude) } } +function New-BaknretJunction { + <# + .SYNOPSIS + 建一个 junction;失败时抛异常(调用方决定降级还是报错)。 + + .DESCRIPTION + 恢复时用它做"零拷贝落地":把 `<目标父目录>\` 建成指向真实目标目录的 + junction,再让 7z 往那里解(写入会穿过 junction 落到真实目录里), + 解完立刻拆掉连接点。这样不必"先解到临时目录再整体搬一遍"。 + #> + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$Target + ) + + if (Test-Path -LiteralPath $Path) { + throw "连接点目标已存在:$Path" + } + New-Item -ItemType Junction -Path $Path -Target $Target -ErrorAction Stop | Out-Null + return $Path +} + +function Remove-BaknretJunction { + <# + .SYNOPSIS + 只删连接点本身,绝不顺着它删到目标目录里去。 + #> + param([Parameter(Mandatory = $true)][string]$Path) + + if (-not (Test-Path -LiteralPath $Path)) { return } + try { + # Directory.Delete(path, recursive: $false) 删的只是重解析点,不碰目标内容 + [System.IO.Directory]::Delete($Path, $false) + } catch { + Remove-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue + } +} + +function New-BaknretArchiveStaging { + <# + .SYNOPSIS + 建一个暂存目录,把每个归档项按"归档内的名字"挂进去,供压缩工具直接打包。 + + .DESCRIPTION + 7z 没有"入库时改名"的能力:加进去的名字就是文件系统上的名字。Slot 要成为归档内的一层 + 目录,就得让它在暂存目录里真的叫那个名字: + + * 目录项 -> 建 junction(不复制数据,等于零成本改名); + * 文件项 -> 先试硬链接(同卷),失败再复制(配置文件都很小)。 + + 返回暂存目录路径;调用方用完必须调 Remove-BaknretArchiveStaging 清理。 + 建不出连接点时**明确抛错**,绝不悄悄退化成另一种归档布局 —— 布局一变,恢复就对不上。 + #> + param( + [Parameter(Mandatory = $true)][array]$Items, + [string]$Root = $null + ) + + if (-not $Root) { $Root = Join-Path $env:TEMP ('bnr-stage-' + [guid]::NewGuid().ToString('N')) } + if (-not (Test-Path -LiteralPath $Root)) { + New-Item -ItemType Directory -Path $Root -Force | Out-Null + } + + foreach ($item in $Items) { + if ([string]::IsNullOrWhiteSpace([string]$item.ArchivePath)) { + throw "归档项缺少归档内路径:$($item.RealPath)" + } + + $linkPath = Join-Path $Root $item.ArchivePath + $parent = Split-Path -Path $linkPath -Parent + if ($parent -and -not (Test-Path -LiteralPath $parent)) { + New-Item -ItemType Directory -Path $parent -Force | Out-Null + } + if (Test-Path -LiteralPath $linkPath) { Remove-BaknretJunction -Path $linkPath } + + if ($item.IsFile) { + try { + New-Item -ItemType HardLink -Path $linkPath -Target $item.RealPath -ErrorAction Stop | Out-Null + } catch { + Write-Log ("暂存:硬链接不可用({0}),改为复制文件 {1}" -f $_.Exception.Message, $item.RealPath) -Level DEBUG + Copy-Item -LiteralPath $item.RealPath -Destination $linkPath -Force -ErrorAction Stop + } + } else { + New-Item -ItemType Junction -Path $linkPath -Target $item.RealPath -ErrorAction Stop | Out-Null + } + + Write-Log ("暂存:{0} -> {1}" -f $item.ArchivePath, $item.RealPath) -Level DEBUG + } + + return $Root +} + +function Remove-BaknretArchiveStaging { + <# + .SYNOPSIS + 安全拆掉暂存目录:先手工摘掉 junction,再删剩下的普通文件 / 目录。 + + .DESCRIPTION + 绝不能直接 `Remove-Item -Recurse` 了事:那会顺着 junction 走进真实数据里。 + 这里自己走一遍目录树,遇到连接点只删连接点本身。 + #> + param([string]$Root) + + if (-not $Root -or -not (Test-Path -LiteralPath $Root)) { return } + + $pending = New-Object System.Collections.Generic.Stack[string] + $pending.Push($Root) + while ($pending.Count -gt 0) { + $current = $pending.Pop() + foreach ($child in @(Get-ChildItem -LiteralPath $current -Force -ErrorAction SilentlyContinue)) { + if ($child.LinkType -eq 'Junction' -or $child.LinkType -eq 'SymbolicLink') { + Remove-BaknretJunction -Path $child.FullName + continue + } + if ($child.PSIsContainer) { $pending.Push($child.FullName) } + } + } + + Remove-Item -LiteralPath $Root -Recurse -Force -ErrorAction SilentlyContinue +} + function Resolve-BackupEntry { <# .SYNOPSIS - 把清单条目解析成"实际要备份什么"。 + 把清单条目解析成"实际要打包什么、归档里长什么样"。 .DESCRIPTION - 返回 @{ IsName; BaseName; Sources; Source; ArchiveFlavor },其中: - * IsName —— 这一行写的是软件名还是字面路径 - * BaseName —— 归档基础名 - * Sources —— 要备份的项目列表(一个根目录名 -> 该根目录下的一组相对路径) - * ArchiveFlavor —— 'name'(归档根目录叫软件名)或 'path'(叫源目录名) + 返回: + + IsName / BaseName / ArchiveFlavor / Direction + CatalogEntry —— 名录条目(软件名写法才有) + Items —— 归档项数组(见 New-BaknretArchiveItem) + Encrypt —— 该归档是否加密 + ExcludePatterns / HasExcludeOverride —— 条目级 `:-` / `@ Exclude` 覆盖 + Includes / HasIncludeOverride —— 条目级 `:+` / `@ Include` 覆盖 + Error —— 可恢复的问题(例如名录里路径不存在) + Blocking —— 必须整条失败的问题(归档内路径冲突等) 归档内部布局: - * 软件名条目 -> 根目录用软件名,内容为 `<源目录名>\...` - (这样恢复时能知道文件原来属于哪个目录) - * 字面路径条目 -> 整条目直接写进归档,保持与历史归档完全一致的布局, - 否则现有归档一旦被重打,恢复就会失败。 + * 软件名条目 -> `\`(文件 Slot 就是名为 `` 的文件); + * 手写路径 -> `<末级名>\...`(与历史归档一致,不变)。 + + 名录里的 Slot 存在但路径当前不存在时**照样产出归档项**:源被删掉正是要恢复的场景, + 备份端按存在性跳过,恢复端靠它把内容还原回原位。 #> param( $Entry, @@ -974,182 +1614,277 @@ function Resolve-BackupEntry { ) $isName = -not (Test-LiteralPath -Path $Entry.Path) - # @pathname 强制按"字面路径条目"处理:归档名用路径算法, - # 但清单里写的是软件名,真实路径仍要经名录解析。 $forcePathFlavor = ($Entry.Flags -contains 'pathname') $baseName = Get-ItemArchiveName -Entry $Entry -CatalogPath $CatalogPath -MaxDepth $MaxDepth - $rootNames = @($Entry.Flags | Where-Object { $_ -like 'root=*' } | ForEach-Object { $_.Substring(5) }) - # 必须在下面任何一个分支之前算出来:名录里没有这个名字时也要用它, - # 否则会读到调用方作用域里残留的 $rootName(PowerShell 是动态作用域)。 - $rootName = if ($rootNames.Count -gt 0) { $rootNames[0] } else { $baseName } + $overrides = $Entry.Overrides + if (-not $overrides) { $overrides = @{} } + $overridePath = if ($overrides.ContainsKey('Path')) { [string]$overrides['Path'] } else { $null } + $hasExcludeOverride = $overrides.ContainsKey('Exclude') + $entryExclude = if ($hasExcludeOverride) { @($overrides['Exclude']) } else { @() } + $hasIncludeOverride = $overrides.ContainsKey('Include') + $entryInclude = if ($hasIncludeOverride) { @($overrides['Include']) } else { @() } + $hasEncryptOverride = $overrides.ContainsKey('Encrypt') + $items = @() $catalogEntry = $null - $sources = @() - $archiveFlavor = 'name' $errorText = $null + $blocking = $null + $archiveFlavor = if ($isName) { 'name' } else { 'path' } if (-not $isName) { - # ---- 写法二:用户手写的目录 / 文件(含 \ / 或 % 就按路径处理)---- - $archiveFlavor = 'path' - $source = New-BackupSourceItem -Path ([Environment]::ExpandEnvironmentVariables($Entry.Path)) -Origin 'path' - if ($source) { $sources += $source } + # ---- 写法二:用户手写的目录 / 文件 ---- + $real = [string]$Entry.Path + if ($overridePath) { $real = $overridePath } + $real = [Environment]::ExpandEnvironmentVariables($real).Trim() + + $leaf = Split-Path -Path $real -Leaf + if ($forcePathFlavor) { $archiveFlavor = 'path' } + + $exists = $false + $isFile = $false + if ($real) { + $exists = Test-Path -LiteralPath $real + if ($exists) { + $item = Get-Item -LiteralPath $real -Force -ErrorAction SilentlyContinue + if ($item) { $isFile = -not $item.PSIsContainer } + } + } + + if ([string]::IsNullOrWhiteSpace($leaf) -or [string]::IsNullOrWhiteSpace($real)) { + $errorText = "无法从路径里拆出末级名:$real" + } else { + $items += New-BaknretArchiveItem -ArchivePath $leaf -RealPath $real -Kind 'path' ` + -Origin 'path' -Exists $exists -IsFile $isFile + } } else { # ---- 写法一:软件名录里的软件名 ---- $catalog = Get-SoftwareCatalog -Path $CatalogPath -MaxDepth $MaxDepth if (-not $catalog.ContainsKey($Entry.Path)) { $errorText = "软件名录里没有 '$($Entry.Path)'" - } - else { + } else { $catalogEntry = $catalog[$Entry.Path] - # 单目录条目的 RootName 沿用软件名(历史行为);多目录留空 - $catalogRootName = if ($catalogEntry.Kind -eq 'Single') { $rootName } else { $null } - - if ($forcePathFlavor) { - # @pathname:归档名走路径算法,包内布局按源目录名 - $archiveFlavor = 'path' - $source = New-BackupSourceItem -Path $catalogEntry.ResolvedPath -Origin 'catalog' - if ($source) { $sources += $source } - if ($catalogEntry.Kind -eq 'Unresolved') { $errorText = "名录里的路径不存在:$($catalogEntry.Path)" } + # 名录条目自身有问题(Slot 缺 Path、前缀补全命中多个目录……)时整条失败: + # 继续跑只会"少打包一块"或"任选一个目录",那正是最该避免的静默错误。 + if ($catalogEntry.Error) { + $errorText = $catalogEntry.Error + if (-not $blocking) { $blocking = "软件名录里的 '$($Entry.Path)' 有问题:$($catalogEntry.Error)" } } - else { - # 名录里声明了几个目录就产出几个源 —— **当前不存在的那几个也要留着**: - # 恢复时正是要靠它们把内容还原回原位;备份端按存在性自己跳过。 - foreach ($item in $catalogEntry.Items) { - $source = New-BackupSourceItem -Path $item.Resolved -RootName $catalogRootName ` - -Description $item.Description -Origin 'catalog' - if ($source) { $sources += $source } - } - if ($catalogEntry.Kind -eq 'Unresolved') { - $errorText = "名录里的路径不存在:$($catalogEntry.Path)" - } elseif ($catalogEntry.Kind -eq 'Partial') { - $errorText = ("名录里有 {0} 个目录当前不存在,备份会跳过它们:{1}" -f ` - $catalogEntry.Missing.Count, ($catalogEntry.Missing -join ';')) + + $slots = @($catalogEntry.Slots) + if ($overridePath -and $slots.Count -ne 1) { + $blocking = ("'{0}' 有 {1} 个 Slot,不能用一个 `::` / `@ Path=` 覆盖路径;请写清楚是哪个 Slot" -f ` + $Entry.Path, $slots.Count) + } else { + foreach ($slot in $slots) { + $resolvedPath = $slot.Resolved + $exists = $slot.Exists + $isFile = $slot.IsFile + + if ($overridePath) { + $resolvedPath = [Environment]::ExpandEnvironmentVariables($overridePath).Trim() + $exists = Test-Path -LiteralPath $resolvedPath + $isFile = $false + if ($exists) { + $item = Get-Item -LiteralPath $resolvedPath -Force -ErrorAction SilentlyContinue + if ($item) { $isFile = -not $item.PSIsContainer } + } + } + + $items += New-BaknretArchiveItem -ArchivePath $slot.Name -RealPath $resolvedPath -Kind 'slot' ` + -Slot $slot.Name -Description $slot.Description -Origin 'catalog' ` + -Exists $exists -IsFile $isFile -Exclude $slot.Exclude } } } } # ------------------------------------------------------------------ - # 追加段:`:+ <路径 或 软件名>`,**两种写法都生效** - # 以前这一节只接在"软件名且能解析出目录"的那条路径后面,手写路径的 :+ 会被整段丢掉。 + # 包含项:`<归档内相对路径>:<宿主机绝对路径>`,把宿主机上的目录 / 文件放到包内指定位置。 + # 条目级写 `:+` / `@ Include=` 就覆盖名录里的 Include;没写就用名录里各 Slot 的。 # ------------------------------------------------------------------ - foreach ($added in @($Entry.AddedPaths)) { - if ([string]::IsNullOrWhiteSpace([string]$added)) { continue } - $addedText = ([string]$added).Trim() - $addedPath = [Environment]::ExpandEnvironmentVariables($addedText) + $includeTexts = @() + if ($hasIncludeOverride) { + $includeTexts = @($entryInclude) + } elseif ($catalogEntry) { + foreach ($slot in @($catalogEntry.Slots)) { $includeTexts += @($slot.Include) } + } - if (-not (Test-LiteralPath -Path $addedPath)) { - # 写得像软件名:按名录展开成它的全部目录 - $addedCatalog = Get-SoftwareCatalog -Path $CatalogPath -MaxDepth $MaxDepth - if ($addedCatalog.ContainsKey($addedPath)) { - foreach ($item in $addedCatalog[$addedPath].Items) { - $source = New-BackupSourceItem -Path $item.Resolved -Description $item.Description -Origin 'append-catalog' - if ($source) { $sources += $source } + foreach ($includeText in $includeTexts) { + if ([string]::IsNullOrWhiteSpace($includeText)) { continue } + $text = ([string]$includeText).Trim() + + $archivePart = '' + $hostPart = $text + $separator = $text.IndexOf(':') + if ($separator -ge 0) { + $archivePart = $text.Substring(0, $separator).Trim() + $hostPart = $text.Substring($separator + 1).Trim() + + # 写成 `D:\extra\ps-modules:Modules`(宿主机在前)时纠正并告警。 + # 判据:第一个冒号前只有盘符那一个字母,而且紧跟着 `\` 或 `/`。 + # 这时按**最后一个**冒号切,才能把宿主机路径完整地拿回来。 + if ($archivePart -match '^[A-Za-z]$' -and ($hostPart.StartsWith('\') -or $hostPart.StartsWith('/'))) { + $lastSeparator = $text.LastIndexOf(':') + if ($lastSeparator -gt $separator) { + Write-Log ("包含项写得像'宿主机:归档内':{0} —— 语法应为 <归档内相对路径>:<宿主机绝对路径>,已按后者解释" -f $text) -Level WARN + $hostPart = $text.Substring(0, $lastSeparator).Trim() + $archivePart = $text.Substring($lastSeparator + 1).Trim() } - continue } - Write-Log "追加项 '$addedText' 既不是字面路径,也不在软件名录里,已忽略" -Level WARN + } + + $hostPath = [Environment]::ExpandEnvironmentVariables($hostPart).Trim() + if ([string]::IsNullOrWhiteSpace($hostPath)) { + Write-Log "包含项 '$text' 里没有宿主机路径,已忽略" -Level WARN continue } - $source = New-BackupSourceItem -Path $addedPath -Origin 'append-path' - if ($source) { $sources += $source } - else { Write-Log "追加项无法拆出父目录与末级名,已忽略:$addedText" -Level WARN } + $exists = Test-Path -LiteralPath $hostPath + $isFile = $false + if ($exists) { + $item = Get-Item -LiteralPath $hostPath -Force -ErrorAction SilentlyContinue + if ($item) { $isFile = -not $item.PSIsContainer } + } + + $archivePath = $archivePart + if ([string]::IsNullOrWhiteSpace($archivePath)) { $archivePath = Split-Path -Path $hostPath -Leaf } + + $items += New-BaknretArchiveItem -ArchivePath $archivePath -RealPath $hostPath -Kind 'include' ` + -Origin 'include' -Exists $exists -IsFile $isFile } # ------------------------------------------------------------------ - # 归档内顶层同名冲突拦截 - # 7z 加进来的路径,在归档里就是**文件系统上的那个名字**(命令行没有"入库改名"的能力)。 - # 所以同一个条目里出现两个同名目录(例如两个 persist)时,它们在包内会混成一棵树, - # 解出来两边的内容都是错的。宁可明确报错,也不要静默搅在一起。 + # 归档内路径冲突拦截 + # 一个目录 / 文件在包内只能有一个位置:重名会互相覆盖,祖宗关系会混成一棵树。 + # 宁可明确报错,也不要静默搅在一起。 # ------------------------------------------------------------------ - $seenTop = @{} + $seen = @{} $collisions = @() - foreach ($source in $sources) { - $top = @($source.RelativePaths)[0] - if (-not $top) { continue } - if ($seenTop.ContainsKey($top)) { - $collisions += ("'{0}'({1} 与 {2})" -f $top, $seenTop[$top], $source.SourcePath) + foreach ($item in $items) { + $key = ([string]$item.ArchivePath).ToLower() + if (-not $key) { continue } + if ($seen.ContainsKey($key)) { + $collisions += ("'{0}'({1} 与 {2})" -f $item.ArchivePath, $seen[$key], $item.RealPath) } else { - $seenTop[$top] = $source.SourcePath + $seen[$key] = $item.RealPath + } + } + foreach ($item in $items) { + $key = ([string]$item.ArchivePath).ToLower() + foreach ($other in $seen.Keys) { + if ($other -eq $key) { continue } + if ($other.StartsWith("$key\") -or $key.StartsWith("$other\")) { + $collisions += ("'{0}' 与 '{1}' 是父子关系,包内会互相覆盖" -f $item.ArchivePath, $other) + } + } + } + $collisions = @($collisions | Select-Object -Unique) + + if ($collisions.Count -gt 0) { + $blocking = ("归档内路径冲突:{0}。每个 Slot / 追加项在包内必须有唯一位置," + + "请改 Slot 名或归档内相对路径。") -f ($collisions -join ';') + } + + # ------------------------------------------------------------------ + # 加密:清单覆盖优先,其次是名录里各 Slot 的 Encrypt 取或。 + # 一个软件一个归档,所以 Slot 之间不一致时按"加密"处理(宁可多加密,不可漏加密)。 + # ------------------------------------------------------------------ + $encrypt = $false + if ($hasEncryptOverride) { + $encrypt = [bool]$overrides['Encrypt'] + } elseif ($catalogEntry) { + $slots = @($catalogEntry.Slots) + $encryptedSlots = @($slots | Where-Object { $_.Encrypt }) + $encrypt = $encryptedSlots.Count -gt 0 + if ($encryptedSlots.Count -gt 0 -and $encryptedSlots.Count -lt $slots.Count) { + Write-Log ("{0}:名录里各 Slot 的 Encrypt 不一致,整个归档按加密处理" -f $Entry.Path) -Level WARN } } - $blocking = $null - if ($collisions.Count -gt 0) { - $blocking = ("归档内顶层同名,无法区分:{0}。7z 不能把同一个源在包内改名,它们会在归档里混成一棵树;" + - "请把它们拆成两个独立条目(各自一个归档)。") -f ($collisions -join ';') - } - return [pscustomobject]@{ - IsName = [bool]$isName - CatalogEntry = $catalogEntry - BaseName = $baseName - ArchiveFlavor = $archiveFlavor - RootName = $(if ($isName -and -not $forcePathFlavor) { $rootName } else { $null }) - Sources = @($sources) - Source = $Entry.Path - Error = $errorText - Blocking = $blocking + IsName = [bool]$isName + CatalogEntry = $catalogEntry + BaseName = $baseName + ArchiveFlavor = $archiveFlavor + Direction = $Entry.Direction + Items = @($items) + Encrypt = [bool]$encrypt + ExcludePatterns = @($entryExclude) + HasExcludeOverride = [bool]$hasExcludeOverride + Includes = @($entryInclude) + HasIncludeOverride = [bool]$hasIncludeOverride + Source = $Entry.Path + Error = $errorText + Blocking = $blocking } } function Write-BackupEntryPlan { <# .SYNOPSIS - 在动手打包之前,把"这个条目会打包哪些目录、排除了什么、为什么"打印出来。 + 在动手打包之前,把"这条会打包哪些目录、归档里长什么样、排除了什么、为什么"打印出来。 .DESCRIPTION - 目录说明来自 SoftwareCatalog;排除 / 追加的**来源**来自清单,逐项打印: - * 每个目录一行:路径、它是怎么来的(名录 / 手写路径 / :+ 追加)、 - 当前在不在、以及这个目录是干什么的(Description); - * 排除模式按来源分组打印:清单的 :- 段、BackupConfig.psd1 的 DefaultExcludes; - * 清单行尾的 `# 说明` 作为这条目的整体说明打印出来。 + 逐项打印:归档内路径、宿主机路径、它是怎么来的(名录 / 手写路径 / 追加)、 + 当前在不在、是文件还是目录、以及这个 Slot 是干什么的(Description)。 #> param( [Parameter(Mandatory = $true)]$Resolved, [Parameter(Mandatory = $true)][string]$DisplayPath, [string[]]$ListExcludes = @(), + [string[]]$CatalogExcludes = @(), [string[]]$ConfigExcludes = @(), [string]$Comment ) $originText = @{ - 'catalog' = '软件名录' - 'path' = '手写路径' - 'append-catalog' = '清单 :+ 追加(按软件名录展开)' - 'append-path' = '清单 :+ 追加(字面路径)' + 'catalog' = '软件名录' + 'path' = '手写路径' + 'include' = '追加项(清单 :+ / 名录 Include)' + } + $directionText = @{ + 'both' = '备份 + 恢复' + 'backup' = '仅备份(行首 +)' + 'restore' = '仅恢复(行首 -)' } Write-Log ("条目:{0}" -f $DisplayPath) - Write-Log (" 归档:{0}" -f $Resolved.BaseName) + Write-Log (" 归档:{0}.7z;方向:{1};加密:{2}" -f $Resolved.BaseName, + $(if ($directionText.ContainsKey($Resolved.Direction)) { $directionText[$Resolved.Direction] } else { $Resolved.Direction }), + $(if ($Resolved.Encrypt) { '是' } else { '否' })) if ($Comment) { Write-Log (" 说明:{0}" -f $Comment) } if ($Resolved.Error) { Write-Log (" 提示:{0}" -f $Resolved.Error) -Level WARN } - $sources = @($Resolved.Sources) - if ($sources.Count -eq 0) { - Write-Log ' 目录:没有解析出任何目录' -Level WARN - } + $items = @($Resolved.Items) + if ($items.Count -eq 0) { Write-Log ' 归档项:没有解析出任何目录' -Level WARN } - for ($index = 0; $index -lt $sources.Count; $index++) { - $source = $sources[$index] - $exists = Test-Path -LiteralPath $source.SourcePath - $origin = if ($source.Origin -and $originText.ContainsKey($source.Origin)) { $originText[$source.Origin] } else { $source.Origin } + for ($index = 0; $index -lt $items.Count; $index++) { + $item = $items[$index] + $exists = Test-Path -LiteralPath $item.RealPath + $origin = if ($item.Origin -and $originText.ContainsKey($item.Origin)) { $originText[$item.Origin] } else { $item.Origin } - Write-Log (" 目录 {0}/{1}:{2}" -f ($index + 1), $sources.Count, $source.SourcePath) - Write-Log (" 来源:{0};{1}" -f $origin, $(if ($exists) { '存在,会打包' } else { '当前不存在,本次跳过' })) - if ($source.Description) { Write-Log (" 介绍:{0}" -f $source.Description) } + Write-Log (" 归档项 {0}/{1}:{2} <- {3}" -f ($index + 1), $items.Count, $item.ArchivePath, $item.RealPath) + Write-Log (" 来源:{0};{1};{2}" -f $origin, + $(if ($exists) { '存在,会打包' } else { '当前不存在,本次跳过' }), + $(if ($item.IsFile) { '文件' } else { '目录' })) + if ($item.Description) { Write-Log (" 介绍:{0}" -f $item.Description) } + if (@($item.Exclude).Count -gt 0) { + Write-Log (" 名录里的排除:{0}" -f (@($item.Exclude) -join '、')) + } } if ($ListExcludes.Count -gt 0) { - Write-Log (" 排除 {0} 条(来自清单的 :- 段):{1}" -f $ListExcludes.Count, ($ListExcludes -join '、')) + Write-Log (" 排除 {0} 条(来自清单的 :- / @ Exclude):{1}" -f $ListExcludes.Count, ($ListExcludes -join '、')) + } + if ($CatalogExcludes.Count -gt 0) { + Write-Log (" 排除 {0} 条(来自名录 Slot 的 Exclude):{1}" -f $CatalogExcludes.Count, ($CatalogExcludes -join '、')) } if ($ConfigExcludes.Count -gt 0) { Write-Log (" 排除 {0} 条(来自 BackupConfig.psd1 的 DefaultExcludes):{1}" -f $ConfigExcludes.Count, ($ConfigExcludes -join '、')) } - if ($ListExcludes.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 ' 排除:无(整包收下)' } } @@ -1410,6 +2145,867 @@ function Move-BaknretArchiveIntoPlace { Move-Item -LiteralPath $TempPath -Destination $DestinationPath -Force } +# ============================================================================ +# 安全描述符(NTFS 属主 / ACL) +# ============================================================================ +# 为什么需要它:归档格式(.7z / .zip / .tar)**不承载 NT 安全描述符** —— +# 7-Zip 的 -sni(Store NT security information)官方文档写明"当前版本只能写进 WIM 归档"。 +# 于是"备份 → 恢复"之后,每个对象的安全描述符都是新建对象的默认值: +# 属主是跑恢复脚本的那个进程,DACL 是从目标父目录继承来的那一套。 +# +# 对 C:\ProgramData 下的目录这是致命的,它的 ACL 里有: +# (A;OICIIO;GA;;;CO) CREATOR OWNER + inherit-only + GENERIC_ALL +# 而 CREATOR OWNER(S-1-3-0)不是账户,是**访问检查时才替换的占位符**: +# 替换成"被检查对象的属主"。所以只回放 ACE 文本、不恢复属主,等于把 +# "谁创建的东西谁有全权"里的那个"谁"换成了跑脚本的账户,原程序反而没权限。 +# +# 存储格式:每对象一条 SDDL($acl.Sddl 原文)。SDDL 的 SID 是数值形式,CO / OW +# 这类占位符原样保留,往返无损;**绝不做账户名解析**——名字解析会把占位符映射成 +# 当前用户,或者直接抛 IdentityNotMappedException,那正是"权限落到脚本头上"的另一种成因。 +# +# 恢复:自顶向下、每个对象一次写 Owner|Group|Access;原本不 protected 的 DACL +# 只写显式 ACE,其余交给(已经修好的)父目录重新继承,保住"活继承"的语义。 +# 写属主需要 SeRestorePrivilege,而且必须**显式启用**:管理员的过滤令牌里它默认是 +# disabled,Set-Acl / SetAccessControl 都不会替你打开(见 Enable-BaknretPrivilege)。 + +$script:BaknretPrivilegeState = @{} + +function Enable-BaknretPrivilege { + <# + .SYNOPSIS + 在当前进程令牌里启用指定特权,返回哪些没能启用。 + + .DESCRIPTION + 必须显式启用。MSDN(SetNamedSecurityInfoW)写明: + "If the caller does not have the SeRestorePrivilege constant, this SID must be + contained in the caller's token, and must have the SE_GROUP_OWNER permission + enabled." 也就是说没有它就没法把属主改成别的账户,而失败信息只有一句 + "Access is denied"(easily mistaken for a path problem)。 + + 两个坑: + * 结构体嵌套赋值(`$tp.Privileges.Luid.LowPart = …`)在 PowerShell 里改的是 + 装箱副本,改了不生效,所以整段放进 C# 里做; + * AdjustTokenPrivileges 返回 true 也可能是 ERROR_NOT_ALL_ASSIGNED(1300), + 那代表特权根本不在令牌里,必须当成失败。 + + 返回 [pscustomobject]@{ Enabled; Missing; Failed }(都是名字数组)。 + #> + param([string[]]$Name = @('SeRestorePrivilege', 'SeBackupPrivilege')) + + $result = [pscustomobject]@{ + Enabled = @() + Missing = @() + Failed = @() + } + + if (-not ('Baknret.Privileges' -as [type])) { + try { + Add-Type -Namespace Baknret -Name Privileges -MemberDefinition @' +[DllImport("advapi32.dll", SetLastError = true)] +static extern bool OpenProcessToken(IntPtr h, int acc, out IntPtr phtok); +[DllImport("advapi32.dll", SetLastError = true)] +static extern bool LookupPrivilegeValue(string host, string name, out long pluid); +[DllImport("advapi32.dll", SetLastError = true)] +static extern bool AdjustTokenPrivileges(IntPtr htok, bool disall, + ref TOKEN_PRIVILEGES newst, int len, IntPtr prev, IntPtr relen); +[DllImport("kernel32.dll")] static extern IntPtr GetCurrentProcess(); +[DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr h); +[StructLayout(LayoutKind.Sequential)] public struct LUID { public uint LowPart; public int HighPart; } +[StructLayout(LayoutKind.Sequential)] public struct LUID_AND_ATTRIBUTES { public LUID Luid; public uint Attributes; } +[StructLayout(LayoutKind.Sequential)] public struct TOKEN_PRIVILEGES { public uint PrivilegeCount; public LUID_AND_ATTRIBUTES Privileges; } +// 0 = 已启用;1 = 令牌里没有这个特权;2 = 其它失败 +public static int Enable(string name) { + IntPtr token; + if (!OpenProcessToken(GetCurrentProcess(), 0x28, out token)) { return 2; } + try { + long luid; + if (!LookupPrivilegeValue(null, name, out luid)) { return 1; } + TOKEN_PRIVILEGES tp = new TOKEN_PRIVILEGES(); + tp.PrivilegeCount = 1; + tp.Privileges.Luid.LowPart = (uint)(luid & 0xFFFFFFFF); + tp.Privileges.Luid.HighPart = (int)(luid >> 32); + tp.Privileges.Attributes = 0x2; + if (!AdjustTokenPrivileges(token, false, ref tp, 0, IntPtr.Zero, IntPtr.Zero)) { return 2; } + if (Marshal.GetLastWin32Error() == 1300) { return 1; } + return 0; + } finally { CloseHandle(token); } +} +'@ + } catch { + Write-Log "特权启用代码编译失败(本次不启用任何特权):$($_.Exception.Message)" -Level WARN + $result.Failed = @($Name) + return $result + } + } + + $enabled = @(); $missing = @(); $failed = @() + foreach ($privilege in @($Name)) { + $cacheKey = $privilege + if ($script:BaknretPrivilegeState.ContainsKey($cacheKey)) { + $state = $script:BaknretPrivilegeState[$cacheKey] + } else { + $state = [Baknret.Privileges]::Enable($privilege) + $script:BaknretPrivilegeState[$cacheKey] = $state + } + switch ($state) { + 0 { $enabled += $privilege } + 1 { $missing += $privilege } + default { $failed += $privilege } + } + } + + if ($missing.Count -gt 0) { + Write-Log ("这些特权不在当前令牌里(需要管理员或 SYSTEM):{0} —— 属主将无法改成别的账户,只能恢复 DACL" -f ($missing -join '、')) -Level WARN + } + if ($failed.Count -gt 0) { + Write-Log ("这些特权启用失败:{0}" -f ($failed -join '、')) -Level WARN + } + + $result.Enabled = @($enabled) + $result.Missing = @($missing) + $result.Failed = @($failed) + return $result +} + +function ConvertTo-BaknretWildcardPattern { + <# + .SYNOPSIS + 把 7z 风格的通配符(* 与 ?)转成正则片段。 + + .DESCRIPTION + 与 Get-BaknretExcludeArgument 保持一致:模式里的空格先转成 `?`(7z 的 + `-x!` 不接受带空格的模式)。`*` 转 `.*`,跨过路径分隔符, + 这样锚定模式 `Default\*` 才能命中 `Default\a\b`。 + #> + param([AllowEmptyString()][string]$Pattern) + + $text = ([string]$Pattern) -replace ' ', '?' + $escaped = [regex]::Escape($text) + $escaped = $escaped -replace '\\\*', '.*' + $escaped = $escaped -replace '\\\?', '.' + return $escaped +} + +function Test-BaknretPathExcluded { + <# + .SYNOPSIS + 判断归档内的一个相对路径是否命中排除模式。 + + .DESCRIPTION + 安全描述符采集走的目录树必须和真正打进归档的那棵树一致,否则会出现 + "归档里有、安全描述符里没有"(恢复后那块内容变成新建对象的默认 ACL)。 + 所以这里与交给 7z 的 -x! / -xr! 语义对齐: + + * `<相对路径>` 锚定在本归档项的根上(`Default\Cache` 只命中它自己那棵子树) + * `!<通配>` 任意层级按**组件名**匹配(`!*Cache` 命中任意一层叫 *Cache 的目录) + * `!re:<正则>` 正则:命中组件名或整条相对路径 + + $RelativePath 用 `\` 分隔,且**不含归档项的根名**。 + #> + param( + [AllowEmptyString()][string]$RelativePath, + [string[]]$Patterns = @() + ) + + $relative = ([string]$RelativePath).Trim([char[]]@('\', '/')) + if (-not $relative) { return $false } + $components = @($relative -split '\\') + + foreach ($pattern in @($Patterns)) { + if ([string]::IsNullOrWhiteSpace($pattern)) { continue } + $text = ([string]$pattern).Trim() + + if ($text.StartsWith('!re:')) { + $regexText = $text.Substring(4).Trim() + if (-not $regexText) { continue } + try { + $options = [System.Text.RegularExpressions.RegexOptions]::IgnoreCase + if ([regex]::IsMatch($relative, $regexText, $options)) { return $true } + foreach ($component in $components) { + if ([regex]::IsMatch($component, $regexText, $options)) { return $true } + } + } catch { + Write-Log "排除正则非法,安全描述符采集按'不排除'处理:$regexText —— $($_.Exception.Message)" -Level WARN + } + continue + } + + if ($text.StartsWith('!')) { + $wildcard = $text.Substring(1).Trim() + if (-not $wildcard) { continue } + $componentPattern = '^(?:' + (ConvertTo-BaknretWildcardPattern -Pattern $wildcard) + ')$' + foreach ($component in $components) { + if ($component -match $componentPattern) { return $true } + } + continue + } + + $anchored = ([string]$text).Trim([char[]]@('\', '/')) + if (-not $anchored) { continue } + $anchoredPattern = '^(?:' + (ConvertTo-BaknretWildcardPattern -Pattern $anchored) + ')$' + if ($relative -match $anchoredPattern) { return $true } + } + + return $false +} + +function Get-BaknretAceSignatureList { + <# + .SYNOPSIS + 把 ACE 列表压成可比对的"签名"集合(`类型|SID|掩码`)。 + + .DESCRIPTION + 只用来回答一个问题:"子对象上这条继承来的 ACE,在父目录的 ACL 里找得到出处吗?" + 所以**刻意不带继承标志位**:同一条 ACE 传给文件子对象时容器继承位会被去掉 + (实测父目录的 (A;OICI;FA;;;SY) 到文件上变成 (A;ID;FA;;;SY)), + 带上标志比较会永远不相等。掩码取 AccessMask 整数值,避免枚举把组合权限拆得不一样。 + #> + param([array]$Rules = @()) + + $list = @() + foreach ($rule in @($Rules)) { + if (-not $rule) { continue } + $mask = -1 + try { $mask = [int]$rule.FileSystemRights } catch { $mask = -1 } + $list += ('{0}|{1}|{2}' -f $rule.AccessControlType, $rule.IdentityReference.Value, $mask) + } + return $list +} + +function Get-BaknretSecuritySddlWithStale { + <# + .SYNOPSIS + 对象与父目录的继承链**不自洽**时,把整套 ACE 冻结成显式副本(并置 protected), + 返回改写后的 SDDL;自洽时原样返回 $Acl.Sddl。 + + .DESCRIPTION + 恢复时只重放**显式** ACE,其余交给父目录重新继承 —— 对绝大多数对象这是最忠实的 + 做法(父目录修好之后继承会长出同样的 ACE,还保住了活继承语义)。 + + 但有一类对象不行:它的 DACL 里留着**陈旧**的继承 ACE —— 父目录早就改过权限, + 这条 ACE 已经没有任何出处。真机实测两件事: + + 1) 把父目录设成 protected 的新 DACL 之后,子对象仍留着从祖父目录继承来的 + `(A;ID;FA;;;S-1-5-21-…)`;条数与父目录的可继承条数**正好都是 4**、内容却不同 + —— 所以判据必须比 ACE 内容,不能只数条数。 + 2) Windows 在改写父目录时**不会**替子对象清掉这种已无出处的 ACE。于是 + "目标上本来就留着它 + 我又补写一条显式 ACE" = 同一条 ACE 出现两次。 + + 所以这类对象只能整套冻结:显式 ACE + 陈旧 ACE 全部按显式写,并置 protected + (protected 才不会被系统再补一遍继承 ACE)。代价是这个对象从此不跟随父目录 + —— 但它本来就已经跟父目录脱节了,冻结是唯一"不丢 ACE、也不重复 ACE"的做法。 + + $ParentSignatures 为 $null 表示"调用方没有父目录上下文"(归档项根、单文件项), + 此时不做任何改写。 + #> + param( + [Parameter(Mandatory = $true)]$Acl, + [AllowNull()][string[]]$ParentSignatures = $null + ) + + if ($null -eq $ParentSignatures) { return $Acl.Sddl } + + $sid = [System.Security.Principal.SecurityIdentifier] + $inherited = @($Acl.GetAccessRules($false, $true, $sid)) + if ($inherited.Count -eq 0) { return $Acl.Sddl } + + # 自洽 = 继承来的 ACE 每一条都能在父目录的 ACL 里找到出处 + $stale = @() + foreach ($rule in $inherited) { + $signature = @(Get-BaknretAceSignatureList -Rules @($rule))[0] + if ($ParentSignatures -notcontains $signature) { $stale += $rule } + } + if ($stale.Count -eq 0) { return $Acl.Sddl } + + $rebuilt = $null + if ($Acl -is [System.Security.AccessControl.DirectorySecurity]) { + $rebuilt = New-Object System.Security.AccessControl.DirectorySecurity + } else { + $rebuilt = New-Object System.Security.AccessControl.FileSecurity + } + + # 整套(显式 + 继承)都按显式写:内容与备份时逐条一致,不靠继承去"猜"回来 + foreach ($rule in @($Acl.GetAccessRules($true, $true, $sid))) { $rebuilt.AddAccessRule($rule) } + + $sections = [System.Security.AccessControl.AccessControlSections]::Access + try { + $rebuilt.SetOwner($Acl.GetOwner($sid)) + $sections = $sections -bor [System.Security.AccessControl.AccessControlSections]::Owner + } catch { } + try { + $rebuilt.SetGroup($Acl.GetGroup($sid)) + $sections = $sections -bor [System.Security.AccessControl.AccessControlSections]::Group + } catch { } + + $rebuilt.SetAccessRuleProtection($true, $false) + + Write-Log ("{0} 条继承 ACE 已无出处(父目录里找不到),整套 ACE 冻结为显式并置 protected" -f $stale.Count) -Level DEBUG + return $rebuilt.GetSecurityDescriptorSddlForm($sections) +} + +function Get-BaknretSecurityRecord { + <# + .SYNOPSIS + 读一个对象的安全描述符,产出可序列化的一条记录。 + + .DESCRIPTION + 返回 [pscustomobject]: + p / k 归档内相对路径 / 类型(d 目录、f 文件) + s SDDL 原文(含 O: / G: / D:) + o / g 属主 / 属组 SID 字符串 + e 读不到时的错误(**必须记账**,不能当成"没有特殊权限") + Protected / Explicit / Inherited / Inheritable / Analyzed + Smart 模式判断"是否与父目录不同"用的分析结果 + + 属主/属组一律取 SID 字符串(GetOwner(SecurityIdentifier).Value): + 走 .Owner 会触发账户名解析,孤儿 SID 上会抛异常或很慢,而我们只要数值身份。 + + 读 SD 需要 READ_CONTROL;C:\ProgramData 里确实有 Get-Acl 直接报 + "Attempted to perform an unauthorized operation" 的目录,先开 SeBackupPrivilege + 能救回大部分,救不回的会带 e 字段落进 sidecar。 + #> + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$Key, + [ValidateSet('d', 'f')][string]$Kind = 'd', + [switch]$IncludeSacl, + [AllowNull()][string[]]$ParentSignatures = $null + ) + + $record = [pscustomobject]@{ + p = $Key + k = $Kind + s = $null + o = $null + g = $null + e = $null + Protected = $false + Explicit = 0 + Inherited = 0 + Inheritable = 0 + InheritedSignatures = @() + AllSignatures = @() + Analyzed = $false + } + + $acl = $null + try { + if ($IncludeSacl) { + $acl = Get-Acl -LiteralPath $Path -Audit -ErrorAction Stop + } else { + $acl = Get-Acl -LiteralPath $Path -ErrorAction Stop + } + } catch { + $record.e = $_.Exception.Message + return $record + } + + try { + # 陈旧继承 ACE 要固化成显式 ACE,否则恢复后会消失(见 Get-BaknretSecuritySddlWithStale) + $record.s = Get-BaknretSecuritySddlWithStale -Acl $acl -ParentSignatures $ParentSignatures + } catch { + $record.e = $_.Exception.Message + } + if (-not $record.s) { + if (-not $record.e) { $record.e = '读不到安全描述符' } + return $record + } + + try { $record.o = $acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value } catch { } + try { $record.g = $acl.GetGroup([System.Security.Principal.SecurityIdentifier]).Value } catch { } + + try { + $sid = [System.Security.Principal.SecurityIdentifier] + $record.Protected = [bool]$acl.AreAccessRulesProtected + + $explicitRules = @($acl.GetAccessRules($true, $false, $sid)) + $inheritedRules = @($acl.GetAccessRules($false, $true, $sid)) + $record.Explicit = $explicitRules.Count + $record.Inherited = $inheritedRules.Count + $record.InheritedSignatures = @(Get-BaknretAceSignatureList -Rules $inheritedRules) + $record.AllSignatures = @(Get-BaknretAceSignatureList -Rules @($acl.GetAccessRules($true, $true, $sid))) + + $inheritable = 0 + foreach ($rule in @($acl.GetAccessRules($true, $true, $sid))) { + $fsRule = $rule -as [System.Security.AccessControl.FileSystemAccessRule] + if ($fsRule -and ($fsRule.InheritanceFlags -ne [System.Security.AccessControl.InheritanceFlags]::None)) { + $inheritable++ + } + } + $record.Inheritable = $inheritable + $record.Analyzed = $true + } catch { + # 分析失败时一律当成"需要保留"(Analyzed=$false 会让 keeper 直接保留) + $record.Analyzed = $false + } + + return $record +} + +function Test-BaknretSecurityRecordNeeded { + <# + .SYNOPSIS + Smart 模式下判断这条记录是否必须落进 sidecar。 + + .DESCRIPTION + 判据是"恢复时不能被继承自动复现",任何一条成立就得留: + + * 读不到(e)—— 必须记账,恢复时要能报出来; + * DACL 是 protected(断开继承)—— 只靠父目录继承永远复现不出这一套; + * 有显式 ACE(Explicit > 0)—— 同上; + * NULL DACL(NO_ACCESS_CONTROL)—— 那不是"没有特殊权限",是"人人全权"; + * 属主 / 属组与父目录不同 —— CREATOR OWNER 的解析结果就取决于属主; + * 继承链路与父目录脱节 —— 条数对不上,或某条继承来的 ACE 在父目录 ACL 里 + 找不到出处(父目录改过权限、子对象还留着老 ACE);空 DACL 也会在这里露出来。 + + 分析不了(Analyzed=$false)时一律保留:多存永远比少存安全。 + #> + param( + [Parameter(Mandatory = $true)]$Record, + [string]$ParentOwner, + [string]$ParentGroup, + [int]$ParentInheritable = -1, + [string[]]$ParentSignatures = @(), + [switch]$Force + ) + + if ($Force) { return $true } + if ($Record.e) { return $true } + if (-not $Record.s) { return $true } + if (-not $Record.Analyzed) { return $true } + if ($Record.Protected) { return $true } + if ($Record.Explicit -gt 0) { return $true } + if ($Record.s -match 'NO_ACCESS_CONTROL') { return $true } + if ($Record.o -and $ParentOwner -and ($Record.o -ne $ParentOwner)) { return $true } + if ($Record.g -and $ParentGroup -and ($Record.g -ne $ParentGroup)) { return $true } + + # 继承链还接不接得上父目录:先比条数,再比每一条在父目录 ACL 里有没有出处。 + # 只比条数会漏判 —— 真机实测过:子对象留着"改权限之前"的老 ACE, + # 条数与父目录可继承条数正好相等(都 4 条),内容却完全不同。 + if ($ParentInheritable -ge 0 -and $Record.Inherited -ne $ParentInheritable) { return $true } + foreach ($signature in @($Record.InheritedSignatures)) { + if ($ParentSignatures -notcontains $signature) { return $true } + } + + return $false +} + +function Get-BaknretSecurityRecords { + <# + .SYNOPSIS + 采集一组归档项的安全描述符,键是**归档内相对路径**(`\…`)。 + + .DESCRIPTION + 键用归档内路径而不是宿主机路径:目标机器上 `%UserProfile%` 会变、名录的前缀补全 + (legendary -> legendary_2.0.4)也会变,只有归档内相对路径在两端是同一个坐标系。 + + 遍历用显式栈,并且**跳过 reparse point**:PS 5.1 的 Get-ChildItem -Recurse 会 + 跟着 junction 无限转;scoop 的 `apps\\current` 就是 junction,正撞在这个坑上。 + + $ScopeMap 由 Split-BaknretPatternScope 产出(项下标 -> 该相对根的模式数组), + 所以这里的排除判定与真正交给 7z 的 -x! / -xr! 是同一套规则。 + + Mode: + * Roots —— 只存每个归档项的根(最省,适合"权限只在根上"的场景) + * Smart —— 根 + 所有"继承复现不出来"的对象(默认;几万文件的树 sidecar 也只有几百 KB) + * Full —— 每一个对象都存(最保险,sidecar 会大到几 MB) + + 返回 [pscustomobject]@{ Records; Scanned; Kept; Errors }。 + #> + param( + [array]$Items = @(), + [hashtable]$ScopeMap = @{}, + [ValidateSet('Roots', 'Smart', 'Full')][string]$Mode = 'Smart', + [switch]$IncludeSacl + ) + + $records = New-Object System.Collections.Generic.List[object] + $scanned = 0 + $errorCount = 0 + + # 读安全描述符要 READ_CONTROL:系统目录里读不到是常态(C:\ProgramData 下就有 + # Get-Acl 直接报 "Attempted to perform an unauthorized operation" 的目录)。 + # SeBackupPrivilege 启用后系统会把读权限授予任何文件;连它都没有的账户, + # 读不到的对象会带 e 字段落进 sidecar,而不是被静默当成"没有特殊权限"。 + $privileges = @('SeBackupPrivilege') + if ($IncludeSacl) { $privileges += 'SeSecurityPrivilege' } + Enable-BaknretPrivilege -Name $privileges | Out-Null + + for ($index = 0; $index -lt $Items.Count; $index++) { + $item = $Items[$index] + if (-not $item) { continue } + + $archiveRoot = [string]$item.ArchivePath + $real = [string]$item.RealPath + if ([string]::IsNullOrWhiteSpace($archiveRoot) -or [string]::IsNullOrWhiteSpace($real)) { continue } + if (-not (Test-Path -LiteralPath $real)) { continue } + + $patterns = @() + if ($ScopeMap -and $ScopeMap.ContainsKey($index)) { $patterns = @($ScopeMap[$index]) } + + $rootItem = Get-Item -LiteralPath $real -Force -ErrorAction SilentlyContinue + if (-not $rootItem) { continue } + + if (-not $rootItem.PSIsContainer) { + $record = Get-BaknretSecurityRecord -Path $real -Key $archiveRoot -Kind 'f' -IncludeSacl:$IncludeSacl + $scanned++ + if ($record.e) { $errorCount++ } + $records.Add($record) + continue + } + + $rootRecord = Get-BaknretSecurityRecord -Path $real -Key $archiveRoot -Kind 'd' -IncludeSacl:$IncludeSacl + $scanned++ + if ($rootRecord.e) { $errorCount++ } + $records.Add($rootRecord) + + if ($Mode -eq 'Roots') { continue } + + $pending = New-Object System.Collections.Generic.Stack[object] + $pending.Push(@{ + Dir = $rootItem + Rel = '' + Owner = $rootRecord.o + Group = $rootRecord.g + Inheritable = $rootRecord.Inheritable + Signatures = $rootRecord.AllSignatures + }) + + while ($pending.Count -gt 0) { + $frame = $pending.Pop() + foreach ($child in @(Get-ChildItem -LiteralPath $frame.Dir.FullName -Force -ErrorAction SilentlyContinue)) { + if ($child.Attributes -band [IO.FileAttributes]::ReparsePoint) { continue } + + $childRel = if ($frame.Rel) { $frame.Rel + '\' + $child.Name } else { $child.Name } + if (Test-BaknretPathExcluded -RelativePath $childRel -Patterns $patterns) { continue } + + $kind = if ($child.PSIsContainer) { 'd' } else { 'f' } + $record = Get-BaknretSecurityRecord -Path $child.FullName -Key ($archiveRoot + '\' + $childRel) ` + -Kind $kind -IncludeSacl:$IncludeSacl -ParentSignatures $frame.Signatures + $scanned++ + if ($record.e) { $errorCount++ } + + if ($Mode -eq 'Full') { + $records.Add($record) + } elseif (Test-BaknretSecurityRecordNeeded -Record $record ` + -ParentOwner $frame.Owner -ParentGroup $frame.Group ` + -ParentInheritable $frame.Inheritable -ParentSignatures $frame.Signatures) { + $records.Add($record) + } + + if ($child.PSIsContainer) { + $pending.Push(@{ + Dir = $child + Rel = $childRel + Owner = $record.o + Group = $record.g + Inheritable = $record.Inheritable + Signatures = $record.AllSignatures + }) + } + } + } + } + + return [pscustomobject]@{ + Records = @($records.ToArray()) + Scanned = $scanned + Kept = $records.Count + Errors = $errorCount + } +} + +function Write-BaknretAtomicText { + <# + .SYNOPSIS + 原子写一个文本文件(先写 .tmp,再替换)。 + #> + param( + [Parameter(Mandatory = $true)][string]$Path, + [AllowEmptyString()][string]$Text = '' + ) + + $directory = Split-Path -Parent $Path + if ($directory -and -not (Test-Path -LiteralPath $directory)) { + New-Item -ItemType Directory -Path $directory -Force | Out-Null + } + + $temp = "$Path.tmp" + [System.IO.File]::WriteAllText($temp, $Text, $script:LogEncoding) + Move-BaknretArchiveIntoPlace -TempPath $temp -DestinationPath $Path + return $Path +} + +function Save-BaknretSecuritySidecar { + <# + .SYNOPSIS + 把采集结果写成 sidecar(`<归档名>.acl.json`)。 + + .DESCRIPTION + 放在归档旁边而不是塞进归档里:7z 装不下它,塞进去又会污染 Slot 布局 + (归档内顶层名是要与 manifest 的 roots/layouts 对账的)。 + 代价是它得跟归档一起搬,README 里已写明。 + + 用 JSON 数组而不是"路径 -> SDDL"的对象:ConvertFrom-Json 出来的是 + PSCustomObject,按深度排序还得自己摊平;数组直接有序。 + #> + param( + [Parameter(Mandatory = $true)][string]$Path, + [array]$Records = @(), + [string]$Mode = 'Smart', + [bool]$IncludeSacl = $false, + [int]$Errors = 0, + [int]$Scanned = 0 + ) + + $projected = @() + foreach ($record in @($Records)) { + if (-not $record) { continue } + $entry = [ordered]@{ + p = [string]$record.p + k = [string]$record.k + } + if ($record.s) { $entry.s = [string]$record.s } + if ($record.o) { $entry.o = [string]$record.o } + if ($record.g) { $entry.g = [string]$record.g } + if ($record.e) { $entry.e = [string]$record.e } + $projected += $entry + } + + $payload = [ordered]@{ + schemaVersion = 1 + tool = 'BakNRet' + capturedAt = (Get-Date).ToString('o') + mode = $Mode + includeSacl = [bool]$IncludeSacl + objectCount = $projected.Count + scannedCount = $Scanned + errorCount = $Errors + records = @($projected) + } + + $json = $payload | ConvertTo-Json -Depth 5 + return (Write-BaknretAtomicText -Path $Path -Text $json) +} + +function Read-BaknretSecuritySidecar { + <# + .SYNOPSIS + 读 sidecar;不存在或损坏时返回 $null(调用方据此打"该归档不含安全描述符"的告警)。 + #> + param([Parameter(Mandatory = $true)][string]$Path) + + if (-not (Test-Path -LiteralPath $Path)) { return $null } + + try { + $raw = Get-Content -LiteralPath $Path -Raw -Encoding UTF8 -ErrorAction Stop + if ([string]::IsNullOrWhiteSpace($raw)) { return $null } + + $parsed = $raw | ConvertFrom-Json -ErrorAction Stop + $records = @() + if (($parsed.PSObject.Properties.Name -contains 'records') -and $parsed.records) { + $records = @($parsed.records) + } + + return [pscustomobject]@{ + CapturedAt = $parsed.capturedAt + Mode = $parsed.mode + IncludeSacl = [bool]$parsed.includeSacl + ObjectCount = $parsed.objectCount + ErrorCount = $parsed.errorCount + Records = @($records) + } + } catch { + Write-Log "安全描述符文件解析失败:$Path —— $($_.Exception.Message)" -Level WARN + return $null + } +} + +function Convert-BaknretSidMap { + <# + .SYNOPSIS + 按 SID 映射表改写 SDDL 里的 SID(跨机恢复用)。 + + .DESCRIPTION + 只在**完整的 SID 记号**上替换:`S-1-5-21-1-2-3-1001` 是 + `S-1-5-21-1-2-3-10012` 的前缀,直接 -replace 会改坏后者, + 所以前后加边界断言(前面不能是数字或 -,后面不能是数字)。 + #> + param( + [AllowEmptyString()][string]$Sddl, + [hashtable]$SidMap = @{} + ) + + $text = [string]$Sddl + if (-not $text -or -not $SidMap -or $SidMap.Count -eq 0) { return $text } + + foreach ($old in @($SidMap.Keys)) { + $newSid = [string]$SidMap[$old] + $oldSid = [string]$old + if ([string]::IsNullOrWhiteSpace($oldSid) -or [string]::IsNullOrWhiteSpace($newSid)) { continue } + $pattern = '(? + param( + [Parameter(Mandatory = $true)]$Item, + [Parameter(Mandatory = $true)][string]$Sddl, + [ValidateSet('All', 'OwnerAndAccess', 'AccessOnly')][string]$Scope = 'All' + ) + + $sections = [System.Security.AccessControl.AccessControlSections]::Access + if ($Scope -ne 'AccessOnly') { + if ($Sddl -match 'O:') { $sections = $sections -bor [System.Security.AccessControl.AccessControlSections]::Owner } + if ($Scope -eq 'All' -and $Sddl -match 'G:') { $sections = $sections -bor [System.Security.AccessControl.AccessControlSections]::Group } + } + + if ($Item.PSIsContainer) { + $sd = New-Object System.Security.AccessControl.DirectorySecurity + } else { + $sd = New-Object System.Security.AccessControl.FileSecurity + } + + $sd.SetSecurityDescriptorSddlForm($Sddl, $sections) + + if (-not $sd.AreAccessRulesProtected) { + $sd.SetAccessRuleProtection($false, $false) + } + + if ($PSVersionTable.PSEdition -eq 'Core') { + [System.IO.FileSystemAclExtensions]::SetAccessControl($Item, $sd) + } else { + $Item.SetAccessControl($sd) + } +} + +function Restore-BaknretSecurity { + <# + .SYNOPSIS + 把 sidecar 里属于某个归档项的那部分安全描述符,回放到真实目标路径上。 + + .DESCRIPTION + 只处理 `p` 等于/位于 $ArchiveRoot 之下的记录(一项一棵子树,和其它恢复语义一致)。 + + 顺序很重要:**按深度自顶向下**。父目录先写,子对象的继承才会收敛到原样; + 反过来做会被父目录的继承覆盖掉。 + + 原文件在归档里没解出来(被排除、或本来就缺失)时跳过,并计入 Skipped。 + + 返回 [pscustomobject]@{ Total; Applied; OwnerFailed; Skipped; Failed; Failures }。 + #> + param( + [Parameter(Mandatory = $true)]$Sidecar, + [Parameter(Mandatory = $true)][string]$ArchiveRoot, + [Parameter(Mandatory = $true)][string]$TargetPath, + [hashtable]$SidMap = @{}, + [switch]$WhatIf + ) + + $result = [pscustomobject]@{ + Total = 0 + Applied = 0 + OwnerFailed = 0 + Skipped = 0 + Failed = 0 + Failures = @() + } + + # 写属主需要 SeRestorePrivilege,而且必须**显式启用**:管理员的过滤令牌里它默认是 + # disabled,Set-Acl / SetAccessControl 都不会替你打开。没有它,属主会写失败并静默 + # 退化成"只恢复 DACL" —— 那恰恰丢掉了这个功能存在的理由(CREATOR OWNER 判给谁)。 + Enable-BaknretPrivilege -Name @('SeRestorePrivilege', 'SeBackupPrivilege') | Out-Null + + if (-not $Sidecar -or -not $Sidecar.Records) { return $result } + + $root = ([string]$ArchiveRoot).Trim([char[]]@('\', '/')) + if ([string]::IsNullOrWhiteSpace($root)) { return $result } + $prefix = "$root\" + + $selected = @() + foreach ($record in @($Sidecar.Records)) { + if (-not $record) { continue } + $key = [string]$record.p + if ([string]::IsNullOrWhiteSpace($key)) { continue } + + $relative = $null + if ($key -ieq $root) { + $relative = '' + } elseif ($key.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) { + $relative = $key.Substring($prefix.Length) + } else { + continue + } + $selected += [pscustomobject]@{ Relative = $relative; Record = $record } + } + + if ($selected.Count -eq 0) { return $result } + + $ordered = @($selected | Sort-Object -Property ` + @{ Expression = { @(($_.Relative) -split '\\').Count } }, ` + @{ Expression = { $_.Relative } }) + + foreach ($entry in $ordered) { + $target = if ($entry.Relative) { Join-Path $TargetPath $entry.Relative } else { $TargetPath } + $result.Total++ + + if ($entry.Record.e -or -not $entry.Record.s) { $result.Skipped++; continue } + if (-not (Test-Path -LiteralPath $target)) { $result.Skipped++; continue } + + $item = Get-Item -LiteralPath $target -Force -ErrorAction SilentlyContinue + if (-not $item) { $result.Skipped++; continue } + if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { $result.Skipped++; continue } + + if ($WhatIf) { continue } + + $sddl = Convert-BaknretSidMap -Sddl ([string]$entry.Record.s) -SidMap $SidMap + + try { + Set-BaknretObjectSecurity -Item $item -Sddl $sddl -Scope All + $result.Applied++ + } catch { + $fullError = $_ + try { + Set-BaknretObjectSecurity -Item $item -Sddl $sddl -Scope OwnerAndAccess + $result.OwnerFailed++ + $result.Failures += ("{0}:属组未恢复,属主与 DACL 已恢复({1})" -f $target, $fullError.Exception.Message) + } catch { + try { + Set-BaknretObjectSecurity -Item $item -Sddl $sddl -Scope AccessOnly + $result.OwnerFailed++ + $result.Failures += ("{0}:属主/属组未恢复({1}),已只恢复 DACL" -f $target, $_.Exception.Message) + } catch { + $result.Failed++ + $result.Failures += ("{0}:{1}" -f $target, $_.Exception.Message) + } + } + } + } + + return $result +} + # ============================================================================ # 配置 # ============================================================================ @@ -1437,6 +3033,20 @@ function Get-BaknretConfig { ToolOutput = 'live' # live | quiet Snapshot = @{ Enabled = $false; KeepCount = 3; KeepDays = 30 } Encryption = @{ Enabled = $false; PasswordFile = ''; EncryptHeaders = $true } + # 安全描述符(属主 / ACL)的采集与回放。 + # Mode Off | Roots | Smart | Full(语义见 Get-BaknretSecurityRecords) + # **默认 Full**:这个功能存在的意义就是不丢权限,正确性优先于体积; + # Smart 是体积优化(靠继承复现的对象不落盘),已在真机上见过 + # 它需要处理的"陈旧继承 ACE",判据偏保守,但终究是启发式。 + # IncludeSacl 是否连审计规则(SACL)一起存取,需要 SeSecurityPrivilege + # SidMap 跨机恢复时的 SID 映射:@('S-1-5-21-旧-1001' = 'S-1-5-21-新-1001') + # FailOnError 安全描述符写盘失败时,是否把这条备份算作失败(默认只告警) + Security = @{ + Mode = 'Full' + IncludeSacl = $false + SidMap = @{} + FailOnError = $false + } DefaultExcludes = @() } @@ -1445,14 +3055,14 @@ function Get-BaknretConfig { } try { - $loaded = Import-PowerShellDataFile -LiteralPath $Path -ErrorAction Stop + $loaded = Import-BaknretDataFile -Path $Path } catch { Write-Log "配置文件读取失败(改用默认值):$Path —— $_" -Level WARN return $defaults } foreach ($key in $loaded.Keys) { - if ($key -in @('Snapshot', 'Encryption') -and $loaded[$key] -is [hashtable]) { + if ($key -in @('Snapshot', 'Encryption', 'Security') -and $loaded[$key] -is [hashtable]) { $merged = @{} foreach ($subKey in $defaults[$key].Keys) { $merged[$subKey] = $defaults[$key][$subKey] } foreach ($subKey in $loaded[$key].Keys) { $merged[$subKey] = $loaded[$key][$subKey] } @@ -1524,11 +3134,19 @@ Export-ModuleMember -Function @( 'Set-BaknretDebug', 'Start-BaknretLog', 'Stop-BaknretLog', 'Get-BaknretLogPath', 'Write-Log', 'Test-Administrator', 'Get-BaknretFreeSpaceGB', 'ConvertTo-NativeArgumentString', 'Invoke-ExternalCommand', 'Resolve-CompressionTool', 'Get-Optimized7zArgument', - 'ConvertFrom-BackupListLine', 'Get-ArchiveExcludeArgument', 'Test-LiteralPath', + 'Split-BaknretToken', 'Remove-BaknretQuote', 'Test-BaknretMarker', 'ConvertFrom-BaknretPatternList', + 'ConvertFrom-BackupListLine', 'Test-LiteralPath', + 'Get-BaknretRegexExclude', 'Get-BaknretExcludeArgument', 'Split-BaknretPatternScope', 'Merge-BaknretExcludeArgument', 'Resolve-CatalogPath', 'Get-SoftwareCatalog', 'Find-ChildDirectoryByName', 'Format-CatalogName', - 'Get-ArchiveTopLevelNames', + 'Expand-CatalogPathText', 'Get-ArchiveTopLevelNames', + 'Get-BaknretArchiveTopName', 'New-BaknretArchiveItem', 'New-BaknretJunction', 'Remove-BaknretJunction', + 'New-BaknretArchiveStaging', 'Remove-BaknretArchiveStaging', 'Get-ItemArchiveName', 'Resolve-BackupEntry', 'Write-BackupEntryPlan', 'Get-BackupBaseName', 'Convert-BackupFileNameToPath', 'Get-FolderSummary', 'Read-BaknretManifest', 'Write-BaknretManifest', 'Sync-BaknretManifestArchive', 'Move-BaknretArchiveIntoPlace', + 'Enable-BaknretPrivilege', 'ConvertTo-BaknretWildcardPattern', 'Test-BaknretPathExcluded', + 'Get-BaknretAceSignatureList', 'Get-BaknretSecuritySddlWithStale', 'Get-BaknretSecurityRecord', 'Test-BaknretSecurityRecordNeeded', 'Get-BaknretSecurityRecords', + 'Write-BaknretAtomicText', 'Save-BaknretSecuritySidecar', 'Read-BaknretSecuritySidecar', + 'Convert-BaknretSidMap', 'Set-BaknretObjectSecurity', 'Restore-BaknretSecurity', 'Get-BaknretConfig', 'Get-BaknretPassword' ) diff --git a/README.md b/README.md index bf0d243..36633fc 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,17 @@ 把 `BackupList.txt` 里列出的软件 / 目录用 **7-Zip** 打包进 `Backups/`,并且能用 `Restore.ps1` 原样恢复的 Windows 备份工具。 -- 清单里**直接写软件名**即可(如 `FooClolor`),目录映射维护在 `SoftwareCatalog.psd1` 里。 -- 归档名就是软件名(`FooClolor.7z`),不再是 `FooClolor_from_C_+Programs.7z`。 +- 清单里**直接写软件名**即可(如 `Edge`),目录映射维护在 `SoftwareCatalog.psd1` 里。 +- 一个软件一个归档:**归档名 = 软件名**(`Edge.7z`),归档内按名录里的 **Slot 分层** + (`\<该路径的内容>`),所以同一个软件里两个都叫 `persist` 的目录不会再撞在一起。 +- 清单行首 `+` = 仅备份、`-` = 仅恢复;两条路径共用同一份清单。 +- 排除 / 追加 / 加密都能写在 `SoftwareCatalog.psd1` 的 Slot 上,清单行里可以按条目覆盖。 - 只依赖 PowerShell(5.1 或 7.x)与 7-Zip,**运行备份/恢复不需要任何模块**(只有跑 Pester 测试才需要 Pester 5)。 - 每个归档写完后做 `7z t` 内容校验,**先写临时文件、校验通过再原子替换**。 - 每次运行产出可核对的 `Backups/manifest.json` 与 `logs/*.log`。 +- 归档之外还保存 **NTFS 安全描述符**(属主 / 属组 / DACL):每个归档旁边一份 + `<归档名>.acl.json`,恢复时按它回放。这是"恢复之后原程序还能不能读写"的关键 + (`C:\ProgramData` 下那些靠 `CREATOR OWNER` 授权的目录,见「安全描述符」一节)。 - 退出码可靠:有失败就返回 `1`,计划任务能正确判断成败。 - 备份结束做**孤儿归档审计**:磁盘上有、但没有任何清单条目指向的归档会被点名(它们恢复不到,别误删)。 - 恢复支持 `-WhatIf` / `-DryRun` / `-VerifyOnly` / `-Only` / `-Skip`;其中三种"只看不写"的模式(`-WhatIf` / `-DryRun` / `-VerifyOnly`)**一个字节都不写**。 @@ -30,7 +36,7 @@ .\Backup.ps1 -Force -AcceptWarnings # 4. 只备份 / 只恢复某几项(通配符匹配清单条目或归档名) -.\Backup.ps1 -Only 'FooClolor','.ssh' +.\Backup.ps1 -Only 'Edge','OpenSSH' .\Restore.ps1 -Only 'Edge' -Force # 5. 恢复前先看计划(恢复会覆盖真实目录,务必先看一眼) @@ -44,60 +50,78 @@ | 路径 | 作用 | | --- | --- | -| `SoftwareCatalog.psd1` | **软件名 → 目录**的映射,清单里写软件名的依据 | -| `BackupList.txt` | 备份 / 恢复共用的清单,唯一的"要备份什么"来源 | +| `SoftwareCatalog.psd1` | **软件名 → Slot 组**的映射:每个 Slot 是一个目录/文件,以及它的排除、追加、加密、说明 | +| `BackupList.txt` | 备份 / 恢复共用的清单,唯一的"要处理什么"来源 | | `BackupConfig.psd1` | 目录、空间阈值、校验、加密等配置 | | `Backup.ps1` / `Restore.ps1` | 备份 / 恢复入口 | -| `Common.psm1` | 公共模块(日志、外部命令、解析、名录、manifest) | +| `Common.psm1` | 公共模块(日志、外部命令、清单与名录解析、归档布局、暂存、manifest) | | `Backups/` | 归档与 `manifest.json`(已 gitignore) | | `logs/` | 每次运行的日志(已 gitignore) | | `tests/` | 测试:Pester 套件(`*.Tests.ps1`)、零依赖套件、端到端验收、真实归档恢复演练 | | `tools/Register-BackupTask.ps1` | 注册 / 移除计划任务 | -| `tools/Rename-Archives.ps1` | 把按路径命名的旧归档重命名成软件名(默认试运行) | +| `tools/Rename-Archives.ps1` | 把归档名对齐到当前清单规则(默认试运行) | | `tools/Install-TestDependencies.ps1` | 把 Pester 5 装到仓库内的 `.tools/`(不动机器上的全局模块) | -## SoftwareCatalog.psd1 —— 软件名 → 目录 +## SoftwareCatalog.psd1 —— 软件名 → Slot 组 ```powershell @{ - # 1) 一个目录,直接写字符串 - FooClolor = 'C:\Programs\FooClolor' - - # 2) 一个目录 + 介绍(运行时会打印出来,推荐) - Kazumi = @{ - Path = '%AppData%\com.example\Kazumi' - Description = 'Kazumi 的观看记录与设置' + Edge = @{ + # Slot = 归档内的一层目录:内容进 DefaultData\,恢复时整棵回到这个 Path + DefaultData = @{ + Path = '%LocalAppData%\Microsoft\Edge\User Data' + Exclude = '!*Cache,!Crashpad,Default\Extensions,Default\Service Worker' + Description = 'Edge 用户数据:书签/密码/偏好/历史,以及站点数据' + } } - # 3) 一个软件 = 多个目录:写成对象数组,每个目录各自带说明 - scoop = @( - @{ - Path = '%UserProfile%\scoop\persist' - Description = 'scoop 各应用的持久化数据(重装应用就会丢)' - } - @{ + Scoop = @{ + # 一个软件可以有多个 Slot;两个都叫 persist 的目录因此不再冲突 + DefaultConfig = @{ Path = '%UserProfile%\.config\scoop' + Encrypt = $true Description = 'scoop 自身的配置' } - ) + UserPersist = @{ + Path = '$(if ($env:SCOOP) { $env:SCOOP } else { Join-Path $env:USERPROFILE "scoop" })\persist' + Encrypt = $true + Description = 'scoop 各应用的持久化数据' + } + } - # 含 - 或 . 的键必须加引号 - '.ssh' = @{ Path = '%UserProfile%\.ssh'; Description = 'SSH 私钥(不可再生)' } + WindowsTerminal = @{ + # Path 指向文件时,归档里就是一个名为 DefaultData 的文件(没有扩展名) + DefaultData = @{ + Path = '%LocalAppData%\Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json' + Encrypt = $true + Description = 'Windows Terminal 的设置文件' + } + } } ``` +字段: + +| 字段 | 说明 | +| --- | --- | +| Slot 名 | **归档内的一层目录**。内容进 `\`;Path 是文件时就是名为 `` 的文件。同一软件里不能重名 | +| `Path` | 宿主机上的绝对路径。支持 `%变量%` 与 `$( ... )` 子表达式 | +| `Exclude` | 排除模式,相对本 Slot 的根,逗号分隔。`!` 打头 = 任意层级(7z 通配符),`!re:<正则>` = 正则 | +| `Include` | 追加项,`<归档内相对路径>:<宿主机绝对路径>`,逗号分隔 | +| `Encrypt` | 该归档是否加密,默认 `$false`。同一软件里若各 Slot 不一致,整个归档按**加密**处理 | +| `Description` | 这个 Slot 是干什么的;运行时逐条打印 | + 要点: -- **含 `-` 或 `.` 的键一定要加引号**,否则 PowerShell 会把 `a-b` 解析成减法表达式并报 - `Missing '=' operator after key in hash literal`。这是最容易踩的一个坑。 -- 数组元素也接受**纯字符串**(`scoop = @('D:\a', 'D:\b')`),以及旧的 - `@{ Dirs = @(...) }` / `@{ Variants = @(...) }` 写法 —— 三种都能用。 +- **`Path` 支持 `$( ... )`**:`$(if ($env:SCOOP) { $env:SCOOP } else { Join-Path $env:USERPROFILE "scoop" })` + 会按 PowerShell 求值(求值结果会缓存,不会每个条目重复起进程)。 + 这类写法用了 `+` 拼接字符串时,`Import-PowerShellDataFile` 会拒绝,脚本会自动改用 + PowerShell 求值——名录与配置是仓库里的本地文件,和脚本同级,信任级别相同。 - **目录当前不存在也不会被丢掉**:备份时跳过并记 `missing-source`,但恢复时仍然知道 "这块内容原本该回到哪个位置",这正是恢复要用的。 -- **前缀补全**:写 `D:\Programs\legendary`,实际目录是 `legendary_2.0.4` 时会自动匹配。 - 只认 `<名>_*` 与 `<名>-*`,不会把 `Legendary` 误配成 `LegendarySomething`。 -- **一个软件里不能有两个同名目录**(例如两个 `persist`):归档内的顶层名就是目录名, - 那样会在包里混成一棵树。脚本会明确报错(退出码 1)让你拆成两个条目。 +- **前缀补全**:写 `D:\Programs\legendary`,实际目录是 `legendary_2.0.4` 时会自动匹配 + (只认 `<名>_*` 与 `<名>-*`)。一个 Slot 只能对应一个目录,补全出多个会明确报错并让你拆 Slot。 +- **一个软件里不能有两个同名 Slot**,否则归档内会混成一棵树;脚本会明确报错。 **分文件维护**:用 `Includes` 引入其它名录文件(路径相对本文件): @@ -111,88 +135,74 @@ ## BackupList.txt 语法 ```text -<软件名 或 手写目录> [ :+ <再追加一个目录/软件名> ... ] [ :- <排除模式>[,<排除模式>...] ] [ @<标记> ] +[+|-] <软件名 或 绝对路径> [ :: <绝对路径> ] [ :- <模式>[,<模式>...] ] [ :+ <追加项>[,<追加项>...] ] + [ :encrypt | :!encrypt ] [ @ ='<值>' ] [ # 说明 ] ``` -### 两种写法(混用没问题) +修饰符必须是**独立的、前后带空白的记号**,所以路径里出现的 `:-`、`C:\a#b` 之类不会被误切。 + +### 目标(二选一) | 写法 | 说明 | | --- | --- | -| `FooClolor` | **软件名**:去 `SoftwareCatalog.psd1` 查目录,**归档名 = 软件名**。一个软件可以挂多个目录 | -| `%UserProfile%\Documents\PowerShell` | **手写目录**:含 `\` `/` 或 `%` 就按路径处理,归档名沿用 `<末级名>_from_<上级路径>` | -| `FooClolor @pathname` | 软件名 + 强制用路径命名。适合想换到名录体系但暂时不想改归档名的条目 | +| `Edge` | **软件名**:去 `SoftwareCatalog.psd1` 查 Slot 组,**归档名 = 软件名** | +| `C:\Programs\MiFlash` | **手写路径**:含 `\` `/` 或 `%` 就按路径处理,归档名 = `<末级名>_from_<上级路径用 + 连接>` | +| `Edge @pathname` | 软件名 + 强制用路径命名(想换到名录体系但暂时不想改归档名时用) | + +### 行首方向标记 | 标记 | 作用 | | --- | --- | -| `encrypt` | 用 7z 加密该归档,见下文「加密」 | -| `pathname` | 用路径命名算法而不是软件名 | -| `root=<名>` | **尚未实现**:归档内的根目录始终是源目录名。用了会打印告警,不会静默失效 | +| `+` | **仅备份,不恢复**(`Restore.ps1` 会跳过它;归档名照旧算"有主"的,不会被报成孤儿) | +| `-` | **仅恢复,不备份**(`Backup.ps1` 会跳过它;适合放在别处、必要时才还原的目录) | +| 无 | 既能备份也能恢复(默认) | -### 两种写法都支持追加(`:+`)与排除(`:-`) +### 修饰符 -| 记号 | 作用 | -| --- | --- | -| `:+` | **追加**一个目录;写成软件名时会按名录展开成它的全部目录。可以写多个、位置随意,追加进来的目录与主目录一起打进同一个归档 | -| `:-` | **排除**模式(`::` 是历史别名,等价)。`,` 与 `;` 都当分隔符 | +| 修饰符 | 等价写法 | 作用 | +| --- | --- | --- | +| `:: <绝对路径>` | `@ Path='<绝对路径>'` | 覆盖 Path(软件名条目只有一个 Slot 时可用) | +| `:- <模式>[,...]` | `@ Exclude='<模式>'` | 排除模式(`,` `;` 都当分隔符),**覆盖**名录里各 Slot 的 Exclude | +| `:+ <追加项>[,...]` | `@ Include='<追加项>'` | 追加项,语法 `<归档内相对路径>:<宿主机绝对路径>`,**覆盖**名录里的 Include | +| `:encrypt` | `@ Encrypt='$true'` | 该条目加密 | +| `:!encrypt` | `@ Encrypt='$false'` | 该条目不加密 | +| `@ ='<值>'` | — | 覆盖名录里的默认字段(目前支持 Path / Exclude / Include / Encrypt) | + +兼容的历史写法仍然认:`@encrypt` / `@!encrypt` / `@pathname` / `@root=<名>`(`root=` 已废弃,只会打印告警)。 ```text -# 软件名 + 追加 + 排除 -scoop :- !*Cache :+ D:\scoop-extra +# 软件名:用名录里的 Slot 与排除;再把额外目录放进包内 Modules\ 位置 +Scoop :- GlobalPersist\steam\steamapps -# 手写目录 + 追加 + 排除 -C:\Programs\MiFlash :+ MiFlash_Unlock :- MiFlash\logs\ +# 手写目录 + 排除 +C:\Programs\MiFlash :- MiFlash\logs\ + +# 追加映射:把宿主机的 D:\extra\ps-modules 放到包内 Modules\ 下 +PowerShell :+ Modules:D:\extra\ps-modules + +# 覆盖加密(名录里默认加密时特别有用) +PowerShell @ Encrypt='$false' +WindowsPowerShell :!encrypt ``` -> 手写目录的 `:+` 以前会被整段丢掉(只有软件名写法才生效),现在已经修好。 +### 模式(排除 / 追加)怎么写 -### 行尾可以写"为什么" +模式匹配的是**归档内的相对路径**,而且**相对本 Slot 的根**(也就是 `\` 里面那一层): -行尾的 ` # 说明` 会被解析出来,运行时和目录介绍一起打印: +| 形态 | 展开成 | 说明 | +| --- | --- | --- | +| `<相对路径>` | `-x!\<相对路径>` | 锚定在归档根下这一份 | +| `!<通配>` | `-xr!<通配>` | **任意层级**按组件名匹配,`*` `?` 是 7z 通配符(不是正则) | +| `!re:<正则>` | 若干 `-x!<完整路径>` | **正则**:脚本自己遍历源目录把命中的路径展开成精确排除项 | +| `GlobalPersist\steam` | `-x!GlobalPersist\steam` | 第一段是 Slot 名时,只作用在那一个 Slot 上 | -```text -Edge :- !*Cache,!Crashpad # 缓存与崩溃转储都可再生,不进归档 -``` - -`#` 必须前面有空白才算注释,所以路径里的 `C:\a#b` 不受影响。 - -### 运行时会把每个条目的目录逐条介绍出来 - -目录介绍来自 `SoftwareCatalog.psd1`,追加/排除的**来源**来自清单: - -```text -[INFO] 条目:scoop -[INFO] 归档:scoop -[INFO] 说明:scoop 各应用的持久化数据 + scoop 自身配置 -[INFO] 目录 1/2:C:\Users\Shuery\scoop\persist -[INFO] 来源:软件名录;存在,会打包 -[INFO] 介绍:scoop 里各应用的持久化数据(重装应用就会丢,必须备份) -[INFO] 目录 2/2:C:\Users\Shuery\.config\scoop -[INFO] 来源:软件名录;存在,会打包 -[INFO] 介绍:scoop 自身的配置(源、代理、已安装清单) -[INFO] 排除 2 条(来自 BackupConfig.psd1 的 DefaultExcludes):!Thumbs.db、!desktop.ini -``` - -`Restore.ps1` 也会打印"哪棵子树还原到哪个目录、会新建还是覆盖"。 - -### 几个必须知道的约束 - -- **同一条目里不能有两个同名目录。** 归档内的顶层名就是目录自己的名字,两个 `persist` - 在包里会混成一棵树。脚本会在打包前明确报错(退出码 1)并让你拆成两个条目,不会静默混淆。 -- **多目录条目恢复时只解出各自那棵子树**,不会再出现"把兄弟目录也复制到别的父目录下"。 -- **归档名重复会直接报错。** 归档名就是软件名,所以同一个软件写两遍会让两个条目互相覆盖。 - -排除模式本身的坑(工具会处理,写的时候知道就行): - -- **模式里不要写引号。** `-x!"路径"` 会让引号成为模式的一部分,结果是**永不匹配**。 -- **模式里的空格会被自动转成 `?`。** 7z 的排除模式不支持空格:`Default\Code Cache` 匹配不到任何东西,`Default\Code?Cache` 才可以。 -- **以第一个 `::` 为界切分。** `:` 在 Windows 路径里只可能是盘符,`::` 不会出现在真实路径里,所以整行被一对引号包住的历史写法也能正确解析。 -- **归档名重复会直接报错。** 归档名就是软件名,所以同一个软件写两遍会让两个条目互相覆盖 —— 脚本拒绝执行并提示。 -- **不带 `!` 的普通模式是"相对归档根目录"锚定的**(展开成 `-x!<归档内完整路径>`),所以只排除根目录下那一份。 - Edge 的 `OneAuth\WebView2\EBWebView\` 里还藏着一整套自己的 `Crashpad` / `BrowserMetrics` / - `ProvenanceData` / `optimization_guide`,根锚定模式碰不到它们 —— 这类可再生的东西要用 - `!<组件名>`(展开成 `-xr!`)才会在任意层级命中。 -- **`!` 是按"路径组件"精确匹配,不是子串。** `!Crashpad` 不会误伤 `CrashpadMetrics.pma` - 或 `ProvenanceDataTensors`,也不会漏掉嵌套的 `...\EBWebView\Crashpad\`。 +- `!*Cache` 一次覆盖 `Cache` / `Code Cache` / `GPUCache` / `DaemonCache` 等一批以 Cache 结尾的组件名。 +- 模式里**不要自己写引号**;模式里的空格会被自动转成 `?`(7z 的 `-x!` 不接受带空格的模式)。 +- 想把 `.log` 之类按正则排除就写 `!re:.*\.log$`;命中的目录会整棵剪掉,命中数超过 300 条会明确报错 + (命令行长度有限),这时应该改用更粗的通配模式。 +- 不带 `!` 的模式是**锚定**的:Edge 的 `OneAuth\WebView2\EBWebView\` 里还有一整套自己的 + `Crashpad` / `BrowserMetrics` / `ProvenanceData` / `optimization_guide`,锚定模式碰不到它们, + 这些可再生的东西一律用 `!<组件名>` 才会在任意层级命中。 实测效果(本机真实 Edge 配置,源 4619.9 MB): @@ -204,36 +214,178 @@ Edge :- !*Cache,!Crashpad # 缓存与崩溃转储都可再生,不进归档 书签、密码(`Login Data`)、`Cookies`、偏好、历史、`IndexedDB`、`Local Storage` 全部保留; 缓存、组件缓存、Service Worker、扩展本体、遥测与优化数据全部排除。 -## 归档命名与迁移 +### 行尾可以写"为什么" + +行尾的 ` # 说明` 会被解析出来,运行时和 Slot 介绍一起打印: + +```text +Edge :- !*Cache,!Crashpad # 缓存与崩溃转储都可再生,不进归档 +``` + +`#` 必须前面有空白才算注释,所以路径里的 `C:\a#b` 不受影响。 + +### 运行时会把每个条目的归档项逐条介绍出来 + +说明来自 `SoftwareCatalog.psd1` 的 Slot,追加/排除的**来源**来自清单: + +```text +[INFO] 条目:Scoop +[INFO] 归档:Scoop.7z;方向:备份 + 恢复;加密:是 +[INFO] 归档项 1/3:DefaultConfig <- C:\Users\Shuery\.config\scoop +[INFO] 来源:软件名录;存在,会打包;目录 +[INFO] 介绍:Scoop 配置。 +[INFO] 归档项 2/3:GlobalPersist <- C:\ProgramData\scoop\persist +[INFO] 来源:软件名录;存在,会打包;目录 +[INFO] 排除 1 条(来自清单的 :- / @ Exclude):GlobalPersist\steam\steamapps +[INFO] 排除 2 条(来自 BackupConfig.psd1 的 DefaultExcludes):!Thumbs.db、!desktop.ini +``` + +`Restore.ps1` 也会打印"哪一项还原到哪个目录、是文件还是目录、会新建还是覆盖"。 + +### 几个必须知道的约束 + +- **归档名重复会直接报错。** 归档名 = 软件名字,所以同一个软件写两遍会让两个条目互相覆盖。 +- **同一软件里的 Slot 名不能重复**,追加项的归档内路径也不能和 Slot 撞;脚本会在打包前明确报错。 +- **一个条目挂多个归档项时,每一项只还原自己那棵子树**,不会把兄弟项也复制到别的父目录下。 +- **`::` 现在是"覆盖 Path"**,不再是 `:-` 的历史别名;排除一律写 `:-`。 + +## 归档布局、命名与迁移 + +### 包内长什么样 + +| 条目类型 | 归档内部 | +| --- | --- | +| 软件名 + Slot 目录 | `\<该 Path 的内容>` | +| 软件名 + Slot 文件 | 一个名为 `` 的文件(没有扩展名,恢复时还原成 Path 里的原名) | +| 手写路径(目录) | `<路径末级名>\...`(与历史归档一致) | +| 手写路径(文件) | 一个名为 `<路径末级名>` 的文件 | +| `:+` / `Include` 追加项 | 你写的那个 `<归档内相对路径>`(目录就是目录,文件就是那个文件) | + +7z 没有"入库时改名"的能力,所以打包前会建一个**暂存目录**:目录项用 junction、 +文件项用硬链接(不可用时退回复制)按归档内的名字挂进去,打完立刻拆掉。 +建不出连接点时会**明确报错**,不会悄悄换成另一种布局——布局一变恢复就对不上了。 + +### 归档名 | 条目类型 | 归档名 | | --- | --- | | 软件名 | `<软件名>.7z` | -| 字面路径 | `<末级名>_from_<上级路径用 + 连接>.7z` | +| 字面路径 | `<末级名>_from_<上级路径用 + 连接>.7z`(`:` 归一化成 `_`) | | 软件名 + `@pathname` | 同字面路径 | -从旧版本升级时用重命名工具把存量归档搬过来(**默认试运行**、逐份大小校验、重建 manifest): +> `::` / `@ Path=` 只改**从哪儿读**,不改归档名:软件名条目仍然叫 `<软件名>.7z`。 +> 想换归档名就用 `@pathname`,或者干脆把条目写成绝对路径。 -```powershell -.\tools\Rename-Archives.ps1 # 先看计划 -.\tools\Rename-Archives.ps1 -Apply # 确认后执行 +### 从旧版迁移(重要) + +1. **包内布局变了。** 重构前生成的归档,包内顶层是源目录名;现在软件名条目多了一层 Slot。 + `Restore.ps1` 会识别这种情况(归档里没有该 Slot 时打印告警并按旧布局解), + 所以**旧归档仍然恢复得出来**;但要让包内结构统一,跑一次 `.\Backup.ps1 -Force` 重打即可 + (`-Force` 会忽略"源未更新"判断)。 +2. **手写路径条目的归档名可能变了。** 清单里把原来的软件名改成绝对路径之后, + 归档名会从 `<软件名>` 变成 `<末级名>_from_<...>`。用重命名工具对齐(**默认试运行**、 + 逐份大小校验、重建 manifest,只改名不搬数据): + + ```powershell + .\tools\Rename-Archives.ps1 # 先看计划 + .\tools\Rename-Archives.ps1 -Apply # 确认后执行 + ``` + + 它会先用当前规则算出目标名,再从"路径命名算法 / 名录里的软件名 / manifest 里记录过的归档名" + 里找磁盘上真实存在的旧文件。 +3. **名录里的 `Encrypt` 现在生效。** 如果某个 Slot 写了 `Encrypt = $true`(或清单里写了 + `:encrypt`),但运行时取不到口令,该条目会**明确失败**,绝不会退化成明文归档。 + 先准备好 `$env:BAKNRET_PASSWORD` 或用 `-KeyFile` 指定密码文件再跑。 +4. **孤儿归档审计**会在每次备份后点名"磁盘上有、但清单里没有任何条目指向"的归档 + (旧名字没迁移、条目被删掉或改名都会这样)。确认新归档校验通过之后再删旧文件。 + +## 安全描述符(属主 / ACL) + +**问题**:归档格式装不下 NTFS 安全描述符 —— 7-Zip 的 `-sni`(Store NT security information) +官方文档写明「当前版本只能写进 WIM 归档」,`.7z` 里一个字节的 ACL 都没有。 +于是"备份 → 恢复"之后,每个对象的安全描述符都是**新建对象的默认值**:属主是跑恢复脚本的 +那个进程,DACL 是从目标父目录继承来的那一套。 + +**为什么这对 `C:\ProgramData` 是致命的**:那里的目录 ACL 里有 + +```text +(A;OICIIO;GA;;;CO) CREATOR OWNER + inherit-only + GENERIC_ALL ``` -> **合并条目 = 换归档名。** 例如把 `scoop-config` / `scoop-persist` 合成一个 `scoop` -> 数组条目后,归档名从两个变成 `scoop.7z`;旧的 `scoop-config.7z` / `scoop-persist.7z` -> 就**没有清单条目指向了**(会出现在孤儿归档审计里)。确认新的 `scoop.7z` 校验通过之后 -> 再删旧的 —— 重命名工具只改名,不会合并归档内容。 +`CREATOR OWNER`(`S-1-3-0`)不是账户,是**访问检查时才替换的占位符** —— 替换成 +"被检查对象的属主"。所以这句话的真实含义是「谁创建的东西谁有全权」。只回放 ACE 文本、 +不恢复属主,等于把里面的"谁"换成了跑恢复脚本的账户,**原程序(服务账户 / 专用用户) +反而没了读写权限**。真机实测(`tools\lab\Lab.ps1 acl-test`): + +```text +原属主 = S-1-5-18 (NT AUTHORITY\SYSTEM) +恢复后属主 = S-1-5-18 ← 正确恢复(要靠显式启用的 SeRestorePrivilege) +负对照属主 = S-1-5-32-544 ← 只搬文件、不回放安全描述符时,属主落到"跑脚本的账户" +``` + +**怎么做**: + +- 备份时把每个对象的 SDDL(`Get-Acl` 的原文,含 `O:` / `G:` / `D:`)写进旁挂文件 + `Backups/<归档名>.acl.json`,键是**归档内相对路径**(目标机器上 `%UserProfile%` 和 + 名录的前缀补全都会变,只有归档内路径两端同源)。 +- SDDL 里的 SID 是**数值形式**,`CO` / `OW` 这类占位符原样保留。全程**不做账户名解析** + —— 名字解析会把占位符映射成当前用户,或者直接抛 `IdentityNotMappedException`, + 那正是"权限落到脚本头上"的另一种成因。 +- 恢复时在**解压之后**、对真实目标路径**自顶向下**回放:父目录先写,子对象的继承才收敛。 + 原本不 `protected` 的 DACL 只写显式 ACE,其余交给父目录重新继承(保住活继承语义); + `protected` 的原样写。 +- 写属主要 `SeRestorePrivilege`,而且**必须显式启用**:管理员的过滤令牌里它默认是 disabled, + `Set-Acl` / `SetAccessControl` 都不会替你打开。所以**恢复要在管理员(或 SYSTEM)下跑**, + 脚本启动时会明确告警"属主将无法恢复,只能恢复 DACL"。 +- 写失败有三级回退:`属主+属组+DACL` → `属主+DACL` → `仅 DACL`(属组常常是最先失败的那个, + 而它对访问判定几乎没影响,不能因为它把属主一起丢掉)。 +- 对象的安全描述符读不到(系统目录里很常见)时**带错误记账**、写进 sidecar 并计入 manifest + 的 `security.errors`,恢复时跳过它并告警 —— 而不是当成"这个对象没有特殊权限"。 + +配置在 `BackupConfig.psd1`: + +```powershell +Security = @{ + Mode = 'Full' # Off | Full | Smart | Roots + IncludeSacl = $false # 连审计规则(SACL)一起存取,需要 SeSecurityPrivilege + SidMap = @{} # 跨机恢复的 SID 映射:@{ 'S-1-5-21-旧' = 'S-1-5-21-新' } + FailOnError = $false # sidecar 写不出来时,是否把该条目算作失败 +} +``` + +- `Full`(默认):每个对象都存。**正确性优先**,几万文件的树 sidecar 几 MB。 +- `Smart`:只存"继承复现不出来"的对象(protected / 有显式 ACE / 属主属组与父目录不同 / + 继承链已脱节)。判据偏保守,但终究是启发式,所以不是默认。 +- `Roots`:只存每个归档项的根,最省。 +- `Off`:完全不采集,恢复出来的就是新建对象的默认值。 + +`Restore.ps1` 另有 `-SkipSecurity` 可以只恢复文件内容。 + +**已知取舍(有意为之)**: + +- 归档旁边没有 `acl.json` 的旧归档照常恢复,只是打一行告警说明"属主/ACL 是默认值"。 +- **陈旧继承 ACE 会被"冻结"**:如果某个对象的 DACL 里留着已经没有任何出处的继承 ACE + (父目录改过权限、Windows 自己也不会再传播它),那它靠继承复现不出来,只能整套冻结成 + 显式 ACE **并置 protected** —— 这是唯一"既不丢 ACE、也不产生重复 ACE"的做法(实测: + 目标上原本就留着那条陈旧 ACE,再补一条显式 ACE 会让同一条 ACE 出现两次)。 + 代价是这个对象从此不跟随父目录,而它本来就已经跟父目录脱节了。 +- ACL 只跟着归档旁边的 `acl.json` 走:**搬归档时要把同名的 `.acl.json` 一起搬**。 ## 恢复语义 -- 用 `7z x` 把归档里**该目标对应的那棵子树**解到目标的父目录,覆盖同名文件。 -- **归档内部布局与历史完全一致**:顶层仍是源目录名(软件名只用于归档文件名)。 - 所以恢复逻辑不需要"剥掉一层",现有归档也不会因为重命名而解不开。 -- **一个条目挂多个目录时,每个目录只还原自己那棵子树**,不会把兄弟目录也复制到别的父目录下。 +- 每一项只解出**它自己那棵子树**(`` / `<末级名>`),不会把兄弟项也复制到别的父目录下。 +- **目录项**:在目标的父目录下建一个指向目标目录的 junction,让 7z 直接写穿它落地(零拷贝), + 解完立刻拆掉连接点。建不出连接点(父目录里已有同名实体、目标卷不支持等)时, + 退回"先解到临时目录再逐项合并"——只慢不错。 +- **文件项**:解到临时目录后把文件搬到 `Path` 指定的位置(恢复原名)。 +- **旧布局兜底**:归档里没有该 Slot 时(重构前的归档)会打印告警,退回到旧布局 + (把目标的末级名直接解到目标的父目录),与重构前的恢复语义一致。 - **不做镜像同步**:目标目录里多出来的文件不会被删除。想得到"完全等于归档"的目录,请先清空目标。 +- 行首 `+`(仅备份)的条目不恢复;行首 `-`(仅恢复)的条目照常恢复。 - 目标目录比归档新时**默认跳过**,需要覆盖就加 `-Force`。 - `-WhatIf` / `-DryRun` 只打印计划;`-VerifyOnly` 只跑 `7z t`。 这三种模式**一个字节都不写**(`manifest.json` 也不会被碰)。 +- 加密归档取不到口令时**直接失败**,不会让 7z 停在控制台等输入(在计划任务里那会静默挂起)。 - **排除规则只在下一份归档里生效**:已经生成的归档不会因为改了排除表而"变干净"。 ## manifest.json @@ -244,7 +396,8 @@ Edge :- !*Cache,!Crashpad # 缓存与崩溃转储都可再生,不进归档 | --- | --- | | `source` | 清单里的原始写法(软件名或路径) | | `resolvedSource` | 展开后的路径 | -| `roots` | 归档内**真实**的顶层条目名(就是源目录 / 源文件名;只统计真实存在的源)。每次重新处理该条目时刷新 | +| `roots` | 归档内**真实**的顶层条目名(就是 Slot 名 / 源目录名 / 追加项的归档内路径;只统计真实存在的项)。每次重新处理该条目时刷新 | +| `layouts` | 每个归档项的 `{ name, kind }`(`dir` / `file`),恢复端在目标还不存在时靠它判断"该还原成目录还是文件" | | `catalog` | 名录里记录的路径(便于追溯软件名到底指向哪) | | `archive` | 归档文件名 | | `action` | `backed-up` / `skip-unchanged` / `missing-source` / `invalid-path` / `failed` / `planned` | @@ -314,9 +467,12 @@ Edge :- !*Cache,!Crashpad # 缓存与崩溃转储都可再生,不进归档 默认关闭 —— 一旦开启而口令丢失,备份就再也解不开。 ```powershell -# 方式一:只为个别条目加密(.ssh 里是私钥,最典型) -# 在 BackupList.txt 里写成: -# .ssh @encrypt +# 方式一:给某个 Slot 加密(私钥、浏览器数据这类最典型) +# SoftwareCatalog.psd1: +# OpenSSH = @{ DefaultData = @{ Path = '%UserProfile%\.ssh'; Encrypt = $true } } +# 或在 BackupList.txt 的条目上写: +# Edge :encrypt +# PowerShell @ Encrypt='$false' # 反过来,关掉名录里的默认加密 # 方式二:全部加密,改配置 # Encryption = @{ Enabled = $true; PasswordFile = 'D:\secret\baknret.key' } @@ -326,7 +482,8 @@ $env:BAKNRET_PASSWORD = '...' # 或 .\Backup.ps1 -KeyFile 'D:\secret\baknret.key' # 文件首行即口令 ``` -要求加密但取不到口令时,该条目会**明确失败**,绝不会退化成明文归档。 +一个软件一个归档:名录里各 Slot 的 `Encrypt` 不一致时,**整个归档按加密处理**(宁可多加密,不可漏加密), +并打印告警。要求加密但取不到口令时,该条目会**明确失败**,绝不会退化成明文归档。 恢复加密归档时同理:取不到口令就直接失败,不会让 7z 停在控制台等待输入(在计划任务里那会静默挂起)。 > ⚠️ 7-Zip 只接受命令行口令,口令在本机进程列表里会短暂可见。这是 7z 本身的限制,请自行权衡。 @@ -347,10 +504,11 @@ $env:BAKNRET_PASSWORD = '...' # 或 | 套件 | 命令 | 需要什么 | 覆盖 | | --- | --- | --- | --- | -| **Pester 套件**(推荐) | `.\tests\Run-Pester.ps1` | Pester 5.0+ 与 7z | 88 项:解析、命名、排除翻译、命令行拼接、manifest / 配置 / 名录、**两种写法 × `:+`/`:-`**,外加**用子进程真正跑 `Backup.ps1` / `Restore.ps1`** 的端到端与回归 | -| 零依赖套件 | `.\tests\Run-Tests.ps1` | 只要 PowerShell + 7z | 49 项:同样的单元面,适合没装 Pester 的机器 | -| 端到端验收 | `.\tests\Run-E2E.ps1` | 只要 PowerShell + 7z | 23 项:备份 → 确认排除生效 → 删源 → 恢复 → 逐字节对拍 | -| **真实归档恢复演练** | `.\tests\Restore-Drill.ps1` | 只要 PowerShell + 7z | 把 `Backups/` 里**真实的那批归档**解到临时目录,再和活源逐字节对拍(全程不碰真实目录) | +| **Pester 套件**(推荐) | `.\tests\Run-Pester.ps1` | Pester 5.0+ 与 7z | 150 项:清单语法(方向 / `::` / `:-` / `:+` / `:encrypt` / `@ Key='值'` / 整行引号与记号边界)、Slot 结构名录、归档命名、排除翻译(`-x!` / `-xr!` / `!re:`)、Slot 前缀分配、暂存、manifest / 配置 / 名录,外加**用子进程真正跑 `Backup.ps1` / `Restore.ps1`** 的端到端与回归 | +| 零依赖套件 | `.\tests\Run-Tests.ps1` | 只要 PowerShell + 7z | 101 项:同样的单元面,适合没装 Pester 的机器 | +| 端到端验收 | `.\tests\Run-E2E.ps1` | 只要 PowerShell + 7z | 36 项:备份 → 确认排除生效 → 删源 → 恢复 → 逐字节对拍,含 `\` 布局、文件 Slot、方向标记与旧布局回退 | +| **真实归档恢复演练** | `.\tests\Restore-Drill.ps1` | 只要 PowerShell + 7z | 12 个真实归档:解到临时目录再和活源逐字节对拍(全程不碰真实目录) | +| **安全描述符套件** | `.\tests\Run-Pester.ps1`(内含 `BakNRet.Security.Tests.ps1`) | Pester 5 + 7z | 25 项:排除判定与 7z `-x!/-xr!` 语义对齐、SID 映射边界(前缀 SID 不被误伤)、采集与 sidecar 往返、回放(`CREATOR OWNER` + 孤儿 SID + `protected` 逐字节一致)、以及真的用子进程跑 `Backup.ps1`/`Restore.ps1` 做端到端 | 演练会把"源在备份之后变过"和"归档/解压有问题"分开:内容不一致时看活源文件的修改时间, 晚于归档时间就算"源变了"(只提示),不晚于归档时间却内容不同才算失败。真实机器上的归档 @@ -379,8 +537,16 @@ Pester 套件里的端到端用例是**用子进程**跑 `Backup.ps1` / `Restore | `Start-Process -PassThru` 的 `ExitCode` 在 PowerShell 7.7.0-preview.4 上恒为 `$null` | 压缩明明成功却报"压缩失败",`exit 2 → 删档重试` 的自愈分支永远不可达 | 用 `.NET Process` 继承控制台启动,退出码可靠 | | 排除模式写成 `-x!"路径"` | 引号成为模式的一部分,**排除对所有条目都失效** | 不再嵌引号;含空格自动转 `?`,`!` 前缀走 `-xr!` | | 解析器用 `;` 分隔,清单里写的是 `,` | 整串被当成一个模式,等于没有排除 | `,` 与 `;` 都支持 | -| `^"([^"]+)"` 贪婪匹配 | 整行加引号的写法把排除表吞进路径 → 该条目被静默跳过,2.8 GB 归档成了孤儿 | 先按 `::` 切分再处理引号 | +| `^"([^"]+)"` 贪婪匹配 | 整行加引号的写法把排除表吞进路径 → 该条目被静默跳过,2.8 GB 归档成了孤儿 | 先按空白分词切出修饰符,再处理引号 | | 归档名由路径拼出 | 加一条备份要自己算名字,名字随路径变动 | 清单写软件名,归档名就是软件名 | +| 一个软件里两个同名目录(例如两个 `persist`) | 静默混成一棵树,两边的数据都错 | 名录改成 **Slot 结构**,每个 Slot 是归档内的一层目录,同名不再冲突 | +| 清单只能"备份 + 恢复"一把抓 | 想只备份的、只恢复的条目得另开文件 | 行首 `+` / `-` 直接标方向,两条路径共用一份清单 | +| `::` 既是"排除"又是历史别名 | 语义含糊:`::` 一会儿是排除、一会儿是路径 | `::` 只表示**覆盖 Path**,排除一律写 `:-` | +| 加密只能靠裸标记 `@encrypt` | 名录里的加密意图没法表达 | `:encrypt` / `:!encrypt` / `@ Encrypt='$false'`,名录的 Slot 也能写 `Encrypt` | +| 排除/追加只能写在清单行里 | 名录里的 Slot 光有路径,规则全堆在清单里 | `Exclude` / `Include` / `Encrypt` 都可以写在 Slot 上,清单按需覆盖 | +| `!` 只能按通配符匹配 | 想按正则排除做不到 | 新增 `!re:<正则>`(脚本遍历源目录翻译成精确排除项) | +| 名录路径只支持 `%变量%` | `scoop prefix xxx` 这类动态路径写不出来 | `Path` 支持 `$( ... )` 子表达式,并在一次运行内缓存求值结果 | +| 名录每解析一个条目就重新 Import 一次 | 同一个文件被反复读取、`$( ... )` 被反复执行 | 按内容指纹缓存,一次运行只读一次 | | 直接更新已有归档(7z `u`) | 固实归档下收益极小,且排除规则与"源里已删的文件"永远反映不到归档里 | 临时文件 → `7z t` 校验 → 原子替换 | | 没有校验、没有记录 | 中断留下的半个归档会被下次 `u` 续写;跳过/失败只有一行滚过去的 WARN | 校验 + 原子替换 + `manifest.json` + 日志文件 | | 结尾不 `exit` | 全部失败也返回 0,计划任务永远显示成功 | 有失败返回 1 | @@ -389,12 +555,11 @@ Pester 套件里的端到端用例是**用子进程**跑 `Backup.ps1` / `Restore | 恢复没有干跑 | 直接覆盖 `E:\CodeSpace`、Edge User Data 这类真实目录 | `-WhatIf` / `-DryRun` / `-VerifyOnly` / `-Only` | | `manifest.json` 的 `roots` | 记的是软件名,与归档里真实的顶层目录对不上(`Edge` vs `User Data`) | 记归档内真实的顶层条目名,并且和归档内容对账过 | | `-DryRun` / `-WhatIf` / `-VerifyOnly` | 仍然写回 `manifest.json`,违背"不会写入任何文件" | 只有真的恢复成功了才写回(用 manifest 的 SHA256 前后对比验证) | -| 孤儿归档 | 只在恢复时列一下;带 `-Only` 时还会把未选中的归档误报成孤儿,吓得人不敢删 | 备份端也做孤儿审计;`-Only` / `-Skip` 时不再误报 | -| `Resolve-BackupEntry` 里的 `$rootName` | 在赋值之前就被引用,会读到外层作用域残留的值 | 提前赋值,回归测试钉死 | -| 手写目录的 `:+` 追加 | 被整段丢掉(只有软件名写法才生效),既没人报错也没人知道 | 两种写法都生效,追加项还会标出来源(名录展开 / 字面路径) | -| 软件名录的多目录写法 | 只有 `@{ Dirs = @(...) }`,没有"这个目录是干什么的" | 支持**对象数组**(`Path` + `Description`),运行时逐条介绍 | -| 多目录条目的恢复 | 把整包解压到每个位置的父目录,会在别的父目录下凭空冒出兄弟目录 | 每个源只解出**它自己那棵子树** | -| 同一条目里两个同名目录 | 静默混成一棵树,两边的数据都错 | 打包前明确报错(退出码 1)并提示拆成两个条目 | +| 孤儿归档 | 只在恢复时列一下;带 `-Only` 时还会把未选中的归档误报成孤儿,吓得人不敢删 | 备份端也做孤儿审计;`-Only` / `-Skip` 时不再误报;`+` / `-` 的条目也算"有主" | +| 手写目录的 `:+` 追加 | 被整段丢掉(只有软件名写法才生效),既没人报错也没人知道 | 两种写法都生效,追加项还会标出来源(名录 / 追加项) | +| 软件名录的多目录写法 | 一个软件可以挂多个目录,但目录名不能重复,否则包内混成一棵树 | 改成 **Slot 结构**:每个 Slot 是包内一层目录,同名目录(两个 `persist`)不再冲突 | +| 一个条目挂多个目录的恢复 | 把整包解压到每个位置的父目录,会在别的父目录下凭空冒出兄弟目录 | 每个归档项只解出**它自己那棵子树** | +| 归档内路径冲突 | 静默混成一棵树,两边的数据都错 | 打包前明确报错(退出码 1)并提示改 Slot 名 / 归档内相对路径 | | 运行时的可解释性 | 只有一行"开始备份: X" | 逐条打印目录、来源、介绍、排除/追加的出处与理由;备份前还会预估所需空间并判断够不够 | | manifest 的 `archive` 字段 | 源不存在的条目也留着归档名,指向一个根本不存在的文件;恢复时白报"归档不存在" | 只在文件真的存在时才写;删掉归档后同步一次就自我纠正 | | 没有名录、manifest、测试、README,不是 git 仓库 | — | 都有 | @@ -402,20 +567,49 @@ Pester 套件里的端到端用例是**用子进程**跑 `Backup.ps1` / `Restore ## 设计取舍(有意为之,不是遗漏) - **放弃 7z 的更新模式(`u`)。** 7z 默认固实压缩,`u` 本来就要重压大部分数据,收益很小,却让"排除规则改动"和"源里删掉的文件"永远进不了归档。 -- **归档内部不套一层软件名目录。** 考虑过用暂存目录(硬链/复制)把归档根目录改成软件名,代价是多一次链接开销、实现复杂度上升,收益只是"解开包第一层好看"。归档名已经是软件名,包内保持源目录名也便于确认内容来源。顺带一提,7z 的 `-spf` 不是干这个的(它是 *use fully qualified file paths*)。 +- **包内用 Slot 分层,靠暂存目录改名。** 7z 没有"入库时改名"的能力,所以打包前建一个暂存目录, + 把每个归档项按包内名字挂进去(目录走 junction、文件走硬链接/复制),打完立刻拆掉。 + 代价是每份归档多一次 junction 开销;收益是**一个软件可以有多个目录而不怕重名** + (scoop 的用户 `persist` 与全局 `persist` 就属于这种),恢复时也能精确地"只解这一棵子树"。 + 建不出连接点时**明确报错**,不悄悄退化成另一种布局。顺带一提,7z 的 `-spf` 不是干这个的 + (它是 *use fully qualified file paths*)。 +- **恢复用 junction 零拷贝落地。** 目标父目录下建一个指向目标的 junction,让 7z 直接写穿它, + 解完立刻拆掉;建不出来就退回"先解到临时目录再合并"。这样不必把大归档整体搬两遍。 - **不捕获压缩工具的输出。** 结构化记录交给日志与 `manifest.json`;捕获子进程 stdio 需要额外管道,在受限环境里会直接失败。 - **有警告(退出码 1)时不覆盖完整的归档。** 被占用的文件会让 7z 返回 1,此时新归档是**不完整**的。实测 Edge 运行时打包,118 个文件读不到,其中包含 `Login Data`(密码)、`Cookies`、`History`、`Web Data`。所以在位归档完整时脚本**保留它、报失败、退出码 1**,确认可以接受再显式加 `-AcceptWarnings`。 -- **名录里的路径不存在时,恢复仍然可用。** 源被删掉正是要恢复的场景,所以解析器照旧给出 `Sources`,备份端则据此跳过。 +- **名录里的路径不存在时,恢复仍然可用。** 源被删掉正是要恢复的场景,所以解析器照旧给出 `Items`,备份端则据此跳过。 - **源路径不存在只算"跳过",不算失败。** 会以 `missing-source` 记进 manifest。失败只统计真正打不开的条目。 +- **`@ Path=` 覆盖只允许单 Slot 条目。** 多 Slot 时"覆盖"根本没有唯一含义,直接报错比猜一个 Slot 好。 +- **旧归档用"旧布局兜底"而不是拒绝恢复。** 重构前的归档包内没有 Slot 层, + 恢复时按 Slot 解会失败,脚本捕获后按旧布局(目标的末级名)再试一次, + 并在日志里说清楚——旧备份仍然救得回来。 ## 已知限制 -- **改软件名等于换归档名。** 改名后旧归档不会被自动迁移,用 `tools/Rename-Archives.ps1` 或手动改名,并注意 manifest 里会留下旧键。 +- **改软件名 / 改 Slot 名等于换归档结构。** 改名后旧归档不会被自动迁移,用 `tools/Rename-Archives.ps1` 或手动改名, + 并注意 manifest 里会留下旧键;Slot 名变了则需要重打(`-Force`)。 - 路径里本来就含 `+` 或 `_from_` 时,仅靠文件名无法可靠反推路径,此时依赖 `manifest.json`。 - `-Snapshot` 目前是"复制一份带时间戳的副本",不做自动轮转清理(`KeepCount` / `KeepDays` 尚未实现)。 - 加密归档的常规备份/恢复不依赖 `RAR`;`RAR` 与内置 `ZIP` 分支仅作降级,未做加密支持(ZIP 明确拒绝加密请求)。 -- `Variants`(同名目录分散在多处)当前打包第一个位置;恢复时每个源只解出**它自己那棵子树**,不会把兄弟目录复制到别的父目录下。 -- **`root=<名>` 标记尚未实现。** 归档内的根目录始终是源目录名(见「设计取舍」)。7z 命令行没有"入库时改名"的能力;用了该标记会打印告警,不会静默失效。 + 内置 ZIP 分支也不支持排除规则(`Compress-Archive` 没有对应开关),只保证内容完整。 +- **暂存改名需要能建目录连接点(junction)。** 暂存目录在 `%TEMP%`(NTFS 即可),目标源目录跨盘也没问题; + 建不出连接点时该条目会明确失败,而不会静默换成别的布局。恢复时的 junction 建不出来会自动退回"临时目录 + 合并"。 +- **一个 Slot 只能对应一个目录。** 前缀补全命中多个候选(同名目录分散在多处)时会报错并让你拆成多个 Slot, + 而不是任选一个。 +- **`!re:` 有量级上限。** 正则命中的路径超过 300 条、或排除参数超过命令行安全长度时会明确失败; + 这种场景应改用更粗的通配模式。 +- **`root=<名>` 标记已废弃。** 包内的一层目录现在由 Slot 决定;写了该标记只会打印告警。 - **空间只做"预估 + 提示",不做全局拦截。** 备份前会打印预计峰值新增和"够不够"的结论; 不够时**只告警不中断**,真正放不下的条目交给逐条目守卫跳过。`MinFreeSpaceGB` 是告警阈值。 想稳妥跑完就先腾空间,或用 `-Only` / `-Skip` 分批。 +- **恢复安全描述符需要管理员(或 SYSTEM)。** 非提权时属主写不进去(`SeRestorePrivilege` + 不在令牌里),脚本会退化到"只恢复 DACL"并明确告警 —— 那不是失败,但 `CREATOR OWNER` + 会判给"当前属主",所以依赖它的程序可能仍然没权限。 +- **`acl.json` 要跟归档一起搬。** 它不在归档里(7z 装不下),改名 / 迁移归档时要用 + `tools\Rename-Archives.ps1` 或手工把同名旁挂文件一起改。 +- **7z 会跟随 junction**(不是存成链接,因为 `-snl` 只对 WIM/TAR 生效):所以 scoop 那种 + `apps\\current` 的连接点,备份时会把目标内容一并收进归档(体积翻倍),恢复后 + `current` 变成**真实目录**。功能上仍然可用(`current\bin\...` 路径还在),但要心里有数。 +- **跨机恢复要配 `Security.SidMap`**:本机不存在的 SID 写进 DACL 是安全的(那条 ACE 只是 + 永不匹配),但写进**属主**会让谁都没有合理所有权 —— 换域 / 换机时请给映射,或接受 + "属主未恢复"的告警。服务账户(`NT SERVICE\X`)的 SID 是按名字算出来的,跨机一致。 diff --git a/Restore.ps1 b/Restore.ps1 index caa8678..303e12f 100644 --- a/Restore.ps1 +++ b/Restore.ps1 @@ -16,6 +16,10 @@ 真实目录的破坏性操作,必须能先看清单再决定。 4. 支持 -Only / -Skip 只恢复指定条目,-VerifyOnly 只校验不写盘。 5. 结尾按失败数 exit。 + 6. 清单行首 `+`(仅备份)的条目会跳过;`-`(仅恢复)的条目照常恢复。 + 7. 归档内的一层目录由 SoftwareCatalog 的 Slot 决定(`\<内容>`), + 恢复时只解出该 Slot 那棵子树,并通过"目标父目录下的 junction"直接落地 + (零拷贝;建不出连接点时退回先解到临时目录再合并)。 #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] @@ -48,7 +52,11 @@ param( # 只对归档做 7z t 校验,不解压 [Parameter()] - [switch]$VerifyOnly + [switch]$VerifyOnly, + + # 不恢复安全描述符(属主 / ACL):默认会按 <归档名>.acl.json 回放 + [Parameter()] + [switch]$SkipSecurity ) $ErrorActionPreference = 'Stop' @@ -157,25 +165,32 @@ function Get-7zExecutable { return $sevenZip } -function Invoke-Extraction { - param([object]$ArchiveFile, [string]$DestinationPath, [string]$RelativePath) +function Invoke-ExtractionRaw { + <# + .SYNOPSIS + 把归档里某个子树解到指定目录,不关心"落地"问题。 + + .DESCRIPTION + 归档布局:软件名条目是 `\...`(Slot 就是归档内的一层目录), + 手写路径条目是 `<源目录名>\...`。这里只负责把指定的那条路径解出来。 + #> + param( + [Parameter(Mandatory = $true)][object]$ArchiveFile, + [Parameter(Mandatory = $true)][string]$Destination, + [string]$RelativePath, + [string]$Password + ) $extension = $ArchiveFile.Extension.ToLower() - $destParent = Split-Path -Path $DestinationPath -Parent - - if (-not (Test-Path -LiteralPath $destParent)) { - New-Item -ItemType Directory -Path $destParent -Force | Out-Null + if (-not (Test-Path -LiteralPath $Destination)) { + New-Item -ItemType Directory -Path $Destination -Force | Out-Null } - # 归档布局与历史保持一致:顶层就是**源目录名**(软件名只用于归档文件名)。 - # 一个条目可能打包了好几个目录(软件名录里的数组写法 / `:+` 追加), - # 所以**不能整包往每个目标里倒** —— 那会把兄弟目录也复制到不相干的父目录下。 - # 这里只解出该目标自己那棵子树($RelativePath),其余不动。 $sevenZip = Get-7zExecutable if ($sevenZip) { Write-Log '使用 7z 解压' -Level DEBUG - $argument = @('x', '-bsp2', '-y', "-o$destParent") - if ($password) { $argument += "-p$password" } + $argument = @('x', '-bsp2', '-y', "-o$Destination") + if ($Password) { $argument += "-p$Password" } $argument += $ArchiveFile.FullName if ($RelativePath) { $argument += $RelativePath } @@ -189,7 +204,7 @@ function Invoke-Extraction { $rarExe = Get-Command rar, unrar -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source if (-not $rarExe) { throw '未找到 RAR 工具' } Write-Log '使用 RAR 解压' -Level DEBUG - $argument = @('x', '-idp', '-idn', '-y', $ArchiveFile.FullName, "$destParent\") + $argument = @('x', '-idp', '-idn', '-y', $ArchiveFile.FullName, "$Destination\") if ($RelativePath) { $argument += $RelativePath } $exitCode = Invoke-ExternalCommand -FilePath $rarExe -ArgumentList $argument if ($exitCode -ne 0) { throw "RAR 解压失败(退出码:$exitCode)" } @@ -199,13 +214,13 @@ function Invoke-Extraction { if ($RelativePath) { Write-Log "内置 ZIP 不支持只解子树,将整包解压($RelativePath)" -Level WARN } - Expand-Archive -LiteralPath $ArchiveFile.FullName -DestinationPath $destParent -Force + Expand-Archive -LiteralPath $ArchiveFile.FullName -DestinationPath $Destination -Force } '.tar' { $tarExe = Get-Command tar -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source if (-not $tarExe) { throw '未找到 TAR 工具' } Write-Log '使用 TAR 解压' -Level DEBUG - $argument = @('-xf', $ArchiveFile.FullName, '-C', $destParent) + $argument = @('-xf', $ArchiveFile.FullName, '-C', $Destination) if ($RelativePath) { $argument += $RelativePath } $exitCode = Invoke-ExternalCommand -FilePath $tarExe -ArgumentList $argument if ($exitCode -ne 0) { throw "TAR 解压失败(退出码:$exitCode)" } @@ -215,6 +230,210 @@ function Invoke-Extraction { return $true } +function Invoke-ExtractionByLayout { + <# + .SYNOPSIS + 按**当前归档布局**(软件名条目 = `\<内容>`)解出一个归档项并落到目标位置。 + + .DESCRIPTION + $Item:ArchivePath(归档内相对路径)、RealPath(宿主机目标)、IsFile。 + + 落地方式(关键:不整包往目标里倒,只解出这一项自己那棵子树): + + * 目录项 -> 在目标的父目录下建一个**指向目标目录的 junction**, + 让 7z 直接写穿连接点落地(零拷贝,不需要"先解到临时目录再整体搬一遍"), + 解完立刻拆掉连接点。建不出连接点(父目录里已有同名实体等)时, + 退回"解到临时目录再逐项合并",只慢不错。 + * 文件项 -> 解到临时目录后把文件搬到目标位置(保留 Path 里的原始文件名)。 + + 目标目录只覆盖同名文件,不删除多余文件(镜像同步不是这里的语义)。 + #> + param( + [Parameter(Mandatory = $true)][object]$ArchiveFile, + [Parameter(Mandatory = $true)][object]$Item, + [string]$Password + ) + + $archivePath = [string]$Item.ArchivePath + $destPath = [string]$Item.RealPath + if ([string]::IsNullOrWhiteSpace($archivePath)) { throw "归档项缺少归档内路径($destPath)" } + if ([string]::IsNullOrWhiteSpace($destPath)) { throw "归档项缺少目标路径($archivePath)" } + + $destParent = Split-Path -Path $destPath -Parent + if (-not $destParent) { throw "无法确定目标父目录:$destPath" } + + if ($Item.IsFile) { + $temp = Join-Path $env:TEMP ('bnr-file-' + [guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $temp -Force | Out-Null + try { + if (-not (Invoke-ExtractionRaw -ArchiveFile $ArchiveFile -Destination $temp -RelativePath $archivePath -Password $Password)) { + return $false + } + $produced = Join-Path $temp $archivePath + if (-not (Test-Path -LiteralPath $produced -PathType Leaf)) { + throw "归档里的 $archivePath 不是一个文件" + } + if (-not (Test-Path -LiteralPath $destParent)) { + New-Item -ItemType Directory -Path $destParent -Force | Out-Null + } + Move-Item -LiteralPath $produced -Destination $destPath -Force + } finally { + Remove-Item -LiteralPath $temp -Recurse -Force -ErrorAction SilentlyContinue + } + return $true + } + + # 目录项:先把目标目录准备好(不存在就建),再决定用连接点还是合并兜底 + if (-not (Test-Path -LiteralPath $destPath)) { + New-Item -ItemType Directory -Path $destPath -Force | Out-Null + } + + $anchorName = Get-BaknretArchiveTopName -ArchivePath $archivePath + $anchorPath = if ($anchorName) { Join-Path $destParent $anchorName } else { $null } + $junctionCreated = $false + + if ($anchorPath -and -not (Test-Path -LiteralPath $anchorPath)) { + try { + New-BaknretJunction -Path $anchorPath -Target $destPath | Out-Null + $junctionCreated = $true + Write-Log ("落地:{0} -> {1}(经连接点 {2})" -f $archivePath, $destPath, $anchorPath) -Level DEBUG + } catch { + Write-Log "无法建连接点($($_.Exception.Message)),改为先解到临时目录再合并" -Level WARN + } + } + + if ($junctionCreated) { + try { + return (Invoke-ExtractionRaw -ArchiveFile $ArchiveFile -Destination $destParent -RelativePath $archivePath -Password $Password) + } finally { + Remove-BaknretJunction -Path $anchorPath + } + } + + Write-Log ("落地:{0} -> {1}(先解到临时目录再合并)" -f $archivePath, $destPath) -Level WARN + $temp = Join-Path $env:TEMP ('bnr-merge-' + [guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $temp -Force | Out-Null + try { + if (-not (Invoke-ExtractionRaw -ArchiveFile $ArchiveFile -Destination $temp -RelativePath $archivePath -Password $Password)) { + return $false + } + $source = Join-Path $temp $archivePath + if (-not (Test-Path -LiteralPath $source)) { throw "归档里没有 $archivePath" } + # 逐个顶层子项复制(而不是 `Copy-Item '<源>\*'`):空目录时通配符匹配不到任何东西, + # Copy-Item 会直接报 "Cannot find path"。合并语义:覆盖同名文件,不删多余文件。 + foreach ($child in @(Get-ChildItem -LiteralPath $source -Force -ErrorAction SilentlyContinue)) { + Copy-Item -LiteralPath $child.FullName -Destination $destPath -Recurse -Force + } + } finally { + Remove-Item -LiteralPath $temp -Recurse -Force -ErrorAction SilentlyContinue + } + return $true +} + +function Test-BaknretArchivePath { + <# + .SYNOPSIS + 归档里有没有这条路径。 + + .DESCRIPTION + 必须问,不能靠退出码猜:7z 在"归档里没有这个名字"时**同样返回 0** + (打印一句 "No files to process" 就结束),所以只解压、然后看退出码, + 会把"什么都没解出来"当成成功 —— 那正是最危险的静默失败。 + + 7z 的列表输出没法用管道读(受限环境会拒绝创建管道),所以用 + `Start-Process -RedirectStandardOutput <文件>` 把它重定向到文件再读 + (Start-Process 的重定向是直接给子进程一个文件句柄,不经过管道); + 用 -sccUTF-8 保证非 ASCII 路径不会因为控制台代码页而丢字。 + 列表为空 = 这条路径不在归档里。 + + 注意这里刻意**不用** ExitCode:本机的 PowerShell 预览版上 + `Start-Process -PassThru` 的 ExitCode 恒为 $null(见 README「设计取舍」), + 而 7z 在"路径不存在"时退出码同样是 0,所以退出码本来也不可用。 + #> + param( + [Parameter(Mandatory = $true)][object]$ArchiveFile, + [Parameter(Mandatory = $true)][string]$RelativePath, + [string]$Password + ) + + $sevenZip = Get-7zExecutable + if (-not $sevenZip) { return $true } # 没有 7z 时不预判,交给解压分支自己去失败 + + $item = ([string]$RelativePath).Trim([char[]]@('\', '/')) + if ([string]::IsNullOrWhiteSpace($item)) { return $false } + + $outFile = Join-Path $env:TEMP ('bnr-list-' + [guid]::NewGuid().ToString('N') + '.txt') + $errFile = "$outFile.err" + try { + $argument = @('l', '-ba', '-sccUTF-8') + if ($Password) { $argument += "-p$Password" } + $argument += $ArchiveFile.FullName + $argument += $item + + $null = Start-Process -FilePath $sevenZip ` + -ArgumentList (ConvertTo-NativeArgumentString -ArgumentList $argument) ` + -RedirectStandardOutput $outFile -RedirectStandardError $errFile ` + -NoNewWindow -Wait -PassThru + + $lines = @(Get-Content -LiteralPath $outFile -Encoding UTF8 -ErrorAction SilentlyContinue) + } catch { + Write-Log "无法列出归档内容(跳过预判):$($_.Exception.Message)" -Level DEBUG + return $true + } finally { + Remove-Item -LiteralPath $outFile -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $errFile -Force -ErrorAction SilentlyContinue + } + + # 列表每行的末尾才是路径(前面是时间 / 属性 / 大小),所以按"路径记号"来判定 + $escaped = [regex]::Escape($item) + foreach ($line in $lines) { + $text = ([string]$line).Trim() + if (-not $text) { continue } + if ($text -match "(?i)(^|[\s\\/])$escaped($|[\s\\/])") { return $true } + } + return $false +} + +function Invoke-Extraction { + <# + .SYNOPSIS + 解出一个归档项并落地;包内布局对不上时回退到重构前的旧布局。 + + .DESCRIPTION + Slot 布局(`\<内容>`)是本次重构才开始用的,Backups/ 里还躺着不少 + 按旧布局(包内直接是 `<源目录名>\...`)生成的归档。所以先问归档"这条路径在不在": + + * 在 -> 按当前布局解(junction 零拷贝落地,见 Invoke-ExtractionByLayout); + * 不在,但有旧布局的 `<目标末级名>` -> 打印告警并按旧布局解, + 与重构前的恢复语义完全一致; + * 两个都没有 -> 明确失败,而不是"成功地什么都没恢复"。 + #> + param( + [Parameter(Mandatory = $true)][object]$ArchiveFile, + [Parameter(Mandatory = $true)][object]$Item, + [string]$Password + ) + + $archivePath = [string]$Item.ArchivePath + $destPath = [string]$Item.RealPath + $legacyName = Split-Path -Path $destPath -Leaf + + if (Test-BaknretArchivePath -ArchiveFile $ArchiveFile -RelativePath $archivePath -Password $Password) { + return (Invoke-ExtractionByLayout -ArchiveFile $ArchiveFile -Item $Item -Password $Password) + } + + if ($legacyName -and ($legacyName -ine $archivePath) -and + (Test-BaknretArchivePath -ArchiveFile $ArchiveFile -RelativePath $legacyName -Password $Password)) { + Write-Log ("归档里没有 '{0}'(可能是重构前的旧归档),按旧布局回退为 '{1}'" -f $archivePath, $legacyName) -Level WARN + $parent = Split-Path -Path $destPath -Parent + 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) + } + + throw ("归档 {0} 里既没有 '{1}',也没有旧布局的 '{2}';请确认归档与清单/名录是否匹配" -f ` + $ArchiveFile.Name, $archivePath, $legacyName) +} + # ============================================================================ # 准备 # ============================================================================ @@ -285,6 +504,7 @@ function Test-EntrySelected { $lines = Get-Content -LiteralPath $BackupListPath -ErrorAction Stop $stats = @{ restored = 0; skipped = 0; failed = 0; verified = 0; planned = 0 } +$securityApplied = 0 # 本次回放成功的安全描述符对象数 $failures = @() $referencedArchives = @() @@ -307,6 +527,23 @@ foreach ($line in $lines) { if (-not $baseName) { $stats.skipped++; continue } if (-not (Test-EntrySelected -DisplayPath $displayPath -BaseName $baseName)) { continue } + if ($resolved.Direction -eq 'backup') { + # 仅备份的条目照样要登记归档名:审计要能看出"这个归档是有主的", + # 否则它会被误报成孤儿(只是它本来就恢复不到,因为行首写了 +)。 + $referencedArchives += $baseName + Write-Log "跳过(行首 +,仅备份): $displayPath" -Level DEBUG + continue + } + + # 解析阶段就定死的结构性错误(名录条目有问题、归档内路径冲突): + # 恢复一半比明确失败更危险,所以整条失败。 + if ($resolved.Blocking) { + Write-Log "失败: $displayPath,$($resolved.Blocking)" -Level ERROR + $stats.failed++ + $failures += $displayPath + continue + } + $found = Get-ArchiveForEntry -Entry ([pscustomobject]@{ baseName = $baseName }) -Manifest $manifest if (-not $found) { Write-Log "跳过: $displayPath,未找到归档 $baseName" -Level WARN @@ -314,25 +551,59 @@ foreach ($line in $lines) { continue } - # 恢复目的地。一个条目可能带多个源(软件名录里的数组写法、`:+` 追加、 - # 或同名目录分散在多处),每个源只还原**它自己那棵子树**。 - $targets = @() - if ($resolved.Sources.Count -gt 0) { - foreach ($source in $resolved.Sources) { - $targets += [pscustomobject]@{ - DestPath = $source.SourcePath - RelativePath = @($source.RelativePaths)[0] - Description = $source.Description - Origin = $source.Origin - } + # "是目录还是文件"的判据,按可靠性排序: + # 1. 目标在磁盘上真实存在 -> 直接看它; + # 2. manifest 记的 layouts(备份时记录的归档内条目类型)-> 全新恢复时靠它; + # 3. 名录解析出来的 IsFile(源当前存在时才有值); + # 4. 都没有就按目录处理。 + $layouts = @{} + if ($found.Record -and ($found.Record.PSObject.Properties.Name -contains 'layouts')) { + foreach ($layout in @($found.Record.layouts)) { + if (-not $layout) { continue } + $layoutName = [string]$layout.name + if ([string]::IsNullOrWhiteSpace($layoutName)) { continue } + $layouts[$layoutName.ToLower()] = [string]$layout.kind } - } else { - $expanded = [Environment]::ExpandEnvironmentVariables($displayPath) + } + + # 恢复目的地:一个条目可以挂多个归档项(名录里多个 Slot、`:+` 追加), + # 每一项只还原**它自己那棵子树**,不会把兄弟项也复制过去。 + $targets = @() + foreach ($entryItem in @($resolved.Items)) { + $dest = [string]$entryItem.RealPath + if ([string]::IsNullOrWhiteSpace($dest)) { continue } + + $isFile = [bool]$entryItem.IsFile + if (Test-Path -LiteralPath $dest -PathType Leaf) { + $isFile = $true + } elseif (Test-Path -LiteralPath $dest -PathType Container) { + $isFile = $false + } elseif ($layouts.ContainsKey(([string]$entryItem.ArchivePath).ToLower())) { + $isFile = ($layouts[([string]$entryItem.ArchivePath).ToLower()] -eq 'file') + } + $targets += [pscustomobject]@{ - DestPath = $expanded - RelativePath = (Split-Path -Path $expanded -Leaf) - Description = $null - Origin = 'path' + ArchivePath = [string]$entryItem.ArchivePath + RealPath = $dest + DestPath = $dest + IsFile = $isFile + Description = $entryItem.Description + Origin = $entryItem.Origin + } + } + + # 兜底:解析不出归档项时按字面路径处理(历史清单里的裸路径) + if ($targets.Count -eq 0 -and -not $resolved.IsName) { + $expanded = [Environment]::ExpandEnvironmentVariables($displayPath) + if (-not [string]::IsNullOrWhiteSpace($expanded)) { + $targets += [pscustomobject]@{ + ArchivePath = (Split-Path -Path $expanded -Leaf) + RealPath = $expanded + DestPath = $expanded + IsFile = (Test-Path -LiteralPath $expanded -PathType Leaf) + Description = $null + Origin = 'path' + } } } @@ -340,7 +611,7 @@ foreach ($line in $lines) { # (那种报错是 "Cannot bind argument to parameter 'Path' because it is an empty string") $targets = @($targets | Where-Object { $_.DestPath -and -not [string]::IsNullOrWhiteSpace($_.DestPath) }) if ($targets.Count -eq 0) { - $reason = "无法确定恢复目的地(清单条目 '$displayPath' 解析不出任何源路径)" + $reason = "无法确定恢复目的地(清单条目 '$displayPath' 解析不出任何归档项)" Write-Log "失败: $displayPath,$reason" -Level ERROR $stats.failed++ $failures += $displayPath @@ -415,7 +686,9 @@ foreach ($line in $lines) { foreach ($target in $plannedTargets) { $targetExists = Test-Path -LiteralPath $target.DestPath Write-Log (" 目标:{0}" -f $target.DestPath) - Write-Log (" 归档内子树:{0};{1}" -f $target.RelativePath, $(if ($targetExists) { '已存在,将覆盖同名文件' } else { '不存在,将新建' })) + Write-Log (" 归档内路径:{0}({1});{2}" -f $target.ArchivePath, + $(if ($target.IsFile) { '文件' } else { '目录' }), + $(if ($targetExists) { '已存在,将覆盖同名文件' } else { '不存在,将新建' })) if ($target.Description) { Write-Log (" 介绍:{0}" -f $target.Description) } } @@ -446,13 +719,64 @@ foreach ($line in $lines) { $restoreFailed = $false try { foreach ($target in $plannedTargets) { - if (-not (Invoke-Extraction -ArchiveFile $archiveFile -DestinationPath $target.DestPath -RelativePath $target.RelativePath)) { + if (-not (Invoke-Extraction -ArchiveFile $archiveFile -Item $target -Password $password)) { $restoreFailed = $true break } } if (-not $restoreFailed) { + # ------------------------------------------------------------------ + # 安全描述符(属主 / ACL)回放 + # ------------------------------------------------------------------ + # 解压出来的对象:属主是"跑恢复脚本的进程"、DACL 是"从目标父目录继承 + 进程默认"。 + # 对 C:\ProgramData 这类目录是致命的 —— 那里的 (A;OICIIO;GA;;;CO) 靠 + # CREATOR OWNER 把全权给"对象的属主",属主一变,原程序就没了权限。 + # 必须在解压**之后**、对真实目标路径做(连接点在 Invoke-Extraction 里已经拆掉了)。 + if ($SkipSecurity) { + Write-Log '按 -SkipSecurity 跳过了安全描述符恢复' -Level DEBUG + } elseif (([string]$script:Config.Security.Mode) -eq 'Off') { + Write-Log '配置里 Security.Mode = Off,跳过安全描述符恢复' -Level DEBUG + } else { + $sidecarName = $null + if ($found.Record -and ($found.Record.PSObject.Properties.Name -contains 'security') -and $found.Record.security) { + $sidecarName = [string]$found.Record.security.file + } + if (-not $sidecarName) { $sidecarName = "$baseName.acl.json" } + + $sidecar = Read-BaknretSecuritySidecar -Path (Join-Path $BackupDir $sidecarName) + if (-not $sidecar) { + Write-Log ("这个归档没有安全描述符旁挂文件({0}):恢复出来的属主/ACL 是新建对象的默认值 —— 原程序若依赖特殊权限(ProgramData 下的 CREATOR OWNER 最典型),会报无读写权限" -f $sidecarName) -Level WARN + } else { + $sidMap = @{} + if ($script:Config.Security.SidMap) { $sidMap = $script:Config.Security.SidMap } + + $secTotal = 0; $secApplied = 0; $secOwnerFailed = 0; $secSkipped = 0; $secFailed = 0 + $secMessages = @() + foreach ($target in $plannedTargets) { + $sec = Restore-BaknretSecurity -Sidecar $sidecar -ArchiveRoot $target.ArchivePath ` + -TargetPath $target.DestPath -SidMap $sidMap + $secTotal += $sec.Total + $secApplied += $sec.Applied + $secOwnerFailed += $sec.OwnerFailed + $secSkipped += $sec.Skipped + $secFailed += $sec.Failed + $secMessages += @($sec.Failures) + } + + $securityApplied += $secApplied + Write-Log ("安全描述符:回放 {0}/{1} 个对象(属主/属组未恢复 {2},跳过 {3},失败 {4})" -f ` + $secApplied, $secTotal, $secOwnerFailed, $secSkipped, $secFailed) -Level INFO + foreach ($message in @($secMessages | Select-Object -First 5)) { + Write-Log (" ! {0}" -f $message) -Level WARN + } + if ($secFailed -gt 0) { + Write-Log ("恢复成功但安全描述符有 {0} 个对象失败,已计入失败条目(退出码 1)" -f $secFailed) -Level ERROR + $failures += $displayPath + } + } + } + $stats.restored++ Write-Log "恢复成功: $baseName" -Level INFO @@ -515,6 +839,7 @@ if ($failures.Count -gt 0) { } $summaryText = "恢复完成 - 成功:$($stats.restored),跳过:$($stats.skipped),失败:$($stats.failed)" +if ($securityApplied -gt 0) { $summaryText += ",安全描述符:$securityApplied 个对象" } if ($VerifyOnly) { $summaryText = "校验完成 - 通过:$($stats.verified),失败:$($stats.failed)" } if ($stats.planned -gt 0) { $summaryText += ",试运行计划:$($stats.planned)" } Write-Log $summaryText -Level INFO diff --git a/SoftwareCatalog.psd1 b/SoftwareCatalog.psd1 index b63b566..c37ebcb 100644 --- a/SoftwareCatalog.psd1 +++ b/SoftwareCatalog.psd1 @@ -1,157 +1,232 @@ <# - 软件名录:维护"软件名 -> 目录"的映射。 +.SYNOPSIS + 软件目录清单(SoftwareCatalog)。 - 有这个文件之后,BackupList.txt 里可以直接写软件名: +.DESCRIPTION + 定义每个软件在归档内的槽位(Slot)结构,供备份与恢复共用。 + 一个软件 = 一个归档(归档名就是软件名),包内的顶层目录就是这里定义的 Slot。 - FooClolor - Kazumi :: !*Cache - Edge :: !*Cache,component_crx_cache - .ssh @encrypt - - 归档包的名字也就是软件名(`FooClolor.7z`),不再是 - `FooClolor_from_C_+Programs.7z` 这种由路径拼出来的名字。 - - 写法: - - <软件名> = '<目录>' - - 软件名的限制: - * 必须是合法的文件名(不能含 \ / : * ? " < > |),因为它就是归档名; - * 不能含 `\` 或 `/` 或 `%`,否则会被当作字面路径而不是软件名; - * **含 `-` 或 `.` 的名字必须写成带引号的键**,否则 PowerShell 会把 - `a-b` 解析成减法表达式并报 "Missing '=' operator": - 'scoop-config' = '...' # 正确 - scoop-config = '...' # 报错 - * 建议用英文/数字,但中文也可以。 - - 目录可以写环境变量,例如 '%UserProfile%\.ssh'。 - - 两个便利特性: - - 1. 目录不存在时会按前缀补全:写 'D:\Programs\legendary',实际目录是 - 'D:\Programs\legendary_2.0.4',会自动匹配(只认 `<名>_*` 与 `<名>-*`, - 不会把 Legendary 误配成 LegendarySomething)。 - 2. **一个软件包含多个目录**时,写成**对象数组**(每个目录带自己的说明),全部打进同一个归档: - - scoop = @{ Dirs = @( - '%UserProfile%\scoop\persist' - 'C:\Programs\ScoopApps\persist' - '%UserProfile%\.config\scoop' - ) } - - 归档里每个目录仍是自己的名字与层级,恢复时会**只解出该目录自己那棵子树**, - 各自还原回原位,不会把兄弟目录也复制过去。 - 纯字符串数组、以及旧的 `@{ Dirs = @(...) }` / `@{ Variants = @(...) }` 写法继续可用。 - - 注意:归档内的顶层名就是目录自己的名字,所以**同一个软件里不能有两个同名目录** - (典型例子是两个都叫 persist 的目录)。那种情况脚本会明确报错并让你拆成两个条目, - 而不是把两棵树悄悄混在一起。 - - 分文件维护:用 Includes 引入其它名录文件(路径相对本文件): - - @{ - Includes = @('SoftwareCatalog.games.psd1') - ... + 结构: + SoftWareName = @{ + Slot = @{ + Path = 'Absolute\Path' + Exclude = 'Relative\Path' + Include = 'Relative\Path:Absolute\Path' + Encrypt = $false + Description = 'Some information about this slot.' + } } + +.NOTES + 字段说明: + SoftWareName 软件名称。不含空格,遵循驼峰大小写。 + Slot 插槽。归档内的一层目录:内容进 `\`; + Path 是文件时,存成名为 `` 的文件本身。 + 同一软件里不能有两个同名 Slot。规则同 SoftWareName。 + Path 路径。需备份或恢复的来源路径,为宿主机上的绝对路径。 + Exclude 排除。不需备份的目录,为压缩包内的相对路径。 + 多个以逗号分隔。相对于本 Slot 的根(即归档内的 `\`)。 + 以“!”打头即“任意层级匹配”(7z 的 -xr!,通配符 `*` / `?`); + 要按正则排除写成 `!re:<正则>`(脚本自己展开成精确路径)。 + Include 包含。需要追加的目录。 + 语法:<压缩包内相对路径>:<宿主机绝对路径>。 + 多个以逗号分隔。 + Encrypt 是否加密此归档。默认:$false。 + 同一个条目里各 Slot 不一致时,整个归档按加密处理。 + Description 描述。 #> + @{ - # 每个条目有两种写法: - # 1. 只写一个目录字符串: legendary = '%UserProfile%\.config\legendary' - # 2. 带目录介绍(推荐): - # legendary = @{ - # Path = '%UserProfile%\.config\legendary' - # Description = 'Legendary(Epic 的开源客户端)的配置与已安装记录' - # } - # 一个软件包含**多个目录**时,写成对象数组(见下面的 scoop)。 - # 运行时会把"这个条目打包哪些目录、每个目录是干什么的、排除了什么、为什么" - # 逐条打印出来,说明就来自这里。 - - # ---- 用户配置 / 开发环境 ---- - # 含 `-` 或 `.` 的键必须加引号,否则会被当成减法表达式(见文件开头说明) - legendary = @{ - Path = '%UserProfile%\.config\legendary' - Description = 'Legendary(Epic 的开源客户端)的配置与已安装记录' - } - opencode = @{ - Path = '%UserProfile%\.config\opencode' - Description = 'opencode 的配置' - } - - # 一个软件 = 一个归档;多个目录写成**对象数组**,每个目录各自带说明。 - # 注意:归档内的顶层名字就是**目录自己的名字**,所以同一个软件里不能有两个同名目录 - # (例如两个 persist)—— 那会在包里混成一棵树,脚本会明确报错让你拆成两个条目。 - scoop = @( - @{ - Path = '%UserProfile%\scoop\persist' - Description = 'scoop 里各应用的持久化数据(重装应用就会丢,必须备份)' + AutoDarkMode = @{ + DefaultData = @{ + Path = '%AppData%\AutoDarkMode' + Encrypt = $true + Description = 'AutoDarkMode 数据。' } - @{ + } + + DeepSeekHarness = @{ + DefaultData = @{ + Path = '%UserProfile%\.dsh' + Encrypt = $true + Description = 'DSH(深度求索)数据。' + } + } + + DSHDesktop = @{ + DefaultData = @{ + Path = '%AppData%\dsh-desktop' + Encrypt = $true + Description = 'DSH 桌面版数据。' + } + } + + MicrosoftEdge = @{ + DefaultData = @{ + Path = '%LocalAppData%\Microsoft\Edge\User Data' + Exclude = '!*Cache,!BrowserMetrics,!component_crx_cache,' + + '!Crashpad,!optimization_guide,!ProvenanceData,' + + 'Default\ExtensionActivityEdge,Default\Extensions,Default\Service Worker,' + + 'Snapshots,Edge Sidebar,Edge Shopping' + Encrypt = $true + Description = '微软 Edge 浏览器用户数据。 +保留:书签/密码/偏好/历史,以及站点数据(IndexedDB / Local Storage)。 +排除:缓存、组件缓存、Service Worker、扩展本体(可从商店重装)、遥测与优化数据。 +可选排除:Default\IndexedDB、Default\Local Storage、Default\Session Storage、Default\blob_storage、Default\WebStorage。 +注意:Edge 常驻时打包会有上百个文件读不到(含 Login Data / Cookies),脚本检测到警告后不会用这份不完整的归档覆盖已有的完整归档。备份前建议先退出 Edge。' + } + } + + FastNodeManager = @{ + DefaultData = @{ + Path = '%UserProfile%\fnm' + Encrypt = $true + Description = 'FNM(Node 版本管理器)数据。' + } + } + + INZONEHub = @{ + DefaultData = @{ + Path = '%AppData%\Sony\INZONE Hub' + Description = '索尼英纵数据。' + } + } + + Kazumi = @{ + DefaultData = @{ + Path = '%AppData%\com.example\Kazumi' + Encrypt = $true + Description = 'Kazumi 数据。' + } + } + + Legendary = @{ + DefaultConfig = @{ + Path = '%UserProfile%\.config\legendary' + Encrypt = $true + Description = 'Legendary(Epic 的开源客户端)的配置。' + } + } + + Mnemon = @{ + DefaultData = @{ + Path = '%UserProfile%\.mnemon' + Encrypt = $true + Description = 'Mnemon(LLM 智能体的持久记忆系统)数据。' + } + } + + Obsidian = @{ + DefaultData = @{ + Path = '%AppData%\obsidian' + Encrypt = $true + Description = 'Obsidian 数据。' + } + } + + OpenCode = @{ + DefaultConfig = @{ + Path = '%UserProfile%\.config\opencode' + Encrypt = $true + Description = 'OpenCode 的配置。' + } + } + + OpenSSH = @{ + DefaultData = @{ + Path = '%UserProfile%\.ssh' + Encrypt = $true + Description = 'SSH 私钥 / 公钥 / known_hosts 等文件。' + } + } + + PiliPlus = @{ + DefaultData = @{ + Path = '%AppData%\com.example\piliplus' + Encrypt = $true + Description = 'PiliPlus 数据。' + } + } + + PowerShell = @{ + DefaultData = @{ + Path = '%UserProfile%\Documents\PowerShell' + Encrypt = $true + Description = 'PowerShell 7 的用户配置与模块。' + } + } + + PowerToys = @{ + DefaultBackup = @{ + Path = '%UserProfile%\Documents\PowerToys\Backup' + Encrypt = $true + Description = 'PowerToys 备份。' + } + } + + Scoop = @{ + DefaultConfig = @{ Path = '%UserProfile%\.config\scoop' - Description = 'scoop 自身的配置(源、代理、已安装清单)' + Encrypt = $true + Description = 'Scoop 配置。' } - ) + GlobalPersist = @{ + Path = '$(if ($env:SCOOP_GLOBAL) { $env:SCOOP_GLOBAL } else { Join-Path $env:ProgramData "scoop" })\persist' + Encrypt = $true + Description = 'Scoop 里各全局应用的持久化数据。' + } + UserPersist = @{ + Path = '$(if ($env:SCOOP) { $env:SCOOP } else { Join-Path $env:USERPROFILE "scoop" })\persist' + Encrypt = $true + Description = 'Scoop 里各用户应用的持久化数据。' + } + } - '.ssh' = @{ - Path = '%UserProfile%\.ssh' - Description = 'SSH 私钥 / 公钥 / known_hosts(不可再生;要加密就给清单里那行加 @encrypt)' + Startup = @{ + GlobalLink = @{ + Path = '%ProgramData%\Microsoft\Windows\Start Menu\Programs\Startup' + Description = '全局开机启动项(快捷方式)。' + } + UserLink = @{ + Path = '%AppData%\Microsoft\Windows\Start Menu\Programs\Startup' + Description = '用户开机启动项(快捷方式)。' + } } - CodeSpace = @{ - Path = 'D:\UserData\Documents\CodeSpace' - Description = '开发代码目录' + + SteamRomManager = @{ + DefaultData = @{ + Path = '%AppData%\steam-rom-manager' + Description = 'Steam Rom Manager 数据。' + } } - PowerShell = @{ - Path = '%UserProfile%\Documents\PowerShell' - Description = 'PowerShell 7 的用户配置与模块' + + TranslucentTB = @{ + ScoopData = @{ + Path = '$(scoop prefix translucenttb)\settings.json' + Description = 'TranslucentTB 的设置文件(Scoop 安装)。' + } } + + TwinkleTray = @{ + DefaultData = @{ + Path = '%AppData%\twinkle-tray' + Description = 'Twinkle Tray 数据。' + } + } + WindowsPowerShell = @{ - Path = '%UserProfile%\Documents\WindowsPowerShell' - Description = 'Windows PowerShell 5.1 的用户配置与模块' + DefaultData = @{ + Path = '%UserProfile%\Documents\WindowsPowerShell' + Encrypt = $true + Description = 'Windows PowerShell 5.1 的用户配置与模块。' + } } - # ---- 应用数据 ---- - AutoDarkMode = @{ Path = '%AppData%\AutoDarkMode'; Description = 'AutoDarkMode 的主题/时间设置' } - Kazumi = @{ Path = '%AppData%\com.example\Kazumi'; Description = 'Kazumi 的观看记录与设置' } - piliplus = @{ Path = '%AppData%\com.example\piliplus'; Description = 'piliplus 的设置与账号数据' } - fnm = @{ Path = '%AppData%\fnm'; Description = 'fnm(Node 版本管理器)的版本记录' } - 'twinkle-tray' = @{ Path = '%AppData%\twinkle-tray'; Description = 'Twinkle Tray 的显示器亮度设置' } - - # ---- 浏览器与终端 ---- - # Edge 的缓存/扩展本体等可再生内容由 BackupList.txt 的 :- 排除规则挡掉 - Edge = @{ - Path = '%LocalAppData%\Microsoft\Edge\User Data' - Description = 'Edge 用户数据:书签、密码、Cookies、历史、站点数据' + WindowsTerminal = @{ + DefaultData = @{ + Path = '%LocalAppData%\Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json' + Encrypt = $true + Description = 'Windows Terminal 的设置文件。' + } } - WindowsTerminal = @{ - Path = '%LocalAppData%\Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json' - Description = 'Windows Terminal 的设置文件' - } - - # ---- 系统 ---- - Startup = @{ - Path = '%ProgramData%\Microsoft\Windows\Start Menu\Programs\Startup' - Description = '全局开机启动项(快捷方式)' - } - - # ---- C:\Programs ---- - BaiduNetdisk = @{ Path = 'C:\Programs\BaiduNetdisk'; Description = '百度网盘客户端' } - # FooClolor 的具体用途不明确,先不加介绍(没有 Description 也不会影响打包) - FooClolor = 'C:\Programs\FooClolor' - March7thAssistant = @{ - Path = 'C:\Programs\March7thAssistant' - Description = '三月七助手(WebBrowser 用户目录里的缓存由 BackupList.txt 排除)' - } - MiFlash = @{ Path = 'C:\Programs\MiFlash'; Description = '小米刷机工具 MiFlash' } - MiFlash_Unlock = @{ Path = 'C:\Programs\MiFlash_Unlock'; Description = '小米解锁工具' } - QuarkCloudDrive = @{ Path = 'C:\Programs\QuarkCloudDrive'; Description = '夸克网盘客户端' } - translucenttb = @{ - Path = 'C:\Programs\ScoopApps\apps\translucenttb\current\settings.json' - Description = 'TranslucentTB 的设置文件' - } - 'ScoopApps-persist' = @{ - Path = 'C:\Programs\ScoopApps\persist' - Description = 'ScoopApps 安装位置上那份 persist。它和 scoop 数组里的 %UserProfile%\scoop\persist 是两个不同目录、末级名却同为 persist,所以不能并进同一个归档' - } - - # ---- 其它盘 ---- - Aria = @{ Path = 'D:\UserData\Documents\Aria'; Description = 'Aria 下载器的配置与任务' } -} +} \ No newline at end of file diff --git a/docs/agents/domain.md b/docs/agents/domain.md new file mode 100644 index 0000000..a0e1c7d --- /dev/null +++ b/docs/agents/domain.md @@ -0,0 +1,46 @@ +# 领域文档 + +探索代码之前,工程技能应当怎么消费本仓库的领域文档。 + +## 探索之前先读 + +- 根目录的 **`CONTEXT.md`**;或者 +- 根目录的 **`CONTEXT-MAP.md`**(若存在):它指向每个上下文各一份 `CONTEXT.md`,只读与当前主题相关的那几份。 +- **`docs/adr/`**:读与你要动的区域相关的 ADR。 + +这些文件不存在就**静默继续**:不要提示缺失,也不要提议先建它们。 +`/domain-modeling`(经 `/grill-with-docs`、`/improve-codebase-architecture` 抵达) +会在术语或决策真正落地时按需创建。 + +## 文件结构 + +本仓库是**单上下文**: + +```text +/ +├── CONTEXT.md ← 术语表 / 领域模型(尚不存在,懒创建) +├── docs/adr/ ← 决策记录(尚不存在,懒创建) +│ └── 0001-....md +├── Common.psm1 ← 公共模块:日志、清单解析、名录、归档布局、安全描述符 +├── Backup.ps1 ← 备份入口 +├── Restore.ps1 ← 恢复入口 +├── BackupList.txt ← 唯一「要处理什么」的来源 +├── SoftwareCatalog.psd1 ← 软件名 → Slot 组 +├── BackupConfig.psd1 ← 目录、空间阈值、加密、安全描述符 +├── tests/ ← Pester、零依赖、端到端、真实归档恢复演练 +└── tools/ ← 计划任务注册、归档改名、tools\lab 的 Hyper-V 测试环境 +``` + +## 用词表里的词 + +输出里一旦出现领域概念(issue 标题、重构提案、假设、测试名),就用 `CONTEXT.md` +里定义的那个词,不要漂到它明确避开的同义词。 + +需要用的概念不在词表里,本身就是一个信号:要么你在发明项目不用的语言(重新想), +要么真的缺一条(记下来交给 `/domain-modeling`)。 + +## ADR 冲突要点名 + +如果你的输出与某条 ADR 矛盾,明确说出来,而不是悄悄覆盖: + +> 与 ADR-0007(事件溯源订单)冲突,但值得重开,因为…… diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md new file mode 100644 index 0000000..8871298 --- /dev/null +++ b/docs/agents/issue-tracker.md @@ -0,0 +1,33 @@ +# 议题追踪:本地 Markdown + +本仓库的 issue 与 spec 都是 `.scratch/` 下的 markdown 文件。没有远程追踪器,也没有 CLI 依赖。 + +## 约定 + +- 一个特性一个目录:`.scratch//` +- spec 是 `.scratch//spec.md` +- 实现类 issue **一个 ticket 一个文件**:`.scratch//issues/-.md`, + 从 `01` 编号,不要写成一个合并的 tickets 文件 +- 分诊状态记在每个 issue 文件靠近顶部的 `Status:` 行 +- 评论与对话历史追加到文件底部的 `## Comments` 标题下 + +## 当某个技能说「publish to the issue tracker」 + +在 `.scratch//` 下新建文件(需要就一并建目录)。 + +## 当某个技能说「fetch the relevant ticket」 + +读那个路径的文件。用户通常会直接给出路径或 issue 编号。 + +## Wayfinding(`/wayfinder` 用) + +**Map** 是一份文件,每个 ticket 对应一个 **child** 文件。 + +- **Map**:`.scratch//map.md`(Notes / Decisions-so-far / Fog 正文)。 +- **Child ticket**:`.scratch//issues/NN-.md`,从 `01` 开始,正文写问题本身; + `Type:` 行记类型(`research`/`prototype`/`grilling`/`task`),`Status:` 行记 `claimed`/`resolved`。 +- **Blocking**:靠近顶部写 `Blocked by: NN, NN`;列出的文件全部 `resolved` 才算解锁。 +- **Frontier**:扫 `.scratch//issues/`,取未关闭、未阻塞、未认领的,编号最小者优先。 +- **Claim**:动手前先写 `Status: claimed` 并保存。 +- **Resolve**:在 `## Answer` 标题下追加答案,写 `Status: resolved`, + 再把一段上下文指针(要点 + 链接)追加到 `map.md` 的 Decisions-so-far。 diff --git a/tests/BakNRet.Formats.Tests.ps1 b/tests/BakNRet.Formats.Tests.ps1 index a56fad4..2e102a9 100644 --- a/tests/BakNRet.Formats.Tests.ps1 +++ b/tests/BakNRet.Formats.Tests.ps1 @@ -1,15 +1,18 @@ <# .SYNOPSIS - 清单"两种写法 + 追加/排除"的 Pester 测试:软件名、手写路径,:+/:- 两者都要生效。 + 清单与名录的"格式契约"Pester 测试:软件名 / 手写路径两种写法, + Slot 形状的 SoftwareCatalog.psd1,以及 `::` / `:-` / `:+` / `:encrypt` / `@ Key='Value'`。 .DESCRIPTION - 这里覆盖的是清单/名录的**输入格式**契约: + 这里覆盖的是清单/名录的**输入格式与归档布局**契约(重构后的新契约): * 写法一:直接写 SoftwareCatalog.psd1 里的软件名; * 写法二:用户手写目录(含 \ / 或 % 就按路径处理); - * 两种写法都要支持 `:+` 追加与 `:-` 排除; - * 名录里一个软件可以挂**对象数组**(每个目录带 Description),运行时会逐条介绍; - * 同一条目里出现两个同名目录时,必须在归档前就明确报错(Blocking), - 而不是把两棵树悄悄混在一起。 + * 软件名条目 -> 一个归档,归档内是 `\<内容>`;Path 是文件时归档内是名为 + `` 的文件(没有扩展名); + * 手写路径条目 -> 历史布局 `<末级名>\...`,现有清单不需要改写; + * `::` 覆盖 Path(不再是 `:-` 的别名),排除一律写 `:-`; + * `:+` / `@ Include=` 是 `<归档内相对路径>:<宿主机绝对路径>`; + * 同一条目里两个归档项抢同一个包内位置时,必须在归档前就明确报错(Blocking)。 跟 BakNRet.Tests.ps1 一样,脚本调用统一走**子进程**:Backup.ps1 / Restore.ps1 结尾会 `exit`,同进程 `&` 调用会把 Pester 宿主一起带走。 @@ -32,6 +35,8 @@ BeforeAll { $script:Sandbox = Join-Path $env:TEMP ('baknret-formats-' + [guid]::NewGuid().ToString('N').Substring(0, 8)) New-Item -ItemType Directory -Path $script:Sandbox -Force | Out-Null + # 见 BakNRet.Tests.ps1 里的说明:本机沙箱禁止 PowerShell 为捕获原生子进程输出建管道, + # 所以走"临时 .cmd + 文件重定向 + Invoke-ExternalCommand(继承 stdio)"这条路。 function Invoke-BaknretScript { param( [Parameter(Mandatory = $true)][string]$Script, @@ -49,9 +54,24 @@ BeforeAll { if ($value -is [array]) { $arguments += $value } else { $arguments += [string]$value } } - $lines = & pwsh @arguments 2>&1 + $outFile = Join-Path ([System.IO.Path]::GetTempPath()) ("bnr-out-" + [guid]::NewGuid().ToString('N') + '.txt') + $cmdFile = Join-Path ([System.IO.Path]::GetTempPath()) ("bnr-cmd-" + [guid]::NewGuid().ToString('N') + '.cmd') + $argString = (@($arguments | ForEach-Object { '"' + ($_ -replace '"', '\"') + '"' }) -join ' ') + $batch = "@echo off`r`n" + 'pwsh ' + $argString + ' > "' + $outFile + '" 2>&1' + "`r`nexit /b %ERRORLEVEL%`r`n" + [System.IO.File]::WriteAllText($cmdFile, $batch, [System.Text.UTF8Encoding]::new($false)) + + $exitCode = $null + $lines = @() + try { + $exitCode = Invoke-ExternalCommand -FilePath 'cmd.exe' -ArgumentList @('/c', $cmdFile) + $lines = @(Get-Content -LiteralPath $outFile -Encoding UTF8 -ErrorAction SilentlyContinue) + } finally { + Remove-Item -LiteralPath $outFile -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $cmdFile -Force -ErrorAction SilentlyContinue + } + return [pscustomobject]@{ - ExitCode = $LASTEXITCODE + ExitCode = $exitCode Lines = @($lines | ForEach-Object { [string]$_ }) Output = (($lines | Out-String)) } @@ -71,7 +91,7 @@ AfterAll { } # ============================================================================ -Describe '软件名录:对象数组写法' { +Describe '软件名录:Slot 形状(新契约)' { # ============================================================================ BeforeAll { @@ -84,249 +104,406 @@ Describe '软件名录:对象数组写法' { New-Item -ItemType Directory -Path $directory -Force | Out-Null Set-Content -LiteralPath (Join-Path $directory 'keep.txt') "keep-$directory" } + New-Item -ItemType Directory -Path (Join-Path $script:DirA 'Cache') -Force | Out-Null + Set-Content -LiteralPath (Join-Path $script:DirA 'Cache\c.bin') 'cache' + $script:CfgFile = Join-Path $script:FormatRoot 'settings.json' + Set-Content -LiteralPath $script:CfgFile '{"a":1}' - # 两个不同父目录下各有一个**同名**子目录 —— 用来看"归档内同名"有没有被拦住 + # 两个不同父目录下各有一个**同名**子目录 —— 供"归档内同名"冲突测试用 $script:CollideRoot = Join-Path $script:FormatRoot 'collide' foreach ($parent in 'p1', 'p2') { New-Item -ItemType Directory -Path (Join-Path $script:CollideRoot "$parent\dupdir") -Force | Out-Null Set-Content -LiteralPath (Join-Path $script:CollideRoot "$parent\dupdir\x.txt") $parent } - $dirAPath = $script:DirA - $dirBPath = $script:DirB - $collideP1 = Join-Path $script:CollideRoot 'p1\dupdir' - $collideP2 = Join-Path $script:CollideRoot 'p2\dupdir' + $script:MissingDir = Join-Path $script:FormatRoot 'not-here' $script:FormatCatalog = Write-ListFile -Path (Join-Path $script:FormatRoot 'cat.psd1') -Content @" @{ - 'pair' = @( - @{ Path = '$dirAPath'; Description = '第一个目录' } - @{ Path = '$dirBPath'; Description = '第二个目录' } - ) - 'collide' = @( - @{ Path = '$collideP1'; Description = 'p1 里的' } - @{ Path = '$collideP2'; Description = 'p2 里的' } - ) -} -"@ - - $script:MissingDir = Join-Path $script:FormatRoot 'not-here' - $missingPath = $script:MissingDir - $script:PartialCatalog = Write-ListFile -Path (Join-Path $script:FormatRoot 'cat-partial.psd1') -Content @" -@{ - 'pair' = @( - @{ Path = '$dirAPath'; Description = '存在' } - @{ Path = '$missingPath'; Description = '不存在' } - ) + 'pair' = @{ + A = @{ Path = '$script:DirA'; Description = '第一个 Slot' } + B = @{ Path = '$script:DirB'; Description = '第二个 Slot' } + } + 'solo' = @{ Only = @{ Path = '$script:DirA' } } + 'partial' = @{ Ok = @{ Path = '$script:DirA' }; Gone = @{ Path = '$script:MissingDir' } } + 'slotex' = @{ Data = @{ Path = '$script:DirA'; Exclude = '!*Cache,logs\' } } + 'fileapp' = @{ Cfg = @{ Path = '$script:CfgFile'; Encrypt = `$true } } + 'conflict' = @{ Data = @{ Path = '$script:DirA' } } + 'legacyarr' = @('$script:DirA', '$script:DirB') + 'legacydirs' = @{ Dirs = @('$script:DirA', '$script:DirB') } + 'legacystr' = '$script:DirA' } "@ } - It '一个软件多个目录:顺序与说明都被保留' { - $catalog = Get-SoftwareCatalog -Path $script:FormatCatalog -MaxDepth 3 + It '一个软件多个 Slot:Kind=Multi,Slot 按名排序且说明被保留' { + $catalog = Get-SoftwareCatalog -Path $script:FormatCatalog -MaxDepth 3 -NoCache $catalog['pair'].Kind | Should -Be 'Multi' - @($catalog['pair'].Items).Count | Should -Be 2 - $catalog['pair'].Items[0].Resolved | Should -Be $script:DirA - $catalog['pair'].Items[0].Description | Should -Be '第一个目录' - $catalog['pair'].Items[1].Description | Should -Be '第二个目录' + @($catalog['pair'].Slots).Count | Should -Be 2 + (@($catalog['pair'].Slots | ForEach-Object { $_.Name }) -join ',') | Should -Be 'A,B' + $catalog['pair'].Slots[0].Description | Should -Be '第一个 Slot' + $catalog['pair'].Slots[1].Description | Should -Be '第二个 Slot' + $catalog['pair'].Slots[0].Resolved | Should -Be $script:DirA + $catalog['pair'].Slots[1].Resolved | Should -Be $script:DirB } - It '解析成多个源,每个源带着自己的说明' { + It '每个 Slot 都是一个独立的归档项来源(Kind=slot / Origin=catalog)' { $entry = ConvertFrom-BackupListLine -Line 'pair' $resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3 - @($resolved.Sources).Count | Should -Be 2 - $resolved.Sources[0].SourcePath | Should -Be $script:DirA - $resolved.Sources[0].Description | Should -Be '第一个目录' - $resolved.Sources[1].Description | Should -Be '第二个目录' - @($resolved.Sources | ForEach-Object { $_.Origin }) | Should -Be @('catalog', 'catalog') + @($resolved.Items).Count | Should -Be 2 + (@($resolved.Items | ForEach-Object { $_.ArchivePath }) -join ',') | Should -Be 'A,B' + (@($resolved.Items | ForEach-Object { $_.Kind }) -join ',') | Should -Be 'slot,slot' + (@($resolved.Items | ForEach-Object { $_.Origin }) -join ',') | Should -Be 'catalog,catalog' + $resolved.Items[0].RealPath | Should -Be $script:DirA + $resolved.Items[0].Description | Should -Be '第一个 Slot' + $resolved.Items[1].Description | Should -Be '第二个 Slot' } - It '数组里"当前不存在"的目录仍然产出源(恢复要靠它还原回原位)' { - $entry = ConvertFrom-BackupListLine -Line 'pair' - $resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:PartialCatalog -MaxDepth 3 - @($resolved.Sources).Count | Should -Be 2 - @($resolved.Sources | ForEach-Object { $_.SourcePath }) | Should -Contain $script:MissingDir - $resolved.Error | Should -Not -BeNullOrEmpty # 有提示 - $resolved.Blocking | Should -BeNullOrEmpty # 但不算致命 + It 'Slot 级排除写在 Slot 自己身上(相对本 Slot 的归档根)' { + $catalog = Get-SoftwareCatalog -Path $script:FormatCatalog -MaxDepth 3 -NoCache + (@($catalog['slotex'].Slots[0].Exclude) -join '|') | Should -Be '!*Cache|logs\' + + $entry = ConvertFrom-BackupListLine -Line 'slotex' + $resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3 + (@($resolved.Items[0].Exclude) -join '|') | Should -Be '!*Cache|logs\' + $resolved.HasExcludeOverride | Should -BeFalse } - It '纯字符串数组写法继续可用' { - $plain = Write-ListFile -Path (Join-Path $script:FormatRoot 'cat-plain.psd1') -Content "@{ 'pair2' = @('$script:DirA', '$script:DirB') }" - $catalog = Get-SoftwareCatalog -Path $plain -MaxDepth 3 - $catalog['pair2'].Kind | Should -Be 'Multi' - @($catalog['pair2'].Dirs).Count | Should -Be 2 + It '数组里"当前不存在"的 Slot 仍然产出归档项(恢复要靠它还原回原位)' { + $catalog = Get-SoftwareCatalog -Path $script:FormatCatalog -MaxDepth 3 -NoCache + $catalog['partial'].Kind | Should -Be 'Partial' + @($catalog['partial'].Missing) | Should -Contain $script:MissingDir + + $entry = ConvertFrom-BackupListLine -Line 'partial' + $resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3 + @($resolved.Items).Count | Should -Be 2 + @($resolved.Items | ForEach-Object { $_.RealPath }) | Should -Contain $script:MissingDir + $resolved.Blocking | Should -BeNullOrEmpty # 源不存在不是致命错误 } - It '旧的 @{ Dirs = @(...) } 写法继续可用' { - $legacy = Write-ListFile -Path (Join-Path $script:FormatRoot 'cat-legacy.psd1') -Content "@{ 'pair3' = @{ Dirs = @('$script:DirA', '$script:DirB') } }" - $catalog = Get-SoftwareCatalog -Path $legacy -MaxDepth 3 - $catalog['pair3'].Kind | Should -Be 'Multi' - @($catalog['pair3'].Dirs).Count | Should -Be 2 + It '文件 Slot:归档项是文件项(归档里就是名为 Slot 的文件)' { + $entry = ConvertFrom-BackupListLine -Line 'fileapp' + $resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3 + @($resolved.Items).Count | Should -Be 1 + $resolved.Items[0].IsFile | Should -BeTrue + $resolved.Items[0].ArchivePath | Should -Be 'Cfg' + $resolved.Items[0].RealPath | Should -Be $script:CfgFile + $resolved.Encrypt | Should -BeTrue } - It '数组形式的名录条目在清单里仍然按软件名命名归档' { + It '旧的裸字符串 / 字符串数组写法被拒绝(ERROR + 跳过)' { + $catalog = Get-SoftwareCatalog -Path $script:FormatCatalog -MaxDepth 3 -NoCache + $catalog.ContainsKey('legacystr') | Should -BeFalse + $catalog.ContainsKey('legacyarr') | Should -BeFalse + } + + It '旧的 @{ Dirs = @(...) } 写法不再展开,留下 Invalid 与原因' { + $catalog = Get-SoftwareCatalog -Path $script:FormatCatalog -MaxDepth 3 -NoCache + $catalog.ContainsKey('legacydirs') | Should -BeTrue + $catalog['legacydirs'].Kind | Should -Be 'Invalid' + $catalog['legacydirs'].Error | Should -Not -BeNullOrEmpty + @($catalog['legacydirs'].Slots).Count | Should -Be 0 + } + + It '多 Slot 的名录条目在清单里仍然按软件名命名归档' { $entry = ConvertFrom-BackupListLine -Line 'pair' (Get-ItemArchiveName -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3) | Should -Be 'pair' } } # ============================================================================ -Describe '两种写法都要支持 :+ 追加与 :- 排除' { +Describe '清单修饰符:新契约格式' { # ============================================================================ BeforeAll { $script:FormatRoot = Join-Path $script:Sandbox 'format' + $script:FormatCatalog = Join-Path $script:FormatRoot 'cat.psd1' $script:DirA = Join-Path $script:FormatRoot 'dirA' $script:DirB = Join-Path $script:FormatRoot 'dirB' - $script:FormatCatalog = Join-Path $script:FormatRoot 'cat.psd1' $script:CollideRoot = Join-Path $script:FormatRoot 'collide' } - It '软件名写法::+ 追加一个目录' { - $entry = ConvertFrom-BackupListLine -Line "pair :+ $script:CollideRoot" + It ':: 覆盖 Path:单 Slot 条目直接生效' { + $entry = ConvertFrom-BackupListLine -Line "solo :: $script:DirB" + $entry.Overrides.ContainsKey('Path') | Should -BeTrue $resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3 - @($resolved.Sources).Count | Should -Be 3 - @($resolved.Sources | ForEach-Object { $_.Origin }) | Should -Contain 'append-path' - } - - It '软件名写法::+ 追加"另一个软件名"会按名录展开成它的全部目录' { - $entry = ConvertFrom-BackupListLine -Line 'pair :+ pair' - $resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3 - @($resolved.Sources).Count | Should -Be 4 - @($resolved.Sources | ForEach-Object { $_.Origin }) | Should -Contain 'append-catalog' - } - - # ---- 回归:手写路径的 :+ 以前会被整段丢掉 ---- - It '[回归] 手写路径写法::+ 追加一个目录' { - $entry = ConvertFrom-BackupListLine -Line "$script:DirA :+ $script:DirB" - $resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3 - @($resolved.Sources).Count | Should -Be 2 - @($resolved.Sources | ForEach-Object { $_.SourcePath }) | Should -Contain $script:DirB - @($resolved.Sources | ForEach-Object { $_.Origin }) | Should -Contain 'append-path' - } - - It '手写路径写法::- 排除与 :+ 追加并存' { - $entry = ConvertFrom-BackupListLine -Line "$script:DirA :+ $script:DirB :- skip.log,!*Cache" - $entry.ExcludePatterns.Count | Should -Be 2 - $resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3 - @($resolved.Sources).Count | Should -Be 2 + @($resolved.Items).Count | Should -Be 1 + $resolved.Items[0].ArchivePath | Should -Be 'Only' + $resolved.Items[0].RealPath | Should -Be $script:DirB $resolved.Blocking | Should -BeNullOrEmpty } - It '软件名与手写路径混在一行也认得(主目录是软件名,追加是路径)' { - $entry = ConvertFrom-BackupListLine -Line "pair :+ $script:CollideRoot :- logs\" + It ':: 覆盖遇到多 Slot 条目 -> Blocking(不知道给哪一个,绝不猜)' { + $entry = ConvertFrom-BackupListLine -Line "pair :: $script:DirB" $resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3 - $resolved.IsName | Should -BeTrue - @($resolved.Sources).Count | Should -Be 3 - $entry.ExcludePatterns | Should -Be @('logs\') + @($resolved.Items).Count | Should -Be 0 + $resolved.Blocking | Should -Match '不能用一个' } - It '同一条目里出现两个同名目录 -> Blocking(明确报错,不静默混成一棵树)' { - $entry = ConvertFrom-BackupListLine -Line 'collide' + It ':- 排除与 :+ 包含并存,顺序任意' { + $entry = ConvertFrom-BackupListLine -Line "pair :- logs\,!*Cache :+ Mods:$script:DirB" + (@($entry.ExcludePatterns) -join '|') | Should -Be 'logs\|!*Cache' + (@($entry.Includes) -join '|') | Should -Be "Mods:$script:DirB" + $resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3 - @($resolved.Sources).Count | Should -Be 2 - $resolved.Blocking | Should -Match '顶层同名' + $resolved.HasExcludeOverride | Should -BeTrue + $resolved.HasIncludeOverride | Should -BeTrue + @($resolved.Items | ForEach-Object { $_.ArchivePath }) | Should -Contain 'Mods' + $resolved.Blocking | Should -BeNullOrEmpty + } + + It '@ Exclude / @ Include / @ Path 与记号写法等价' { + $marks = ConvertFrom-BackupListLine -Line "pair :- logs\ :+ Mods:$script:DirB" + $ats = ConvertFrom-BackupListLine -Line "pair @ Exclude='logs\' @ Include='Mods:$script:DirB'" + (@($marks.ExcludePatterns) -join '|') | Should -Be (@($ats.ExcludePatterns) -join '|') + (@($marks.Includes) -join '|') | Should -Be (@($ats.Includes) -join '|') + } + + It ':encrypt / :!encrypt 覆盖名录里的加密默认值' { + $base = Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line 'fileapp') -CatalogPath $script:FormatCatalog -MaxDepth 3 + $base.Encrypt | Should -BeTrue # 名录里 Cfg Slot 标了 Encrypt + + $off = Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line 'fileapp :!encrypt') -CatalogPath $script:FormatCatalog -MaxDepth 3 + $off.Encrypt | Should -BeFalse + + $on = Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line 'pair :encrypt') -CatalogPath $script:FormatCatalog -MaxDepth 3 + $on.Encrypt | Should -BeTrue + } + + It '遗留写法 @encrypt / @pathname / @root= 仍可解析' { + (ConvertFrom-BackupListLine -Line 'pair @encrypt').Overrides['Encrypt'] | Should -BeTrue + (ConvertFrom-BackupListLine -Line 'pair @pathname').Flags | Should -Contain 'pathname' + (ConvertFrom-BackupListLine -Line 'pair @root=Bar').Flags | Should -Contain 'root=Bar' + + # @pathname 对软件名条目也会改用真实路径命名 + $entry = ConvertFrom-BackupListLine -Line 'pair @pathname' + $expected = Get-BackupBaseName -RawPath $script:DirA + (Get-ItemArchiveName -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3) | Should -Be $expected + } + + It '同一行里重复写同类记号会累积(不静默丢掉前一条规则)' { + # `:+ a :+ b` 与 `:+ a,b` 等价:两条规则都生效。 + # 静默丢掉前一条排除/追加规则是这工具最不该犯的错,所以这里是"累加"语义。 + $entry = ConvertFrom-BackupListLine -Line "pair :+ Mods:$script:DirB,More:$script:DirA" + (@($entry.Includes) -join '|') | Should -Be "Mods:$script:DirB|More:$script:DirA" + + $repeated = ConvertFrom-BackupListLine -Line "pair :+ Mods:$script:DirB :+ More:$script:DirA" + (@($repeated.Includes) -join '|') | Should -Be "Mods:$script:DirB|More:$script:DirA" + + $excludes = ConvertFrom-BackupListLine -Line 'pair :- logs\ :- !*Cache :- temp\' + (@($excludes.ExcludePatterns) -join '|') | Should -Be 'logs\|!*Cache|temp\' + + # @ Exclude= 与 :- 也是累加关系 + $mixed = ConvertFrom-BackupListLine -Line "pair @ Exclude='a' :- b" + (@($mixed.ExcludePatterns) -join '|') | Should -Be 'a|b' + } + + It '行尾说明与缺少目标的行' { + $entry = ConvertFrom-BackupListLine -Line 'pair :- logs\ # 日志可再生' + $entry.Comment | Should -Be '日志可再生' + (@($entry.ExcludePatterns) -join '|') | Should -Be 'logs\' + + ConvertFrom-BackupListLine -Line ':- logs\' | Should -BeNullOrEmpty } } # ============================================================================ -Describe '清单行尾的 `# 说明`' { -# ============================================================================ - - It '会作为这条目的说明解析出来' { - $entry = ConvertFrom-BackupListLine -Line 'Edge :- !*Cache # 缓存可再生' - $entry.Path | Should -Be 'Edge' - $entry.ExcludePatterns | Should -Be @('!*Cache') - $entry.Comment | Should -Be '缓存可再生' - } - - It '路径里紧贴的 # 不会被当成注释' { - $entry = ConvertFrom-BackupListLine -Line 'C:\a#b\c' - $entry.Path | Should -Be 'C:\a#b\c' - $entry.Comment | Should -BeNullOrEmpty - } - - It '没有说明时 Comment 为空' { - ConvertFrom-BackupListLine -Line 'legendary' | Select-Object -ExpandProperty Comment | Should -BeNullOrEmpty - } -} - -# ============================================================================ -Describe '集成:手写路径 + :+ 追加 的打包与恢复' -Skip:(-not ($script:HasSevenZip -and $script:HasPwsh)) { +Describe '集成:Slot 布局的打包与恢复' -Skip:(-not ($script:HasSevenZip -and $script:HasPwsh)) { # ============================================================================ BeforeAll { - $script:AppendRoot = Join-Path $script:Sandbox 'append-e2e' - # 刻意放在**两个不同的父目录**下:只有这样才能验证 - # "恢复时不会把兄弟目录也复制过去" - $script:AppendA = Join-Path $script:AppendRoot 'srcA\dirA' - $script:AppendB = Join-Path $script:AppendRoot 'srcB\dirB' - foreach ($directory in $script:AppendA, $script:AppendB) { - New-Item -ItemType Directory -Path $directory -Force | Out-Null - } - Set-Content -LiteralPath (Join-Path $script:AppendA 'a.txt') 'A' - Set-Content -LiteralPath (Join-Path $script:AppendB 'b.txt') 'B' - Set-Content -LiteralPath (Join-Path $script:AppendA 'skip.log') 'S' + $script:SlotRoot = Join-Path $script:Sandbox 'slots-e2e' + $script:SlotAppOne = Join-Path $script:SlotRoot 'apps\AppOne' + $script:SlotAppTwo = Join-Path $script:SlotRoot 'apps\AppTwo' + $script:SlotCfgDir = Join-Path $script:SlotRoot 'apps\AppCfg' + $script:SlotInclude = Join-Path $script:SlotRoot 'psmodules' - $script:AppendList = Write-ListFile -Path (Join-Path $script:AppendRoot 'list.txt') ` - -Content "$script:AppendA :+ $script:AppendB :- skip.log`n" - $script:AppendBackupDir = Join-Path $script:AppendRoot 'Backups' + New-Item -ItemType Directory -Path (Join-Path $script:SlotAppOne 'Cache') -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $script:SlotAppOne 'sub') -Force | Out-Null + New-Item -ItemType Directory -Path $script:SlotAppTwo -Force | Out-Null + New-Item -ItemType Directory -Path $script:SlotCfgDir -Force | Out-Null + New-Item -ItemType Directory -Path $script:SlotInclude -Force | Out-Null - $script:AppendBackupRun = Invoke-BaknretScript -Script $script:BackupScript -Parameters @{ - BackupListPath = $script:AppendList - BackupDir = $script:AppendBackupDir + Set-Content -LiteralPath (Join-Path $script:SlotAppOne 'one.txt') 'one' + Set-Content -LiteralPath (Join-Path $script:SlotAppOne 'sub\deep.txt') 'deep' + Set-Content -LiteralPath (Join-Path $script:SlotAppOne 'Cache\c.bin') 'cache' + Set-Content -LiteralPath (Join-Path $script:SlotAppTwo 'two.txt') 'two' + Set-Content -LiteralPath (Join-Path $script:SlotCfgDir 'settings.json') '{"a":1}' + Set-Content -LiteralPath (Join-Path $script:SlotInclude 'mod.txt') 'mod' + + $appOne = $script:SlotAppOne + $appTwo = $script:SlotAppTwo + $cfgFile = Join-Path $script:SlotCfgDir 'settings.json' + $includeDir = $script:SlotInclude + + $script:SlotCatalog = Write-ListFile -Path (Join-Path $script:SlotRoot 'cat.psd1') -Content @" +@{ + 'appkit' = @{ + Cfg = @{ Path = '$cfgFile' } + Data = @{ Path = '$appOne'; Exclude = '!*Cache' } + Extra = @{ Path = '$appTwo'; Include = 'Modules:$includeDir' } + } +} +"@ + $script:SlotConfig = Write-ListFile -Path (Join-Path $script:SlotRoot 'config.psd1') -Content "@{ SoftwareCatalog = '$script:SlotCatalog' }" + $script:SlotList = Write-ListFile -Path (Join-Path $script:SlotRoot 'list.txt') -Content "appkit`n" + $script:SlotBackupDir = Join-Path $script:SlotRoot 'Backups' + + $script:SlotBackupRun = Invoke-BaknretScript -Script $script:BackupScript -Parameters @{ + BackupListPath = $script:SlotList + BackupDir = $script:SlotBackupDir + ConfigPath = $script:SlotConfig Force = $true QuietTool = $true } + $script:SlotManifest = Read-BaknretManifest -Path (Join-Path $script:SlotBackupDir 'manifest.json') + $script:SlotArchive = @(Get-ChildItem -LiteralPath $script:SlotBackupDir -File -Filter *.7z)[0] } - It '备份前会打印空间预估与"够不够"的结论' { - $script:AppendBackupRun.Output | Should -Match '备份前空间预估' - $script:AppendBackupRun.Output | Should -Match '要重打' - $script:AppendBackupRun.Output | Should -Match '结论:' + It '备份退出码 0,归档名就是软件名' { + $script:SlotBackupRun.ExitCode | Should -Be 0 + $script:SlotArchive.BaseName | Should -Be 'appkit' } - It '备份成功,manifest.roots 记录两棵子树' { - $script:AppendBackupRun.ExitCode | Should -Be 0 - $archive = @(Get-ChildItem -LiteralPath $script:AppendBackupDir -File -Filter *.7z)[0] - $record = (Read-BaknretManifest -Path (Join-Path $script:AppendBackupDir 'manifest.json')).items[$archive.BaseName] - $record.roots | Should -Contain 'dirA' - $record.roots | Should -Contain 'dirB' + It '归档顶层就是各个 Slot 名(目录 Slot + 文件 Slot + Include 项)' { + $top = @(Get-ArchiveTopLevelNames -ArchivePath $script:SlotArchive.FullName -SevenZip $script:SevenZip) + (@($top | Sort-Object) -join ',') | Should -Be 'Cfg,Data,Extra,Modules' } - It '归档里两棵树都在,且 :- 排除生效' { - $verify = Join-Path $script:AppendRoot 'verify' + It 'manifest.layouts 记下每个归档项是目录还是文件' { + $record = $script:SlotManifest.items['appkit'] + $record.action | Should -Be 'backed-up' + $record.roots | Should -Contain 'Data' + (@($record.layouts | ForEach-Object { $_.name + ':' + $_.kind }) -join ',') | Should -Be 'Cfg:file,Data:dir,Extra:dir,Modules:dir' + } + + It '归档内容:\<内容> 布局,文件 Slot 是名为 Slot 的文件,Slot 排除生效' { + $verify = Join-Path $script:SlotRoot 'verify' New-Item -ItemType Directory -Path $verify -Force | Out-Null - $archive = @(Get-ChildItem -LiteralPath $script:AppendBackupDir -File -Filter *.7z)[0] - (Invoke-ExternalCommand -FilePath $script:SevenZip -ArgumentList @('x', '-bso0', '-bsp0', '-y', "-o$verify", $archive.FullName)) | Should -Be 0 + (Invoke-ExternalCommand -FilePath $script:SevenZip -ArgumentList @('x', '-bso0', '-bsp0', '-y', "-o$verify", $script:SlotArchive.FullName)) | Should -Be 0 - Test-Path -LiteralPath (Join-Path $verify 'dirA\a.txt') | Should -BeTrue - Test-Path -LiteralPath (Join-Path $verify 'dirB\b.txt') | Should -BeTrue - Test-Path -LiteralPath (Join-Path $verify 'dirA\skip.log') | Should -BeFalse + Test-Path -LiteralPath (Join-Path $verify 'Data\one.txt') | Should -BeTrue + Test-Path -LiteralPath (Join-Path $verify 'Data\sub\deep.txt') | Should -BeTrue + Test-Path -LiteralPath (Join-Path $verify 'Data\Cache\c.bin') | Should -BeFalse + Test-Path -LiteralPath (Join-Path $verify 'Extra\two.txt') | Should -BeTrue + Test-Path -LiteralPath (Join-Path $verify 'Modules\mod.txt') | Should -BeTrue + Test-Path -LiteralPath (Join-Path $verify 'Cfg') -PathType Leaf | Should -BeTrue + (Get-Content -LiteralPath (Join-Path $verify 'Cfg') -Raw).Trim() | Should -Be '{"a":1}' } - It '删源后恢复:每个目录只落回自己的父目录,兄弟目录不会被复制过去' { - Remove-Item -LiteralPath $script:AppendA -Recurse -Force - Remove-Item -LiteralPath $script:AppendB -Recurse -Force + It '真实恢复:目录 Slot、文件 Slot 与 Include 都落回各自的原位' { + Remove-Item -LiteralPath (Join-Path $script:SlotRoot 'apps') -Recurse -Force + Remove-Item -LiteralPath $script:SlotInclude -Recurse -Force $run = Invoke-BaknretScript -Script $script:RestoreScript -Parameters @{ - BackupListPath = $script:AppendList - BackupDir = $script:AppendBackupDir + BackupListPath = $script:SlotList + BackupDir = $script:SlotBackupDir + ConfigPath = $script:SlotConfig Force = $true } $run.ExitCode | Should -Be 0 + $run.Output | Should -Match '恢复成功: appkit' - Test-Path -LiteralPath (Join-Path $script:AppendA 'a.txt') | Should -BeTrue - Test-Path -LiteralPath (Join-Path $script:AppendB 'b.txt') | Should -BeTrue + (Get-Content -LiteralPath (Join-Path $script:SlotAppOne 'one.txt') -Raw).Trim() | Should -Be 'one' + (Get-Content -LiteralPath (Join-Path $script:SlotAppOne 'sub\deep.txt') -Raw).Trim() | Should -Be 'deep' + (Get-Content -LiteralPath (Join-Path $script:SlotAppTwo 'two.txt') -Raw).Trim() | Should -Be 'two' + (Get-Content -LiteralPath (Join-Path $script:SlotInclude 'mod.txt') -Raw).Trim() | Should -Be 'mod' - # 关键:srcA 下不该冒出 dirB,srcB 下也不该冒出 dirA - Test-Path -LiteralPath (Join-Path $script:AppendRoot 'srcA\dirB') | Should -BeFalse - Test-Path -LiteralPath (Join-Path $script:AppendRoot 'srcB\dirA') | Should -BeFalse + # 被 Slot 排除的缓存没有进过归档,自然也不会被恢复出来 + Test-Path -LiteralPath (Join-Path $script:SlotAppOne 'Cache\c.bin') | Should -BeFalse + } + + It '文件 Slot 在目标不存在时靠 manifest.layouts 恢复成文件(而不是目录)' { + # 目标文件被删掉了,名录解析只能得到 IsFile=false;判据要靠 manifest 的 layouts。 + $restored = Join-Path $script:SlotCfgDir 'settings.json' + (Test-Path -LiteralPath $restored -PathType Leaf) | Should -BeTrue + (Get-Content -LiteralPath $restored -Raw).Trim() | Should -Be '{"a":1}' + } + + It '恢复之后 manifest 记下 lastRestoreAt' { + $manifest = Read-BaknretManifest -Path (Join-Path $script:SlotBackupDir 'manifest.json') + $manifest.items['appkit'].lastRestoreAt | Should -Not -BeNullOrEmpty } } # ============================================================================ -Describe '集成:同一条目里两个同名目录会被拒绝执行' -Skip:(-not ($script:HasSevenZip -and $script:HasPwsh)) { +Describe '集成:旧布局归档的回退恢复' -Skip:(-not ($script:HasSevenZip -and $script:HasPwsh)) { +# ============================================================================ + + # Slot 布局是重构后才有的,Backups\ 里还躺着按旧布局(包内直接是 <源目录名>\...) + # 生成的归档。恢复这类归档时必须回退到"把 <目标末级名> 解到目标父目录"的旧语义。 + + BeforeAll { + $script:LegacyRoot = Join-Path $script:Sandbox 'legacy-layout' + $script:LegacyHolder = Join-Path $script:LegacyRoot 'holder' + $script:LegacyDestParent = Join-Path $script:LegacyRoot 'dest' + $script:LegacyLeaf = 'My Code Space' + New-Item -ItemType Directory -Path (Join-Path $script:LegacyHolder $script:LegacyLeaf) -Force | Out-Null + New-Item -ItemType Directory -Path $script:LegacyDestParent -Force | Out-Null + Set-Content -LiteralPath (Join-Path $script:LegacyHolder "$script:LegacyLeaf\legacy.txt") 'old-layout' + + $destPath = Join-Path $script:LegacyDestParent $script:LegacyLeaf + $script:LegacyCatalog = Write-ListFile -Path (Join-Path $script:LegacyRoot 'cat.psd1') -Content @" +@{ + 'oldapp' = @{ SlotX = @{ Path = '$destPath' } } +} +"@ + $script:LegacyConfig = Write-ListFile -Path (Join-Path $script:LegacyRoot 'config.psd1') -Content "@{ SoftwareCatalog = '$script:LegacyCatalog' }" + $script:LegacyList = Write-ListFile -Path (Join-Path $script:LegacyRoot 'list.txt') -Content "oldapp`n" + $script:LegacyBackupDir = Join-Path $script:LegacyRoot 'Backups' + New-Item -ItemType Directory -Path $script:LegacyBackupDir -Force | Out-Null + + # 手工造一个旧布局归档:顶层就是源目录名,不是 Slot 名。 + $script:LegacyArchive = Join-Path $script:LegacyBackupDir 'oldapp.7z' + (Invoke-ExternalCommand -FilePath $script:SevenZip ` + -ArgumentList @('a', '-t7z', '-mx=1', '-bso0', '-bsp0', $script:LegacyArchive, $script:LegacyLeaf) ` + -WorkingDirectory $script:LegacyHolder) | Should -Be 0 + } + + It '归档确实是旧布局:顶层是源目录名而不是 Slot 名' { + $top = @(Get-ArchiveTopLevelNames -ArchivePath $script:LegacyArchive -SevenZip $script:SevenZip) + (@($top | Sort-Object) -join ',') | Should -Be $script:LegacyLeaf + } + + It '归档里缺 Slot 层(真实旧归档)时按旧布局回退,把内容还原回原位' { + # 归档里没有 SlotX,但有旧布局的 <目标末级名>;恢复端必须先问归档"这条路径在不在", + # 不能靠 7z 的退出码猜(7z 对不存在的条目同样返回 0)。 + Remove-Item -LiteralPath (Join-Path $script:LegacyDestParent $script:LegacyLeaf) -Recurse -Force -ErrorAction SilentlyContinue + + $run = Invoke-BaknretScript -Script $script:RestoreScript -Parameters @{ + BackupListPath = $script:LegacyList + BackupDir = $script:LegacyBackupDir + ConfigPath = $script:LegacyConfig + Force = $true + } + + $run.ExitCode | Should -Be 0 + $run.Output | Should -Match '按旧布局回退' + Test-Path -LiteralPath (Join-Path $script:LegacyDestParent "$script:LegacyLeaf\legacy.txt") | Should -BeTrue + (Get-Content -LiteralPath (Join-Path $script:LegacyDestParent "$script:LegacyLeaf\legacy.txt") -Raw).Trim() | Should -Be 'old-layout' + } + + It '归档里既没有 Slot 层、也没有旧布局名字时明确失败(不再"成功地什么都没恢复")' { + # 用 :: 覆盖把目标换成一个归档里根本不存在的末级名:两条路都走不通, + # 必须报失败并说明原因,而不是打一句"恢复成功"却一个文件都没落地。 + $missingList = Write-ListFile -Path (Join-Path $script:LegacyRoot 'missing-list.txt') ` + -Content "oldapp :: $script:LegacyRoot\dest2\Nothing Here`n" + + $run = Invoke-BaknretScript -Script $script:RestoreScript -Parameters @{ + BackupListPath = $missingList + BackupDir = $script:LegacyBackupDir + ConfigPath = $script:LegacyConfig + Force = $true + } + + $run.ExitCode | Should -Be 1 + $run.Output | Should -Match '既没有' + Test-Path -LiteralPath (Join-Path $script:LegacyRoot 'dest2\Nothing Here') | Should -BeFalse + } +} + +# ============================================================================ +Describe '集成:归档内路径冲突会被拒绝执行' -Skip:(-not ($script:HasSevenZip -and $script:HasPwsh)) { # ============================================================================ BeforeAll { @@ -338,8 +515,9 @@ Describe '集成:同一条目里两个同名目录会被拒绝执行' -Skip:(- New-Item -ItemType Directory -Path $directory -Force | Out-Null Set-Content -LiteralPath (Join-Path $directory 'x.txt') 'x' } - Write-ListFile -Path $script:RejectCatalog -Content "@{`n 'collide' = @('$p1', '$p2')`n}`n" | Out-Null - $script:RejectList = Write-ListFile -Path (Join-Path $script:RejectRoot 'list.txt') -Content "collide`n" + # Slot 叫 Data,Include 也要放进包内的 Data -> 同一个位置,必须报错 + Write-ListFile -Path $script:RejectCatalog -Content "@{`n 'collide' = @{ Data = @{ Path = '$p1' } }`n}`n" | Out-Null + $script:RejectList = Write-ListFile -Path (Join-Path $script:RejectRoot 'list.txt') -Content "collide :+ Data:$p2`n" $script:RejectConfig = Write-ListFile -Path (Join-Path $script:RejectRoot 'config.psd1') -Content "@{ SoftwareCatalog = '$script:RejectCatalog' }`n" $script:RejectBackupDir = Join-Path $script:RejectRoot 'Backups' @@ -352,9 +530,9 @@ Describe '集成:同一条目里两个同名目录会被拒绝执行' -Skip:(- } } - It '退出码 1,且给出"顶层同名"的原因,不生成归档' { + It '退出码 1,且给出"归档内路径冲突"的原因,不生成归档' { $script:RejectRun.ExitCode | Should -Be 1 - $script:RejectRun.Output | Should -Match '顶层同名' + $script:RejectRun.Output | Should -Match '归档内路径冲突' @(Get-ChildItem -LiteralPath $script:RejectBackupDir -File -Filter *.7z -ErrorAction SilentlyContinue).Count | Should -Be 0 } @@ -362,7 +540,7 @@ Describe '集成:同一条目里两个同名目录会被拒绝执行' -Skip:(- $manifest = Read-BaknretManifest -Path (Join-Path $script:RejectBackupDir 'manifest.json') $record = $manifest.items['collide'] $record.action | Should -Be 'failed' - $record.reason | Should -Match '顶层同名' + $record.reason | Should -Match '归档内路径冲突' } } @@ -381,7 +559,7 @@ Describe '条目从清单里消失后,旧归档必须被点名为孤儿' -Skip Set-Content -LiteralPath (Join-Path $script:OrphanSource 'data.txt') 'hello' $script:OrphanCatalog = Write-ListFile -Path (Join-Path $script:OrphanRoot 'cat.psd1') ` - -Content "@{`n 'my-app' = '$script:OrphanSource'`n}`n" + -Content "@{ 'my-app' = @{ Default = @{ Path = '$script:OrphanSource' } } }`n" $script:OrphanConfig = Write-ListFile -Path (Join-Path $script:OrphanRoot 'config.psd1') ` -Content "@{ SoftwareCatalog = '$script:OrphanCatalog' }`n" $script:OrphanList = Join-Path $script:OrphanRoot 'list.txt' diff --git a/tests/BakNRet.Security.Tests.ps1 b/tests/BakNRet.Security.Tests.ps1 new file mode 100644 index 0000000..6d82399 --- /dev/null +++ b/tests/BakNRet.Security.Tests.ps1 @@ -0,0 +1,487 @@ +<# +.SYNOPSIS + 安全描述符(NTFS 属主 / ACL)的测试套件。 + +.DESCRIPTION + 为什么单独一套:这一块的核心契约不是"文件内容对不对",而是**安全描述符的形状**—— + + * `C:\ProgramData` 下的目录 ACL 里有 `(A;OICIIO;GA;;;CO)`:CREATOR OWNER 是访问 + 检查时才替换的占位符,替换成"被检查对象的属主"。只回放 ACE 文本、不恢复属主, + 等于把"谁创建的东西谁有全权"里的"谁"换成跑脚本的账户; + * 归档格式(.7z)根本不承载安全描述符(7-Zip 的 -sni 只能写进 WIM), + 所以这一块全部靠 <归档名>.acl.json 旁挂文件 + 显式的回放步骤。 + + 断言用的"安全指纹"刻意**不含** ACE 的继承标志位与 ID(inherited)标志: + 继承到文件子对象时容器继承位会被系统去掉,而 ID 标志写不回去(不是可写的输入)。 + 这两处差异都不改变有效权限,进等式只会制造假失败。 + + 跑法: + .\tests\Run-Pester.ps1 # 会连这一套一起跑 + Invoke-Pester -Path .\tests\BakNRet.Security.Tests.ps1 +#> + +# 发现阶段(discovery)也会执行文件顶层代码,-Skip: 用到的判据必须在这里算好 +$script:HasSevenZip = [bool](Get-Command 7z -ErrorAction SilentlyContinue) + +BeforeAll { + $script:ProjectRoot = Split-Path -Parent $PSScriptRoot + $script:BackupScript = Join-Path $script:ProjectRoot 'Backup.ps1' + $script:RestoreScript = Join-Path $script:ProjectRoot 'Restore.ps1' + + Import-Module (Join-Path $script:ProjectRoot 'Common.psm1') -Force + + $script:Sandbox = Join-Path $env:TEMP ('baknret-acl-' + [guid]::NewGuid().ToString('N').Substring(0, 8)) + New-Item -ItemType Directory -Path $script:Sandbox -Force | Out-Null + + # 一个"带刺"的 DACL:CREATOR OWNER(inherit-only, GENERIC_ALL) + 全权给 SYSTEM/Administrators + # + 一条**孤儿 SID** 的显式 ACE(数值形式的 SID,绝不按账户名写)+ DACL protected。 + # 这正是 ProgramData 下那些目录的形态,也是"名字解析会把权限落到脚本头上"的现场。 + $script:OrphanSid = 'S-1-5-21-1111111111-2222222222-3333333333-4444' + $script:SpecialDacl = 'D:PAI(A;OICIIO;GA;;;CO)(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;0x1200a9;;;BU)(A;;0x1201bf;;;' + $script:OrphanSid + ')' + + function Set-AclRaw { + <# .SYNOPSIS 写安全描述符:.NET Core 走扩展方法,5.1 走实例方法。 #> + param([Parameter(Mandatory = $true)][System.IO.FileSystemInfo]$Item, [Parameter(Mandatory = $true)]$Security) + if ($PSVersionTable.PSEdition -eq 'Core') { + [System.IO.FileSystemAclExtensions]::SetAccessControl($Item, $Security) + } else { + $Item.SetAccessControl($Security) + } + } + + function Get-AclFingerprint { + <# + .SYNOPSIS + 逐对象的"安全指纹":属主 | 属组 | protected | 全部 ACE 的 类型|SID|掩码(排序)。 + + .DESCRIPTION + 比 SDDL 原文更适合做断言:继承标志位与 ID 标志的差异不改变有效权限, + 而它们的表现形式依赖对象类型(文件没有容器继承)与写入方式,进等式只会假失败。 + #> + param([Parameter(Mandatory = $true)][string]$Path) + + $acl = Get-Acl -LiteralPath $Path + $sid = [System.Security.Principal.SecurityIdentifier] + $aces = @($acl.GetAccessRules($true, $true, $sid) | + ForEach-Object { '{0}|{1}|{2}' -f $_.AccessControlType, $_.IdentityReference.Value, [int]$_.FileSystemRights } | + Sort-Object) + return ('O={0} G={1} P={2} [{3}]' -f $acl.GetOwner($sid).Value, $acl.GetGroup($sid).Value, $acl.AreAccessRulesProtected, ($aces -join ' ')) + } + + function New-AclSourceTree { + <# + .SYNOPSIS + 造源目录树并打上"带刺"的 DACL,返回逐对象的安全指纹。 + .NOTES + DACL 是在子树建好**之后**才打的 —— 这样 sub / a.txt 上会留下"父目录改过权限、 + 自己还留着老 ACE"的陈旧继承 ACE,正是采集端必须处理的那种对象。 + #> + param([Parameter(Mandatory = $true)][string]$Root) + + New-Item -ItemType Directory -Path (Join-Path $Root 'sub') -Force | Out-Null + [System.IO.File]::WriteAllText((Join-Path $Root 'sub\a.txt'), 'acl payload') + + $security = New-Object System.Security.AccessControl.DirectorySecurity + $security.SetSecurityDescriptorSddlForm($script:SpecialDacl, [System.Security.AccessControl.AccessControlSections]::Access) + Set-AclRaw -Item (Get-Item -LiteralPath $Root) -Security $security + + $fingerprints = @{} + foreach ($relative in '.', 'sub', 'sub\a.txt') { + $path = if ($relative -eq '.') { $Root } else { Join-Path $Root $relative } + $fingerprints[$relative] = Get-AclFingerprint -Path $path + } + return $fingerprints + } + + function Reset-AclTree { + <# .SYNOPSIS 先把 ACL 复位再删:拒绝型 / protected 的 DACL 会让 Remove-Item 直接失败。 #> + param([Parameter(Mandatory = $true)][string]$Path) + if (-not (Test-Path -LiteralPath $Path)) { return } + & takeown.exe /F $Path /R /D Y 2>&1 | Out-Null + & icacls.exe $Path /reset /T /C /Q 2>&1 | Out-Null + Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction SilentlyContinue + } + + function Invoke-BaknretScript { + <# .SYNOPSIS 用子进程跑 Backup.ps1 / Restore.ps1(两个脚本结尾都 exit,必须独立进程)。 #> + param( + [Parameter(Mandatory = $true)][string]$Script, + [hashtable]$Parameters = @{} + ) + + $arguments = @('-NoProfile', '-NonInteractive', '-File', $Script) + foreach ($name in ($Parameters.Keys | Sort-Object)) { + $value = $Parameters[$name] + if ($value -is [bool]) { + if ($value) { $arguments += "-$name" } + continue + } + $arguments += "-$name" + if ($value -is [array]) { $arguments += $value } else { $arguments += [string]$value } + } + + $outFile = Join-Path ([System.IO.Path]::GetTempPath()) ('bnr-aclout-' + [guid]::NewGuid().ToString('N') + '.txt') + $cmdFile = Join-Path ([System.IO.Path]::GetTempPath()) ('bnr-aclcmd-' + [guid]::NewGuid().ToString('N') + '.cmd') + $argString = (@($arguments | ForEach-Object { '"' + ($_ -replace '"', '\"') + '"' }) -join ' ') + $batch = "@echo off`r`n" + 'pwsh ' + $argString + ' > "' + $outFile + '" 2>&1' + "`r`nexit /b %ERRORLEVEL%`r`n" + [System.IO.File]::WriteAllText($cmdFile, $batch, [System.Text.UTF8Encoding]::new($false)) + + $exitCode = $null + $lines = @() + try { + $exitCode = Invoke-ExternalCommand -FilePath 'cmd.exe' -ArgumentList @('/c', $cmdFile) + $lines = @(Get-Content -LiteralPath $outFile -Encoding UTF8 -ErrorAction SilentlyContinue) + } finally { + Remove-Item -LiteralPath $outFile -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $cmdFile -Force -ErrorAction SilentlyContinue + } + + return [pscustomobject]@{ + ExitCode = $exitCode + Lines = @($lines | ForEach-Object { [string]$_ }) + Output = (($lines | Out-String)) + } + } + + function New-AclEntryHarness { + <# + .SYNOPSIS + 造一份独立的 BackupList / BackupConfig,返回各个路径。 + .NOTES + 用**手写路径**条目,不依赖 SoftwareCatalog:归档名由路径推出, + 测试也就不用管名录的解析规则。 + #> + param([Parameter(Mandatory = $true)][string]$Name, [Parameter(Mandatory = $true)][string]$Root) + + $dir = Join-Path $script:Sandbox $Name + New-Item -ItemType Directory -Path $dir -Force | Out-Null + $sourcePath = Join-Path $dir 'source' + $backupDir = Join-Path $dir 'backups' + New-Item -ItemType Directory -Path $backupDir -Force | Out-Null + + $listPath = Join-Path $dir 'BackupList.txt' + [System.IO.File]::WriteAllText($listPath, "$sourcePath`n", [System.Text.UTF8Encoding]::new($false)) + + $configPath = Join-Path $dir 'BackupConfig.psd1' + $configText = @" +@{ + BackupDir = '$backupDir' + LogDir = '$(Join-Path $dir 'logs')' + SnapshotDir = '$(Join-Path $backupDir 'snapshots')' + SoftwareCatalog = 'NoSuchCatalog.psd1' + MinFreeSpaceGB = 0 + VerifyArchive = `$true + ComputeHash = `$false + CompressionLevel = 1 + ToolOutput = 'quiet' + Snapshot = @{ Enabled = `$false } + Encryption = @{ Enabled = `$false; PasswordFile = '' } + Security = @{ Mode = 'Full'; IncludeSacl = `$false; SidMap = @{}; FailOnError = `$false } + DefaultExcludes = @() +} +"@ + [System.IO.File]::WriteAllText($configPath, $configText, [System.Text.UTF8Encoding]::new($false)) + + return [pscustomobject]@{ + Dir = $dir + SourcePath = $sourcePath + BackupDir = $backupDir + ListPath = $listPath + ConfigPath = $configPath + } + } +} + +AfterAll { + foreach ($name in 'walk', 'capture', 'restore', 'integration') { + $path = Join-Path $script:Sandbox $name + Reset-AclTree -Path $path + } + if ($script:Sandbox -and (Test-Path -LiteralPath $script:Sandbox)) { + Reset-AclTree -Path $script:Sandbox + Remove-Item -LiteralPath $script:Sandbox -Recurse -Force -ErrorAction SilentlyContinue + } +} + +# ============================================================================ +Describe '排除判定与 7z 的 -x! / -xr! 语义对齐' { +# ============================================================================ + It '锚定模式只命中它自己那棵子树' { + 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 'Other\Cache' -Patterns @('Default\Cache') | Should -BeFalse + } + + It '! 通配按任意层级的组件名匹配(* 不是正则)' { + Test-BaknretPathExcluded -RelativePath 'a\Code Cache\f' -Patterns @('!*Cache') | Should -BeTrue + Test-BaknretPathExcluded -RelativePath 'Code Cache' -Patterns @('!*Cache') | Should -BeTrue + Test-BaknretPathExcluded -RelativePath 'a\teamcache.bin' -Patterns @('!*Cache') | Should -BeFalse + } + + It '!re: 走正则,且组件名与整条相对路径都算命中' { + 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 'Cache\data' -Patterns @('!re:^Cache$') | Should -BeTrue + } + + It '没有模式时一律不排除' { + Test-BaknretPathExcluded -RelativePath 'a\b' -Patterns @() | Should -BeFalse + Test-BaknretPathExcluded -RelativePath '' -Patterns @('!*') | Should -BeFalse + } + + It '模式里的空格按 7z 的规矩当 ? 处理' { + Test-BaknretPathExcluded -RelativePath 'a\Cache' -Patterns @('!*Cache Extras') | Should -BeFalse + Test-BaknretPathExcluded -RelativePath 'a\Cache Extras' -Patterns @('!*Cache Extras') | Should -BeTrue + } +} + +# ============================================================================ +Describe '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)' + $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)' + } + + It '不会误伤以它为前缀的更长的 SID' { + $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 | Should -Be 'D:(A;;FA;;;S-1-5-21-9-8-7-1001)(A;;FA;;;S-1-5-21-1-2-3-10012)' + } + + It '空映射表时原样返回' { + $sddl = 'D:(A;;FA;;;SY)' + Convert-BaknretSidMap -Sddl $sddl -SidMap @{} | Should -Be $sddl + } +} + +# ============================================================================ +Describe '安全描述符采集' { +# ============================================================================ + BeforeAll { + $script:CaptureRoot = Join-Path $script:Sandbox 'capture\Data' + $script:CaptureItem = [pscustomobject]@{ ArchivePath = 'Data'; RealPath = $script:CaptureRoot } + $script:CaptureFingerprints = New-AclSourceTree -Root $script:CaptureRoot + } + + It 'Full:每个对象一条记录,键是归档内相对路径' { + $capture = Get-BaknretSecurityRecords -Items @($script:CaptureItem) -Mode Full + $capture.Scanned | Should -Be 3 + $capture.Kept | Should -Be 3 + $capture.Errors | Should -Be 0 + @($capture.Records | ForEach-Object { $_.p }) | Should -Be @('Data', 'Data\sub', 'Data\sub\a.txt') + } + + It '根记录的 SDDL 保留了 CREATOR OWNER、IO 标志、孤儿 SID 和 protected 位' { + $capture = Get-BaknretSecurityRecords -Items @($script:CaptureItem) -Mode Full + $root = @($capture.Records | Where-Object { $_.p -eq 'Data' })[0] + $root.s | Should -Match 'D:PAI' + $root.s | Should -Match '\(A;OICIIO;GA;;;CO\)' + $root.s | Should -BeLike "*$script:OrphanSid*" + $root.o | Should -Be $script:CaptureFingerprints['.'].Split(' ')[0].Substring(2) + } + + It 'Smart 比 Full 少,但根永远保留' { + $full = Get-BaknretSecurityRecords -Items @($script:CaptureItem) -Mode Full + $smart = Get-BaknretSecurityRecords -Items @($script:CaptureItem) -Mode Smart + $smart.Kept | Should -BeLessOrEqual $full.Kept + @($smart.Records | ForEach-Object { $_.p }) | Should -Contain 'Data' + } + + It 'Roots 只存归档项的根,不再往下走' { + $roots = Get-BaknretSecurityRecords -Items @($script:CaptureItem) -Mode Roots + $roots.Kept | Should -Be 1 + $roots.Records[0].p | Should -Be 'Data' + } + + It 'sidecar 往返:条数与 SDDL 原样保留' { + $capture = Get-BaknretSecurityRecords -Items @($script:CaptureItem) -Mode Full + $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 + $sidecar = Read-BaknretSecuritySidecar -Path $path + $sidecar.Records.Count | Should -Be 3 + $record = @($sidecar.Records | Where-Object { $_.p -eq 'Data\sub\a.txt' })[0] + $record.k | Should -Be 'f' + $record.s | Should -Match 'D:' + } + + It '旁挂文件不存在时读出 $null(调用方据此打告警,而不是静默当没事)' { + Read-BaknretSecuritySidecar -Path (Join-Path $script:Sandbox 'nope.acl.json') | Should -BeNullOrEmpty + } + + It '排除模式在采集时同样生效(采集树 == 归档树)' { + # 刻意用一棵**不带**特殊 DACL 的树:带刺的 ACL 里没有"新建子目录"的权限, + # 在它里面造测试数据会被系统直接拒绝(那本身也是这套功能要防的事)。 + $walkRoot = Join-Path $script:Sandbox 'walk\Data' + New-Item -ItemType Directory -Path (Join-Path $walkRoot 'Cache') -Force | Out-Null + [System.IO.File]::WriteAllText((Join-Path $walkRoot 'Cache\c.bin'), 'x') + [System.IO.File]::WriteAllText((Join-Path $walkRoot 'keep.txt'), 'x') + + $walkItem = [pscustomobject]@{ ArchivePath = 'Data'; RealPath = $walkRoot } + $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 -Contain 'Data\keep.txt' + } +} + +# ============================================================================ +Describe '安全描述符回放' { +# ============================================================================ + BeforeAll { + $script:RestoreRoot = Join-Path $script:Sandbox 'restore\Data' + $script:TargetRoot = Join-Path $script:Sandbox 'restore\target' + $script:RestoreFingerprints = New-AclSourceTree -Root $script:RestoreRoot + + $capture = Get-BaknretSecurityRecords -Items @([pscustomobject]@{ ArchivePath = 'Data'; RealPath = $script:RestoreRoot }) -Mode Full + $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 + $script:RestoreSidecar = Read-BaknretSecuritySidecar -Path $script:RestoreSidecarPath + } + + It '回放后根对象的安全描述符与源逐字节一致(protected / CO / 孤儿 SID 全在)' { + # 干净目标:只拷内容,不带 ACL(ACL 是新建对象的默认值) + & 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.Total | Should -Be 3 + $result.Failed | Should -Be 0 + $result.Applied | Should -Be 3 + + (Get-Acl -LiteralPath $script:TargetRoot).Sddl | Should -Be (Get-Acl -LiteralPath $script:RestoreRoot).Sddl + } + + It '全部对象的安全指纹与源一致(属主/属组/ACE 集合)' { + foreach ($relative in '.', 'sub', 'sub\a.txt') { + $sourcePath = if ($relative -eq '.') { $script:RestoreRoot } else { Join-Path $script:RestoreRoot $relative } + $targetPath = if ($relative -eq '.') { $script:TargetRoot } else { Join-Path $script:TargetRoot $relative } + + # 唯一允许的差异:陈旧继承 ACE 被"冻结"成显式 + protected 的对象, + # protected 位会从 False 变 True(见 Get-BaknretSecuritySddlWithStale)。 + $expected = $script:RestoreFingerprints[$relative] -replace ' P=(True|False) ', ' P=' + $actual = (Get-AclFingerprint -Path $targetPath) -replace ' P=(True|False) ', ' P=' + $actual | Should -Be $expected -Because "$relative 的 ACE 集合应当与源一致" + } + } + + It '目标不存在或不是普通对象时记 Skipped,不记 Failed' { + $result = Restore-BaknretSecurity -Sidecar $script:RestoreSidecar -ArchiveRoot 'Data' ` + -TargetPath (Join-Path $script:Sandbox 'restore\does-not-exist') + $result.Total | Should -Be 3 + $result.Skipped | Should -Be 3 + $result.Failed | Should -Be 0 + } + + It '归档根名对不上时一条都不回放(不会把兄弟项的 ACL 倒过来)' { + $result = Restore-BaknretSecurity -Sidecar $script:RestoreSidecar -ArchiveRoot 'Other' -TargetPath $script:TargetRoot + $result.Total | Should -Be 0 + $result.Applied | Should -Be 0 + } + + It '属组写不进去时不会连累 DACL:回退到底也要把 ACL 落下去' { + $path = Join-Path $script:Sandbox 'restore\bogus-group' + New-Item -ItemType Directory -Path $path -Force | Out-Null + + # 属组写成一个本机不存在的 SID:改主组需要特权,这一层必然失败 + $sddl = 'O:' + (Get-Acl -LiteralPath $path).GetOwner([System.Security.Principal.SecurityIdentifier]).Value + + 'G:' + $script:OrphanSid + 'D:(A;;FA;;;SY)' + $sidecar = [pscustomobject]@{ + Records = @([pscustomobject]@{ p = 'Data'; k = 'd'; s = $sddl }) + } + + $result = Restore-BaknretSecurity -Sidecar $sidecar -ArchiveRoot 'Data' -TargetPath $path + $result.Failed | Should -Be 0 + ($result.Applied + $result.OwnerFailed) | Should -Be 1 + (Get-Acl -LiteralPath $path).Sddl | Should -Match '\(A;;FA;;;SY\)' + } + + It '对象的安全描述符读不到时带 e 记账,回放时跳过而不是写坏' { + $record = [pscustomobject]@{ p = 'Data'; k = 'd'; s = $null; e = '读不到' } + $sidecar = [pscustomobject]@{ Records = @($record) } + $path = Join-Path $script:Sandbox 'restore\bogus-group' + $result = Restore-BaknretSecurity -Sidecar $sidecar -ArchiveRoot 'Data' -TargetPath $path + $result.Skipped | Should -Be 1 + $result.Applied | Should -Be 0 + $result.Failed | Should -Be 0 + } +} + +# ============================================================================ +Describe '与 Backup.ps1 / Restore.ps1 的集成' -Skip:(-not $script:HasSevenZip) { +# ============================================================================ + BeforeAll { + $script:Harness = New-AclEntryHarness -Name 'integration' -Root $script:Sandbox + $script:IntegrationFingerprints = New-AclSourceTree -Root $script:Harness.SourcePath + } + + It '备份会写出 <归档名>.acl.json,并在 manifest 里记下它' { + $result = Invoke-BaknretScript -Script $script:BackupScript -Parameters @{ + BackupListPath = $script:Harness.ListPath + ConfigPath = $script:Harness.ConfigPath + BackupDir = $script:Harness.BackupDir + } + $result.ExitCode | Should -Be 0 + + $sidecars = @(Get-ChildItem -LiteralPath $script:Harness.BackupDir -Filter '*.acl.json' -ErrorAction SilentlyContinue) + $sidecars.Count | Should -Be 1 + $result.Output | Should -Match '安全描述符:3 个对象' + + $manifest = Get-Content -LiteralPath (Join-Path $script:Harness.BackupDir 'manifest.json') -Raw | ConvertFrom-Json + $key = @($manifest.items.PSObject.Properties.Name)[0] + $manifest.items.$key.security.file | Should -Be $sidecars[0].Name + $manifest.items.$key.security.objects | Should -Be 3 + $manifest.items.$key.security.errors | Should -Be 0 + } + + It '恢复会把安全描述符回放回去(删源之后仍然逐对象与备份前一致)' { + Reset-AclTree -Path $script:Harness.SourcePath + (Test-Path -LiteralPath $script:Harness.SourcePath) | Should -BeFalse + + $result = Invoke-BaknretScript -Script $script:RestoreScript -Parameters @{ + BackupListPath = $script:Harness.ListPath + ConfigPath = $script:Harness.ConfigPath + BackupDir = $script:Harness.BackupDir + Force = $true + } + $result.ExitCode | Should -Be 0 + $result.Output | Should -Match '安全描述符:回放 3/3 个对象' + + (Get-Acl -LiteralPath $script:Harness.SourcePath).Sddl | Should -Match '\(A;OICIIO;GA;;;CO\)' + (Get-Acl -LiteralPath $script:Harness.SourcePath).Sddl | Should -BeLike "*$script:OrphanSid*" + + foreach ($relative in '.', 'sub', 'sub\a.txt') { + $path = if ($relative -eq '.') { $script:Harness.SourcePath } else { Join-Path $script:Harness.SourcePath $relative } + $expected = $script:IntegrationFingerprints[$relative] -replace ' P=(True|False) ', ' P=' + $actual = (Get-AclFingerprint -Path $path) -replace ' P=(True|False) ', ' P=' + $actual | Should -Be $expected -Because "$relative 的安全指纹应当与备份前一致" + } + } + + It '-SkipSecurity 时不回放(目标保持新建对象的默认 ACL)' { + Reset-AclTree -Path $script:Harness.SourcePath + $result = Invoke-BaknretScript -Script $script:RestoreScript -Parameters @{ + BackupListPath = $script:Harness.ListPath + ConfigPath = $script:Harness.ConfigPath + BackupDir = $script:Harness.BackupDir + Force = $true + SkipSecurity = $true + } + $result.ExitCode | Should -Be 0 + (Get-Acl -LiteralPath $script:Harness.SourcePath).Sddl | Should -Not -Match '\(A;OICIIO;GA;;;CO\)' + } + + It '归档旁边没有 acl.json 时打告警、不算失败(旧归档照样恢复得出来)' { + Reset-AclTree -Path $script:Harness.SourcePath + Get-ChildItem -LiteralPath $script:Harness.BackupDir -Filter '*.acl.json' | Remove-Item -Force + + $result = Invoke-BaknretScript -Script $script:RestoreScript -Parameters @{ + BackupListPath = $script:Harness.ListPath + ConfigPath = $script:Harness.ConfigPath + BackupDir = $script:Harness.BackupDir + Force = $true + } + $result.ExitCode | Should -Be 0 + $result.Output | Should -Match '没有安全描述符旁挂文件' + (Test-Path -LiteralPath (Join-Path $script:Harness.SourcePath 'sub\a.txt')) | Should -BeTrue + } +} diff --git a/tests/BakNRet.Tests.ps1 b/tests/BakNRet.Tests.ps1 index b0abc3b..0b5fb2c 100644 --- a/tests/BakNRet.Tests.ps1 +++ b/tests/BakNRet.Tests.ps1 @@ -12,6 +12,14 @@ 一起带走; 2. 子进程给出的是真正的进程退出码,正好独立验证"退出码取法"这条修复。 + 本套件断言的是**重构后的新契约**(BackupList.txt / SoftwareCatalog.psd1): + * 行首 `+` = 仅备份、`-` = 仅恢复; + * `::` 覆盖 Path(不再是 `:-` 的别名),排除一律写 `:-`; + * `:+` / `@ Include=` 是"把宿主机路径放到归档内指定位置"; + * `:encrypt` / `:!encrypt` / `@ Encrypt=` 控制单个归档加密; + * 软件名条目 -> 一个归档,归档内是 `\<内容>`(Path 是文件时是名为 + `` 的文件);手写路径条目沿用历史布局 `<末级名>\...`。 + 跑法: .\tests\Run-Pester.ps1 # 推荐:会自动找到 Pester(含 .tools 下的本地副本) Invoke-Pester -Path .\tests\BakNRet.Tests.ps1 @@ -31,7 +39,6 @@ BeforeAll { $script:ProjectRoot = Split-Path -Parent $PSScriptRoot $script:BackupScript = Join-Path $script:ProjectRoot 'Backup.ps1' $script:RestoreScript = Join-Path $script:ProjectRoot 'Restore.ps1' - $script:CatalogPath = Join-Path $script:ProjectRoot 'SoftwareCatalog.psd1' $script:SevenZip = Get-Command 7z -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source Import-Module (Join-Path $script:ProjectRoot 'Common.psm1') -Force @@ -40,6 +47,13 @@ BeforeAll { New-Item -ItemType Directory -Path $script:Sandbox -Force | Out-Null # 在子进程里跑 Backup.ps1 / Restore.ps1,拿到真实退出码与完整输出。 + # + # 为什么不用 `$lines = & pwsh @arguments 2>&1`:本机沙箱禁止 PowerShell 为捕获 + # 原生子进程输出创建管道(“Access to the path '\\.\pipe\LOCAL\dotnet_...' is denied”), + # 那条路会直接失败。Invoke-ExternalCommand 让子进程继承 stdio,不受影响, + # 于是这里改成:把整条命令写进临时 .cmd,由 cmd 自己把输出重定向到文件, + # 再用 Invoke-ExternalCommand 调 `cmd /c <批处理>`。退出码来自真实进程, + # 输出从文件读回,全程不经过 PowerShell 的管道。 function Invoke-BaknretScript { param( [Parameter(Mandatory = $true)][string]$Script, @@ -57,9 +71,24 @@ BeforeAll { if ($value -is [array]) { $arguments += $value } else { $arguments += [string]$value } } - $lines = & pwsh @arguments 2>&1 + $outFile = Join-Path ([System.IO.Path]::GetTempPath()) ("bnr-out-" + [guid]::NewGuid().ToString('N') + '.txt') + $cmdFile = Join-Path ([System.IO.Path]::GetTempPath()) ("bnr-cmd-" + [guid]::NewGuid().ToString('N') + '.cmd') + $argString = (@($arguments | ForEach-Object { '"' + ($_ -replace '"', '\"') + '"' }) -join ' ') + $batch = "@echo off`r`n" + 'pwsh ' + $argString + ' > "' + $outFile + '" 2>&1' + "`r`nexit /b %ERRORLEVEL%`r`n" + [System.IO.File]::WriteAllText($cmdFile, $batch, [System.Text.UTF8Encoding]::new($false)) + + $exitCode = $null + $lines = @() + try { + $exitCode = Invoke-ExternalCommand -FilePath 'cmd.exe' -ArgumentList @('/c', $cmdFile) + $lines = @(Get-Content -LiteralPath $outFile -Encoding UTF8 -ErrorAction SilentlyContinue) + } finally { + Remove-Item -LiteralPath $outFile -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $cmdFile -Force -ErrorAction SilentlyContinue + } + return [pscustomobject]@{ - ExitCode = $LASTEXITCODE + ExitCode = $exitCode Lines = @($lines | ForEach-Object { [string]$_ }) Output = (($lines | Out-String)) } @@ -113,87 +142,201 @@ Describe 'BackupList.txt 解析' { ConvertFrom-BackupListLine -Line '' | Should -BeNullOrEmpty } - It '裸路径' { + It '裸路径:Path 原样、方向 both、不是软件名、没有任何覆盖' { $result = ConvertFrom-BackupListLine -Line 'C:\Programs\FooClolor' $result.Path | Should -Be 'C:\Programs\FooClolor' - $result.ExcludePatterns.Count | Should -Be 0 - $result.Flags.Count | Should -Be 0 + $result.IsName | Should -BeFalse + $result.Direction | Should -Be 'both' + @($result.Overrides.Keys).Count | Should -Be 0 + @($result.ExcludePatterns).Count | Should -Be 0 + @($result.Includes).Count | Should -Be 0 + @($result.Flags).Count | Should -Be 0 + @($result.UnknownKeys).Count | Should -Be 0 + $result.Comment | Should -BeNullOrEmpty + $result.Raw | Should -Be 'C:\Programs\FooClolor' } - It '逗号分隔的排除表被拆成多个模式(旧实现只认分号,整串会被当成一个模式)' { - $result = ConvertFrom-BackupListLine -Line 'C:\Programs\March7thAssistant :: a\b,c\d\' - $result.Path | Should -Be 'C:\Programs\March7thAssistant' - $result.ExcludePatterns.Count | Should -Be 2 + It '软件名:IsName 为真' { + $result = ConvertFrom-BackupListLine -Line 'my-app' + $result.Path | Should -Be 'my-app' + $result.IsName | Should -BeTrue + } + + It 'IsName 判定:含分隔符或 % 就算字面路径' { + (ConvertFrom-BackupListLine -Line 'a/b').IsName | Should -BeFalse + (ConvertFrom-BackupListLine -Line '%TEMP%\x').IsName | Should -BeFalse + (ConvertFrom-BackupListLine -Line 'my-app').IsName | Should -BeTrue + } + + It '标记必须是独立记号:C:\a:-b 仍然只是一个路径' { + $result = ConvertFrom-BackupListLine -Line 'C:\a:-b' + $result.Path | Should -Be 'C:\a:-b' + $result.Overrides.ContainsKey('Exclude') | Should -BeFalse + @($result.ExcludePatterns).Count | Should -Be 0 + } + + It '行首 + 仅备份、- 仅恢复,方向标记不进入目标' { + $backupOnly = ConvertFrom-BackupListLine -Line '+ Foo' + $backupOnly.Direction | Should -Be 'backup' + $backupOnly.Path | Should -Be 'Foo' + + $restoreOnly = ConvertFrom-BackupListLine -Line '- Foo' + $restoreOnly.Direction | Should -Be 'restore' + $restoreOnly.Path | Should -Be 'Foo' + + (ConvertFrom-BackupListLine -Line 'Foo').Direction | Should -Be 'both' + } + + It '只有方向标记、没有目标 -> 忽略该行' { + ConvertFrom-BackupListLine -Line '+' | Should -BeNullOrEmpty + ConvertFrom-BackupListLine -Line '-' | Should -BeNullOrEmpty + } + + # ---- 新契约的核心::: 表示覆盖 Path,不再是 :- 的别名 ---- + It ':: 现在表示"覆盖 Path",与 :- 彻底分开' { + $result = ConvertFrom-BackupListLine -Line 'Foo :: D:\bar' + $result.Path | Should -Be 'Foo' + $result.Overrides.ContainsKey('Path') | Should -BeTrue + $result.Overrides['Path'] | Should -Be 'D:\bar' + $result.Overrides.ContainsKey('Exclude') | Should -BeFalse + @($result.ExcludePatterns).Count | Should -Be 0 + } + + It ':: 的值可以带空格(一直取到下一个标记之前)' { + $result = ConvertFrom-BackupListLine -Line 'Foo :: C:\Program Files\App :encrypt' + $result.Overrides['Path'] | Should -Be 'C:\Program Files\App' + $result.Overrides['Encrypt'] | Should -BeTrue + } + + It ':- 的逗号 / 分号列表拆成多个模式' { + $result = ConvertFrom-BackupListLine -Line 'C:\x :- a\b,c\d\;e' + @($result.ExcludePatterns).Count | Should -Be 3 $result.ExcludePatterns[0] | Should -Be 'a\b' $result.ExcludePatterns[1] | Should -Be 'c\d\' + $result.ExcludePatterns[2] | Should -Be 'e' } - It '分号分隔同样支持' { - $result = ConvertFrom-BackupListLine -Line 'C:\x :: a;b' - $result.ExcludePatterns.Count | Should -Be 2 + It '排除模式的引号只保护空白,不保护逗号' { + $spaced = ConvertFrom-BackupListLine -Line "Foo :- 'Default\Code Cache'" + @($spaced.ExcludePatterns).Count | Should -Be 1 + $spaced.ExcludePatterns[0] | Should -Be 'Default\Code Cache' + + $comma = ConvertFrom-BackupListLine -Line "Foo :- 'a,b'" + @($comma.ExcludePatterns).Count | Should -Be 2 } - It '引号只包路径时排除表正常解析' { - $result = ConvertFrom-BackupListLine -Line '"C:\Programs\Foo" :: logs\' - $result.Path | Should -Be 'C:\Programs\Foo' - $result.ExcludePatterns[0] | Should -Be 'logs\' + It ':+ 的包含项是 <归档内相对路径>:<宿主机绝对路径>' { + $result = ConvertFrom-BackupListLine -Line 'Foo :+ Modules:D:\extra,More:D:\extra2' + $result.Overrides.ContainsKey('Include') | Should -BeTrue + (@($result.Includes) -join '|') | Should -Be 'Modules:D:\extra|More:D:\extra2' } - It '整行被一对引号包住时,:: 之后的排除表不被吞进路径(真实踩过的坑)' { - $result = ConvertFrom-BackupListLine -Line '"C:\a b\CodeSpace :: Shuery-Shuai\immortalwrt\"' - $result.Path | Should -Be 'C:\a b\CodeSpace' - $result.ExcludePatterns.Count | Should -Be 1 - $result.ExcludePatterns[0] | Should -Be 'Shuery-Shuai\immortalwrt\' + It ':encrypt 与 :!encrypt 控制该条目的加密开关' { + $on = ConvertFrom-BackupListLine -Line 'Foo :encrypt' + $on.Overrides.ContainsKey('Encrypt') | Should -BeTrue + $on.Overrides['Encrypt'] | Should -BeTrue + + $off = ConvertFrom-BackupListLine -Line 'Foo :!encrypt' + $off.Overrides['Encrypt'] | Should -BeFalse } - It '@ 标记单独出现' { - $result = ConvertFrom-BackupListLine -Line '.ssh @encrypt' - $result.Path | Should -Be '.ssh' - $result.Flags | Should -Contain 'encrypt' + It '@ Key=''Value'' 覆盖 Path / Exclude / Include / Encrypt' { + $path = ConvertFrom-BackupListLine -Line "Foo @ Path='D:\x'" + $path.Overrides['Path'] | Should -Be 'D:\x' + + $exclude = ConvertFrom-BackupListLine -Line "Foo @ Exclude='a,b'" + (@($exclude.ExcludePatterns) -join '|') | Should -Be 'a|b' + + $include = ConvertFrom-BackupListLine -Line "Foo @ Include='Modules:D:\extra'" + (@($include.Includes) -join '|') | Should -Be 'Modules:D:\extra' + + $encryptOn = ConvertFrom-BackupListLine -Line "Foo @ Encrypt='`$true'" + $encryptOn.Overrides['Encrypt'] | Should -BeTrue + $encryptOff = ConvertFrom-BackupListLine -Line "Foo @ Encrypt='false'" + $encryptOff.Overrides['Encrypt'] | Should -BeFalse } - It '@ 标记跟在排除表后面' { - $result = ConvertFrom-BackupListLine -Line 'C:\x :: a,b @encrypt,pathname' - $result.Flags | Should -Contain 'encrypt' - $result.Flags | Should -Contain 'pathname' - $result.ExcludePatterns.Count | Should -Be 2 + It '@ 的键名大小写不敏感,且支持紧跟 @ 的写法' { + $upper = ConvertFrom-BackupListLine -Line "Foo @ PATH='D:\x'" + $upper.Overrides['Path'] | Should -Be 'D:\x' + + $inline = ConvertFrom-BackupListLine -Line "Foo @Exclude='q,w'" + (@($inline.ExcludePatterns) -join '|') | Should -Be 'q|w' } - It ':+ 追加目录(可多个、位置无关)' { - # 每个 :+ 记号后面跟**一个**路径:多个目录要写多个记号。 - # 段内不会按空格再切分——路径本来就可以带空格。 - $result = ConvertFrom-BackupListLine -Line 'Foo :+ D:\a :+ D:\b' + It '历史写法 @encrypt / @!encrypt 仍然被识别成加密覆盖' { + (ConvertFrom-BackupListLine -Line 'Foo @encrypt').Overrides['Encrypt'] | Should -BeTrue + (ConvertFrom-BackupListLine -Line 'Foo @!encrypt').Overrides['Encrypt'] | Should -BeFalse + } + + It '历史写法 @pathname / @root=<名> 进入 Flags' { + (ConvertFrom-BackupListLine -Line 'Foo @pathname').Flags | Should -Contain 'pathname' + (ConvertFrom-BackupListLine -Line 'Foo @root=Bar').Flags | Should -Contain 'root=Bar' + } + + It '未知的 @ 字段进入 UnknownKeys,且不影响其余字段解析' { + $result = ConvertFrom-BackupListLine -Line "Foo @ UnknownKey='v' :- logs\" + $result.UnknownKeys | Should -Contain 'UnknownKey' + $result.Overrides.ContainsKey('Exclude') | Should -BeTrue $result.Path | Should -Be 'Foo' - $result.AddedPaths.Count | Should -Be 2 - $result.AddedPaths[0] | Should -Be 'D:\a' - $result.AddedPaths[1] | Should -Be 'D:\b' - $result.ExcludePatterns.Count | Should -Be 0 } - It ':- 排除,与 :+ 混用且顺序任意' { - $result = ConvertFrom-BackupListLine -Line 'Foo :- logs\ :+ D:\a :- !*Cache' - $result.Path | Should -Be 'Foo' - $result.AddedPaths.Count | Should -Be 1 - $result.AddedPaths[0] | Should -Be 'D:\a' - $result.ExcludePatterns.Count | Should -Be 2 - $result.ExcludePatterns[0] | Should -Be 'logs\' - $result.ExcludePatterns[1] | Should -Be '!*Cache' + It '行尾的 # 说明会成为 Comment' { + $result = ConvertFrom-BackupListLine -Line 'Edge :- !*Cache # 缓存可再生' + $result.Path | Should -Be 'Edge' + (@($result.ExcludePatterns) -join '|') | Should -Be '!*Cache' + $result.Comment | Should -Be '缓存可再生' } - It ':: 与 :- 等价' { - $a = ConvertFrom-BackupListLine -Line 'Foo :: logs\' - $b = ConvertFrom-BackupListLine -Line 'Foo :- logs\' - $a.ExcludePatterns | Should -Be $b.ExcludePatterns + It '路径里紧贴的 # 不会被当成注释' { + $result = ConvertFrom-BackupListLine -Line 'C:\a#b\c' + $result.Path | Should -Be 'C:\a#b\c' + $result.Comment | Should -BeNullOrEmpty } - It '盘符里的单个冒号不被误当分隔符' { - $result = ConvertFrom-BackupListLine -Line 'C:\Programs\Foo :: a\b' - $result.Path | Should -Be 'C:\Programs\Foo' + It '没有说明时 Comment 为空' { + ConvertFrom-BackupListLine -Line 'legendary' | Select-Object -ExpandProperty Comment | Should -BeNullOrEmpty } - It 'root= 标记会被识别出来(备份端据此告警:该功能尚未实现)' { - $result = ConvertFrom-BackupListLine -Line 'Foo @root=Bar' - $result.Flags | Should -Contain 'root=Bar' + It '目标可以带空格:第一个标记之前整段都是目标' { + $bare = ConvertFrom-BackupListLine -Line 'C:\Program Files\App :- logs\' + $bare.Path | Should -Be 'C:\Program Files\App' + $bare.IsName | Should -BeFalse + + $quoted = ConvertFrom-BackupListLine -Line '"C:\Program Files\App" :- logs\' + $quoted.Path | Should -Be 'C:\Program Files\App' + (@($quoted.ExcludePatterns) -join '|') | Should -Be 'logs\' + } + + It '缺少目标(标记出现在第一个位置)会被忽略并告警' { + ConvertFrom-BackupListLine -Line ':- logs' | Should -BeNullOrEmpty + ConvertFrom-BackupListLine -Line "@ Exclude='x'" | Should -BeNullOrEmpty + } + + It '记号切分:引号内的空白不切分,引号本身留在记号里' { + (@(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 '') -join '|') | Should -Be '' + } + + It '记号识别只认完整记号' { + (Test-BaknretMarker -Token '::') | Should -Be 'path' + (Test-BaknretMarker -Token ':-') | Should -Be 'exclude' + (Test-BaknretMarker -Token ':+') | Should -Be 'include' + (Test-BaknretMarker -Token ':encrypt') | Should -Be 'encrypt' + (Test-BaknretMarker -Token ':!encrypt') | Should -Be 'noencrypt' + (Test-BaknretMarker -Token '@') | Should -Be 'at' + (Test-BaknretMarker -Token 'C:\a:-b') | Should -BeNullOrEmpty + (Test-BaknretMarker -Token ':') | Should -BeNullOrEmpty + } + + 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 '""') | Should -Be '' } } @@ -201,6 +344,19 @@ Describe 'BackupList.txt 解析' { Describe '归档命名' { # ============================================================================ + BeforeAll { + $script:NamingRoot = Join-Path $script:Sandbox 'naming' + $script:NamingApp = Join-Path $script:NamingRoot 'Real App' + New-Item -ItemType Directory -Path $script:NamingApp -Force | Out-Null + Set-Content -LiteralPath (Join-Path $script:NamingApp 'keep.txt') 'k' + + $script:NamingCatalog = Write-ListFile -Path (Join-Path $script:NamingRoot 'cat.psd1') -Content @" +@{ + 'my-app' = @{ Main = @{ Path = '$script:NamingApp' } } +} +"@ + } + # 注意:Pester 的 It 名字里出现 $( ) 会被求值,所以标题里不要写子表达式。 It '基础命名规则:末级名_from_上级路径用加号连接' { Get-BackupBaseName -RawPath 'C:\Programs\FooClolor' | Should -Be 'FooClolor_from_C_+Programs' @@ -221,40 +377,254 @@ Describe '归档命名' { $base = Get-BackupBaseName -RawPath 'C:\ac|d?e*f' ($base.IndexOfAny([System.IO.Path]::GetInvalidFileNameChars())) | Should -Be -1 } + + It '%变量% 写法里的 % 会保留在归档名里' { + Get-BackupBaseName -RawPath '%UserProfile%\.ssh' | Should -Be '.ssh_from_%UserProfile%' + } + + It '软件名条目:默认用软件名做归档名' { + $entry = ConvertFrom-BackupListLine -Line 'my-app' + (Get-ItemArchiveName -Entry $entry -CatalogPath $script:NamingCatalog -MaxDepth 3) | Should -Be 'my-app' + } + + It '字面路径条目:仍用路径命名算法(现有清单无需改写)' { + $entry = ConvertFrom-BackupListLine -Line 'C:\Programs\FooClolor' + (Get-ItemArchiveName -Entry $entry -CatalogPath $script:NamingCatalog -MaxDepth 3) | Should -Be 'FooClolor_from_C_+Programs' + } + + It '@pathname 用名录里的真实路径命名,而不是软件名' { + $entry = ConvertFrom-BackupListLine -Line 'my-app @pathname' + $expected = Get-BackupBaseName -RawPath $script:NamingApp + (Get-ItemArchiveName -Entry $entry -CatalogPath $script:NamingCatalog -MaxDepth 3) | Should -Be $expected + } + + It '名录里没有该名字:归档名退回可读目录名,并给出 Error' { + $entry = ConvertFrom-BackupListLine -Line 'no-such-thing' + (Get-ItemArchiveName -Entry $entry -CatalogPath $script:NamingCatalog -MaxDepth 3) | Should -Be 'no-such-thing' + $resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:NamingCatalog -MaxDepth 3 + $resolved.Error | Should -Not -BeNullOrEmpty + @($resolved.Items).Count | Should -Be 0 + } } # ============================================================================ Describe '7z 排除参数翻译' { # ============================================================================ + BeforeAll { + $script:ExcludeTree = Join-Path $script:Sandbox 'exclude-tree' + New-Item -ItemType Directory -Path (Join-Path $script:ExcludeTree 'sub\Cache') -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $script:ExcludeTree 'Keep') -Force | Out-Null + Set-Content -LiteralPath (Join-Path $script:ExcludeTree 'sub\Cache\x.bin') 'x' + Set-Content -LiteralPath (Join-Path $script:ExcludeTree 'Keep\y.bin') 'y' + + function New-TestArchiveItem { + param([string]$ArchivePath, [string]$RealPath = 'C:\nonexistent-path') + New-BaknretArchiveItem -ArchivePath $ArchivePath -RealPath $RealPath -Kind 'path' -Origin 'path' -Exists $false -IsFile $false + } + } + It '相对模式自动补上归档根目录名' { - $arguments = Get-ArchiveExcludeArgument -ItemName 'Foo' -Patterns @('logs\') - $arguments | Should -Contain '-x!Foo\logs' + $result = Get-BaknretExcludeArgument -Item (New-TestArchiveItem -ArchivePath 'Foo') -Patterns @('logs\') + @($result.Arguments) | Should -Contain '-x!Foo\logs' + $result.Error | Should -BeNullOrEmpty } - It '已经带根目录名时不重复前缀' { - $arguments = Get-ArchiveExcludeArgument -ItemName 'Foo' -Patterns @('Foo\logs\') - $arguments | Should -Contain '-x!Foo\logs' + It '模式已带根名前缀时,Split-BaknretPatternScope 先摘掉前缀,结果不重复' { + $item = New-TestArchiveItem -ArchivePath 'Foo' + $map = Split-BaknretPatternScope -Items @($item) -Patterns @('Foo\logs\') + (@($map[0]) -join '|') | Should -Be 'logs' + + $result = Get-BaknretExcludeArgument -Item $item -Patterns @($map[0]) + @($result.Arguments) | Should -Contain '-x!Foo\logs' + @($result.Arguments) | Should -Not -Contain '-x!Foo\Foo\logs' } - It '! 前缀翻译成递归组件匹配' { - $arguments = Get-ArchiveExcludeArgument -ItemName 'User Data' -Patterns @('!*Cache') - $arguments | Should -Contain '-xr!*Cache' + It '! 前缀翻译成递归组件匹配(-xr!)' { + $result = Get-BaknretExcludeArgument -Item (New-TestArchiveItem -ArchivePath 'User Data') -Patterns @('!*Cache') + @($result.Arguments) | Should -Contain '-xr!*Cache' } - It '模式里的空格转成 ? (7z 的模式不支持空格)' { - $arguments = Get-ArchiveExcludeArgument -ItemName 'User Data' -Patterns @('Default\Code Cache') - $arguments | Should -Contain '-x!User?Data\Default\Code?Cache' + It '模式里的空格转成 ?(归档项自己的名字按原样保留)' { + $result = Get-BaknretExcludeArgument -Item (New-TestArchiveItem -ArchivePath 'User Data') -Patterns @('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.Arguments) | Should -Contain '-x!Foo\Default\Code?Cache' } It '生成的参数里绝不出现引号(旧实现 -x!"路径" 让排除全部失效)' { - $arguments = Get-ArchiveExcludeArgument -ItemName 'User Data' -Patterns @('!*Cache', 'Default\Code Cache') - ($arguments -join ' ') | Should -Not -Match '"' + $result = Get-BaknretExcludeArgument -Item (New-TestArchiveItem -ArchivePath 'User Data') -Patterns @('!*Cache', 'Default\Code Cache') + (@($result.Arguments) -join ' ') | Should -Not -Match '"' } It '空模式被忽略' { - $arguments = Get-ArchiveExcludeArgument -ItemName 'Foo' -Patterns @('', ' ', '!') - @($arguments).Count | Should -Be 0 + $result = Get-BaknretExcludeArgument -Item (New-TestArchiveItem -ArchivePath 'Foo') -Patterns @('', ' ', '!') + @($result.Arguments).Count | Should -Be 0 + $result.Error | Should -BeNullOrEmpty + } + + It '!re: 会把正则展开成精确的 -x! 参数' { + $item = New-TestArchiveItem -ArchivePath 'Top' -RealPath $script:ExcludeTree + $result = Get-BaknretExcludeArgument -Item $item -Patterns @('!re:^Cache$') + @($result.Arguments) | Should -Contain '-x!Top\sub\Cache' + + # 命中目录后不再往下走(一个命中只产生一条参数,避免命令行爆炸) + $pruned = Get-BaknretExcludeArgument -Item $item -Patterns @('!re:^sub$') + @($pruned.Arguments) | Should -Contain '-x!Top\sub' + @($pruned.Arguments).Count | Should -Be 1 + } + + It '非法正则给出 Error,而不是抛异常' { + $result = Get-BaknretExcludeArgument -Item (New-TestArchiveItem -ArchivePath 'Top' -RealPath $script:ExcludeTree) -Patterns @('!re:[') + @($result.Arguments).Count | Should -Be 0 + $result.Error | Should -Not -BeNullOrEmpty + } + + It '正则命中数超过上限时明确报错' { + $result = Get-BaknretExcludeArgument -Item (New-TestArchiveItem -ArchivePath 'Top' -RealPath $script:ExcludeTree) ` + -Patterns @('!re:.') -MaxRegexMatches 1 + $result.Error | Should -Not -BeNullOrEmpty + } + + It '参数总长超过安全上限时明确报错' { + $result = Get-BaknretExcludeArgument -Item (New-TestArchiveItem -ArchivePath 'Top') ` + -Patterns @('!*zzz') -MaxCommandLineChars 5 + $result.Error | Should -Not -BeNullOrEmpty + } + + It 'Split-BaknretPatternScope:指名 Slot 的模式只分给那个 Slot' { + $items = @( + New-TestArchiveItem -ArchivePath 'Alpha' + New-TestArchiveItem -ArchivePath 'Beta' + ) + $map = Split-BaknretPatternScope -Items $items -Patterns @('Alpha\logs\') + (@($map[0]) -join '|') | Should -Be 'logs' + @($map[1]).Count | Should -Be 0 + + # 用 / 分隔也一样 + $slash = Split-BaknretPatternScope -Items $items -Patterns @('Beta/sub') + (@($slash[1]) -join '|') | Should -Be 'sub' + @($slash[0]).Count | Should -Be 0 + } + + It 'Split-BaknretPatternScope:没点名任何 Slot 的模式广播给每一项' { + $items = @( + New-TestArchiveItem -ArchivePath 'Alpha' + New-TestArchiveItem -ArchivePath 'Beta' + ) + $map = Split-BaknretPatternScope -Items $items -Patterns @('plain\path', 'Alpha') + (@($map[0]) -join '|') | Should -Be 'plain\path|Alpha' + (@($map[1]) -join '|') | Should -Be 'plain\path|Alpha' + } + + It 'Split-BaknretPatternScope:! 与 !re: 模式广播给每一项' { + $items = @( + New-TestArchiveItem -ArchivePath 'Alpha' + New-TestArchiveItem -ArchivePath 'Beta' + ) + $map = Split-BaknretPatternScope -Items $items -Patterns @('!*Cache', '!re:^x$') + (@($map[0]) -join '|') | Should -Be '!*Cache|!re:^x$' + (@($map[1]) -join '|') | Should -Be '!*Cache|!re:^x$' + } + + It 'Split-BaknretPatternScope:每个归档项都有键(没有模式时是空数组)' { + $items = @( + New-TestArchiveItem -ArchivePath 'Alpha' + New-TestArchiveItem -ArchivePath 'Beta' + ) + $map = Split-BaknretPatternScope -Items $items -Patterns @() + $map.ContainsKey(0) | Should -BeTrue + $map.ContainsKey(1) | Should -BeTrue + @($map[0]).Count | Should -Be 0 + } + + It 'Merge-BaknretExcludeArgument 去重且保持顺序' { + $merged = Merge-BaknretExcludeArgument -ArgumentLists @(@('-x!a', '-x!b'), @('-x!b', '-x!c')) + (@($merged) -join ' ') | Should -Be '-x!a -x!b -x!c' + } + + It '多 Slot 条目:逐项分配 + 翻译 + 去重合成一份参数' { + $items = @( + 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 + ) + $patterns = @('Alpha\logs\', '!*Cache', 'Beta\sub', '!*Cache') + $map = Split-BaknretPatternScope -Items $items -Patterns $patterns + $lists = @() + for ($index = 0; $index -lt $items.Count; $index++) { + $lists += , @((Get-BaknretExcludeArgument -Item $items[$index] -Patterns @($map[$index])).Arguments) + } + $effective = @(Merge-BaknretExcludeArgument -ArgumentLists $lists) + $effective | Should -Contain '-x!Alpha\logs' + $effective | Should -Contain '-x!Beta\sub' + $effective | Should -Contain '-xr!*Cache' + # 同一个 -xr!*Cache 只留一份 + @($effective | Where-Object { $_ -eq '-xr!*Cache' }).Count | Should -Be 1 + } +} + +# ============================================================================ +Describe '归档项与暂存目录' { +# ============================================================================ + + BeforeAll { + $script:StagingRoot = Join-Path $script:Sandbox 'staging' + $script:StagingDir = Join-Path $script:StagingRoot 'real-dir' + $script:StagingFile = Join-Path $script:StagingRoot 'settings.json' + New-Item -ItemType Directory -Path (Join-Path $script:StagingDir 'inner') -Force | Out-Null + Set-Content -LiteralPath (Join-Path $script:StagingDir 'inner\a.txt') 'a' + Set-Content -LiteralPath $script:StagingFile 'file-content' + } + + It 'New-BaknretArchiveItem 规范化归档内路径并算出 TopName' { + $item = New-BaknretArchiveItem -ArchivePath '\Alpha\Sub\' -RealPath $script:StagingDir -Kind 'slot' -Slot 'Alpha' + $item.ArchivePath | Should -Be 'Alpha\Sub' + $item.TopName | Should -Be 'Alpha' + $item.Kind | Should -Be 'slot' + $item.Slot | Should -Be 'Alpha' + $item.Origin | Should -Be 'catalog' + @($item.Exclude).Count | Should -Be 0 + } + + It 'Get-BaknretArchiveTopName 取归档内相对路径的第一段' { + (Get-BaknretArchiveTopName -ArchivePath 'A\B\c.txt') | Should -Be 'A' + (Get-BaknretArchiveTopName -ArchivePath ' A ') | Should -Be 'A' + (Get-BaknretArchiveTopName -ArchivePath '') | Should -Be '' + } + + It '目录项用 junction 挂进暂存目录,文件项用硬链接(名字就是归档内路径)' { + $items = @( + 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 'Cfg' -RealPath $script:StagingFile -Kind 'slot' -Slot 'Cfg' -Exists $true -IsFile $true + ) + + $stage = New-BaknretArchiveStaging -Items $items + try { + $alpha = Get-Item -LiteralPath (Join-Path $stage 'Alpha') -Force + $alpha.LinkType | Should -Be 'Junction' + + $nested = Get-Item -LiteralPath (Join-Path $stage 'Nested\Deep') -Force + $nested.LinkType | Should -Be 'Junction' + + # 文件项:归档内的名字就叫 Cfg(没有扩展名),内容与源文件一致 + $cfg = Get-Item -LiteralPath (Join-Path $stage 'Cfg') -Force + $cfg.PSIsContainer | Should -BeFalse + (Get-Content -LiteralPath $cfg.FullName -Raw).Trim() | Should -Be 'file-content' + } finally { + Remove-BaknretArchiveStaging -Root $stage + } + } + + It 'Remove-BaknretArchiveStaging 只删连接点,不顺着走进真实目录' { + $stage = New-BaknretArchiveStaging -Items @( + New-BaknretArchiveItem -ArchivePath 'Alpha' -RealPath $script:StagingDir -Kind 'slot' -Slot 'Alpha' -Exists $true -IsFile $false + ) + Remove-BaknretArchiveStaging -Root $stage + + Test-Path -LiteralPath $stage | Should -BeFalse + Test-Path -LiteralPath (Join-Path $script:StagingDir 'inner\a.txt') | Should -BeTrue } } @@ -299,6 +669,23 @@ Describe 'manifest 与配置' { $again.items['demo'].verified | Should -BeTrue } + It 'manifest 原样保存 layouts(name/kind),全新恢复时靠它判断目录还是文件' { + $path = Join-Path $script:Sandbox 'layouts\manifest.json' + $manifest = Read-BaknretManifest -Path $path + $manifest.items['app'] = [pscustomobject]@{ + archive = 'app.7z' + layouts = @( + [pscustomobject]@{ name = 'Data'; kind = 'dir' } + [pscustomobject]@{ name = 'Cfg'; kind = 'file' } + ) + } + Write-BaknretManifest -Path $path -Manifest $manifest | Out-Null + + $again = Read-BaknretManifest -Path $path + @($again.items['app'].layouts).Count | Should -Be 2 + (@($again.items['app'].layouts | ForEach-Object { $_.name + ':' + $_.kind }) -join '|') | Should -Be 'Data:dir|Cfg:file' + } + It 'manifest 损坏时不抛异常,而是重建空清单' { $path = Write-ListFile -Path (Join-Path $script:Sandbox 'broken.json') -Content '{ this is not json' { $script:broken = Read-BaknretManifest -Path $path } | Should -Not -Throw @@ -338,109 +725,342 @@ Describe '软件名录' { # ============================================================================ BeforeAll { - $script:CatalogSandbox = Join-Path $script:Sandbox 'catalog' - New-Item -ItemType Directory -Path $script:CatalogSandbox -Force | Out-Null + $script:CatRoot = Join-Path $script:Sandbox 'catalog' + New-Item -ItemType Directory -Path (Join-Path $script:CatRoot 'legendary_2.0.4') -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $script:CatRoot 'LegendarySomething') -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $script:CatRoot 'dash-1.2') -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $script:CatRoot 'dup_1') -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $script:CatRoot 'dup_2') -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $script:CatRoot 'real app') -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $script:CatRoot 'other') -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $script:CatRoot 'inc') -Force | Out-Null + Set-Content -LiteralPath (Join-Path $script:CatRoot 'real app\keep.txt') 'k' + Set-Content -LiteralPath (Join-Path $script:CatRoot 'file.txt') 'file-content' - $script:CatDir = Join-Path $script:CatalogSandbox 'src' - New-Item -ItemType Directory -Path (Join-Path $script:CatDir 'legendary_2.0.4') -Force | Out-Null - New-Item -ItemType Directory -Path (Join-Path $script:CatDir 'LegendarySomething') -Force | Out-Null - New-Item -ItemType Directory -Path (Join-Path $script:CatDir 'Real App') -Force | Out-Null + $script:CatReal = Join-Path $script:CatRoot 'real app' + $script:CatOther = Join-Path $script:CatRoot 'other' + $script:CatInc = Join-Path $script:CatRoot 'inc' - # 先把路径算好再拼名录内容:在 @"..."@ 里嵌 $(...) 再嵌单引号很容易把 - # 收尾的引号吃掉(踩过一次:生成的 .psd1 直接解析失败,而"名录读不到" - # 会让下面几条断言静默通过)。 - $realApp = Join-Path $script:CatDir 'Real App' - $legendarySomething = Join-Path $script:CatDir 'LegendarySomething' - $legendaryTarget = Join-Path $script:CatDir 'legendary' - - $script:CatFile = Write-ListFile -Path (Join-Path $script:CatalogSandbox 'SoftwareCatalog.psd1') -Content @" + # 路径先算好再拼名录内容:在 @"..."@ 里嵌 $ 很容易把引号吃掉, + # 生成一个解析不了的 psd1,而"名录读不到"会让断言静默通过。 + $script:CatFile = Write-ListFile -Path (Join-Path $script:CatRoot 'SoftwareCatalog.psd1') -Content @" @{ - 'my-app' = '$realApp' - 'dotted' = '$realApp' - 'multi' = @{ Dirs = @('$realApp', '$legendarySomething') } - 'legendary' = '$legendaryTarget' + 'single' = @{ Main = @{ Path = '$script:CatReal'; Description = '主 Slot' } } + 'multi' = @{ + Zeta = @{ Path = '$script:CatOther' } + Alpha = @{ Path = '$script:CatReal'; Exclude = '!*Cache,logs\'; Description = 'alpha slot' } + } + 'partial' = @{ Ok = @{ Path = '$script:CatReal' }; Gone = @{ Path = '$script:CatRoot\not-here' } } + 'unres' = @{ Gone = @{ Path = '$script:CatRoot\nope' } } + 'legendary' = @{ L = @{ Path = '$script:CatRoot\legendary' } } + 'dashy' = @{ D = @{ Path = '$script:CatRoot\dash' } } + 'dupslot' = @{ D = @{ Path = '$script:CatRoot\dup' } } + 'fileslot' = @{ Cfg = @{ Path = '$script:CatRoot\file.txt'; Encrypt = `$true } } + 'envpath' = @{ E = @{ Path = '%TEMP%' } } + 'encapp' = @{ A = @{ Path = '$script:CatReal'; Encrypt = `$true }; B = @{ Path = '$script:CatOther' } } + 'incapp' = @{ A = @{ Path = '$script:CatReal'; Include = 'Mods:$script:CatInc' } } + 'conflict' = @{ Data = @{ Path = '$script:CatReal' } } + 'badslot' = @{ S = 'not-a-map' } + 'nopath' = @{ S = @{ Description = 'no path' } } } "@ - # 名录一旦写坏,后面几条测试会全部"静默通过"(目录查不到 → 候选集为空, - # Should -Not -Contain 自然成立)。这里先把"名录本身能读进来"钉死。 - $script:CatalogEntryCount = (Get-SoftwareCatalog -Path $script:CatFile -MaxDepth 3).Count + $script:LegacyFile = Write-ListFile -Path (Join-Path $script:CatRoot 'Legacy.psd1') -Content @" +@{ + 'bare' = 'C:\x' + 'arr' = @('C:\a', 'C:\b') + 'objarr' = @(@{ Path = 'C:\a' }, @{ Path = 'C:\b' }) + 'dirstyle' = @{ Dirs = @('C:\a', 'C:\b') } +} +"@ } - It '名录解析:裸键、引号键、带 - 与 . 的名字' { - $catalog = Get-SoftwareCatalog -Path $script:CatFile -MaxDepth 3 - $catalog.ContainsKey('my-app') | Should -BeTrue - $catalog.ContainsKey('dotted') | Should -BeTrue - $catalog['my-app'].Name | Should -Be 'my-app' + It '名录解析:条目与 Slot 的字段集合就是新契约' { + $catalog = Get-SoftwareCatalog -Path $script:CatFile -MaxDepth 3 -NoCache + $entry = $catalog['single'] + (@($entry.PSObject.Properties.Name) -join ',') | Should -Be 'Name,Path,Description,Slots,Kind,Missing,Error,Raw' + $entry.Name | Should -Be 'single' + $entry.Path | Should -Be $script:CatReal + $entry.Description | Should -Be '主 Slot' + $entry.Kind | Should -Be 'Single' + @($entry.Missing).Count | Should -Be 0 + $entry.Error | Should -BeNullOrEmpty + + $slot = @($entry.Slots)[0] + (@($slot.PSObject.Properties.Name) -join ',') | Should -Be 'Name,Declared,Resolved,Exists,IsFile,Suffixed,Description,Exclude,Include,Encrypt' + $slot.Name | Should -Be 'Main' + $slot.Declared | Should -Be $script:CatReal + $slot.Resolved | Should -Be $script:CatReal + $slot.Exists | Should -BeTrue + $slot.IsFile | Should -BeFalse + $slot.Suffixed | Should -BeFalse + $slot.Encrypt | Should -BeFalse } - It '名录解析:目录带版本后缀时按前缀补全(legendary -> legendary_2.0.4)' { - $catalog = Get-SoftwareCatalog -Path $script:CatFile -MaxDepth 3 - $catalog['legendary'].ResolvedPath | Should -Be (Join-Path $script:CatDir 'legendary_2.0.4') - $catalog['legendary'].Kind | Should -Be 'Single' + It '一个软件多个 Slot:Kind=Multi,Slot 按名字排序' { + $catalog = Get-SoftwareCatalog -Path $script:CatFile -MaxDepth 3 -NoCache + $catalog['multi'].Kind | Should -Be 'Multi' + @($catalog['multi'].Slots).Count | Should -Be 2 + (@($catalog['multi'].Slots | ForEach-Object { $_.Name }) -join ',') | Should -Be 'Alpha,Zeta' + $catalog['multi'].Slots[0].Description | Should -Be 'alpha slot' + (@($catalog['multi'].Slots[0].Exclude) -join '|') | Should -Be '!*Cache|logs\' } - It '名录解析:不会把 Legendary 误配成 LegendarySomething' { - $script:CatalogEntryCount | Should -Be 4 # 先确认名录真的读进来了,避免空集静默通过 - $catalog = Get-SoftwareCatalog -Path $script:CatFile -MaxDepth 3 - $catalog['legendary'].Variants | Should -Contain (Join-Path $script:CatDir 'legendary_2.0.4') - $catalog['legendary'].Variants | Should -Not -Contain (Join-Path $script:CatDir 'LegendarySomething') + It 'Kind:Single / Multi / Partial / Unresolved / Invalid' { + $catalog = Get-SoftwareCatalog -Path $script:CatFile -MaxDepth 3 -NoCache + $catalog['single'].Kind | Should -Be 'Single' + $catalog['multi'].Kind | Should -Be 'Multi' + $catalog['partial'].Kind | Should -Be 'Partial' + $catalog['unres'].Kind | Should -Be 'Unresolved' + $catalog['badslot'].Kind | Should -Be 'Invalid' + $catalog['nopath'].Kind | Should -Be 'Invalid' + + @($catalog['partial'].Missing).Count | Should -Be 1 + @($catalog['unres'].Missing).Count | Should -Be 1 + $catalog['badslot'].Error | Should -Match 'Slot S' + $catalog['nopath'].Error | Should -Match '缺少 Path' } - It '软件名条目:默认用软件名做归档名' { - $entry = ConvertFrom-BackupListLine -Line 'my-app' - (Get-ItemArchiveName -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3) | Should -Be 'my-app' + It '前缀补全:<名>_<后缀> 与 <名>-<后缀> 都会被补全' { + $catalog = Get-SoftwareCatalog -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].Suffixed | Should -BeTrue + + $catalog['dashy'].Slots[0].Resolved | Should -Be (Join-Path $script:CatRoot 'dash-1.2') + $catalog['dashy'].Slots[0].Suffixed | Should -BeTrue } - It '字面路径条目:仍用路径命名算法(现有清单无需改写)' { - $entry = ConvertFrom-BackupListLine -Line 'C:\Programs\FooClolor' - (Get-ItemArchiveName -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3) | Should -Be 'FooClolor_from_C_+Programs' + It '不会把 Legendary 误配成 LegendarySomething' { + $catalog = Get-SoftwareCatalog -Path $script:CatFile -MaxDepth 3 -NoCache + $catalog['legendary'].Slots[0].Resolved | Should -Not -Be (Join-Path $script:CatRoot 'LegendarySomething') } - It '@pathname 用真实路径命名,而不是软件名' { - $entry = ConvertFrom-BackupListLine -Line 'my-app @pathname' - $expected = Get-BackupBaseName -RawPath (Join-Path $script:CatDir 'Real App') - (Get-ItemArchiveName -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3) | Should -Be $expected + It '一个 Slot 命中多个候选目录:报 Error,绝不悄悄挑一棵树' { + $catalog = Get-SoftwareCatalog -Path $script:CatFile -MaxDepth 3 -NoCache + $entry = $catalog['dupslot'] + $entry.Error | Should -Match '只能对应一个目录' + $entry.Error | Should -Match 'dup_1' + $entry.Error | Should -Match 'dup_2' + @($entry.Slots).Count | Should -Be 1 + $entry.Slots[0].Suffixed | Should -BeTrue } - It '名录里没有该名字:BaseName 退回可读目录名,并给出 Error' { - $entry = ConvertFrom-BackupListLine -Line 'no-such-thing' - $resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3 - $resolved.BaseName | Should -Be 'no-such-thing' - $resolved.Sources.Count | Should -Be 0 - $resolved.Error | Should -Not -BeNullOrEmpty + It '文件 Slot:Path 指向文件时 IsFile 为真,Encrypt 也带上' { + $catalog = Get-SoftwareCatalog -Path $script:CatFile -MaxDepth 3 -NoCache + $slot = $catalog['fileslot'].Slots[0] + $slot.IsFile | Should -BeTrue + $slot.Encrypt | Should -BeTrue } - It '名录里的路径不存在时仍给出 Sources(恢复要靠它还原回原位)' { - $missingCatalog = Write-ListFile -Path (Join-Path $script:CatalogSandbox 'Missing.psd1') -Content "@{ 'gone' = 'C:\definitely-not-here-98765' }" - $entry = ConvertFrom-BackupListLine -Line 'gone' - $resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $missingCatalog -MaxDepth 3 - $resolved.Sources.Count | Should -Be 1 - $resolved.Error | Should -Not -BeNullOrEmpty + It '%变量% 会在名录路径里展开' { + $catalog = Get-SoftwareCatalog -Path $script:CatFile -MaxDepth 3 -NoCache + $catalog['envpath'].Slots[0].Resolved | Should -Be ([Environment]::ExpandEnvironmentVariables('%TEMP%')) + } + + It '$( ... ) 子表达式会被求值(含嵌套)' { + (Expand-CatalogPathText -Text '$(Join-Path $env:TEMP "s")') | Should -Be (Join-Path $env:TEMP 's') + (Expand-CatalogPathText -Text '$(Join-Path $env:TEMP "$(Join-Path ''a'' ''b'')")') | Should -Be (Join-Path $env:TEMP 'a\b') + # 括号不配对时原样保留,不抛异常 + (Expand-CatalogPathText -Text '$(Join-Path') | Should -Be '$(Join-Path' + } + + It '读取结果按"路径 + 时间戳 + 长度 + 内容 MD5"缓存' { + $first = Get-SoftwareCatalog -Path $script:CatFile -MaxDepth 3 + $second = Get-SoftwareCatalog -Path $script:CatFile -MaxDepth 3 + [object]::ReferenceEquals($first, $second) | Should -BeTrue + + $fresh = Get-SoftwareCatalog -Path $script:CatFile -MaxDepth 3 -NoCache + [object]::ReferenceEquals($first, $fresh) | Should -BeFalse + } + + It '名录文件内容变了以后缓存自动失效' { + $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' + + 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' } It 'Includes:分文件维护的名录会被合并' { - Write-ListFile -Path (Join-Path $script:CatalogSandbox 'Extra.psd1') -Content "@{ 'extra-app' = '$script:CatDir' }" | Out-Null - $main = Write-ListFile -Path (Join-Path $script:CatalogSandbox 'Main.psd1') -Content "@{ Includes = @('Extra.psd1'); 'main-app' = '$script:CatDir' }" - $catalog = Get-SoftwareCatalog -Path $main -MaxDepth 3 + 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' } } }" + $catalog = Get-SoftwareCatalog -Path $main -MaxDepth 3 -NoCache $catalog.ContainsKey('main-app') | Should -BeTrue $catalog.ContainsKey('extra-app') | Should -BeTrue } + It '旧的裸字符串 / 字符串数组 / 对象数组写法都会报 ERROR 并被跳过' { + $catalog = Get-SoftwareCatalog -Path $script:LegacyFile -MaxDepth 3 -NoCache + $catalog.ContainsKey('bare') | Should -BeFalse + $catalog.ContainsKey('arr') | Should -BeFalse + $catalog.ContainsKey('objarr') | Should -BeFalse + } + + It '旧的 @{ Dirs = @(...) } 写法不再展开:留下 Invalid 条目和原因' { + $catalog = Get-SoftwareCatalog -Path $script:LegacyFile -MaxDepth 3 -NoCache + $catalog.ContainsKey('dirstyle') | Should -BeTrue + $catalog['dirstyle'].Kind | Should -Be 'Invalid' + $catalog['dirstyle'].Error | Should -Not -BeNullOrEmpty + @($catalog['dirstyle'].Slots).Count | Should -Be 0 + } + It '软件名会被规范化成合法文件名' { $clean = Format-CatalogName -Name 'ac:d/e' ($clean.IndexOfAny([System.IO.Path]::GetInvalidFileNameChars())) | Should -Be -1 + (Format-CatalogName -Name ' My App ') | Should -Be 'My App' + } +} + +# ============================================================================ +Describe 'Resolve-BackupEntry:条目解析' { +# ============================================================================ + + BeforeAll { + $script:CatRoot = Join-Path $script:Sandbox 'catalog' + $script:CatFile = Join-Path $script:CatRoot 'SoftwareCatalog.psd1' + $script:CatReal = Join-Path $script:CatRoot 'real app' + $script:CatOther = Join-Path $script:CatRoot 'other' + $script:CatInc = Join-Path $script:CatRoot 'inc' } - # ---- 回归:Resolve-BackupEntry 曾经在给 $rootName 赋值之前就引用它 ---- - It '[回归] 名录里没有该名字时,不会把调用方作用域里残留的 $rootName 泄漏进返回值' { - # PowerShell 是动态作用域:函数会沿着**调用方**的作用域链找变量。 - # 修复前 $rootName 在 return 之后才赋值,于是这里会读到 'LEAKED'。 - $rootName = 'LEAKED' + It 'resolve 与归档项的字段集合就是新契约(旧字段已删除)' { + $entry = ConvertFrom-BackupListLine -Line 'single' + $resolved = Resolve-BackupEntry -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.Items)[0].PSObject.Properties.Name | Should -Contain 'ArchivePath' + @($resolved.Items)[0].PSObject.Properties.Name | Should -Contain 'Origin' + $resolved.PSObject.Properties['Sources'] | Should -BeNullOrEmpty + } + + It '软件名条目:isName/CatalogEntry/BaseName/ArchiveFlavor/Source' { + $entry = ConvertFrom-BackupListLine -Line 'single' + $resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3 + $resolved.IsName | Should -BeTrue + $resolved.BaseName | Should -Be 'single' + $resolved.ArchiveFlavor | Should -Be 'name' + $resolved.Source | Should -Be 'single' + $resolved.CatalogEntry | Should -Not -BeNullOrEmpty + $resolved.Error | Should -BeNullOrEmpty + $resolved.Blocking | Should -BeNullOrEmpty + } + + It '软件名条目:Items 来自 Slot(ArchivePath=Slot 名,Kind=slot,Origin=catalog)' { + $entry = ConvertFrom-BackupListLine -Line 'multi' + $resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3 + @($resolved.Items).Count | Should -Be 2 + (@($resolved.Items | ForEach-Object { $_.ArchivePath }) -join ',') | Should -Be 'Alpha,Zeta' + (@($resolved.Items | ForEach-Object { $_.Kind }) -join ',') | Should -Be 'slot,slot' + (@($resolved.Items | ForEach-Object { $_.Origin }) -join ',') | Should -Be 'catalog,catalog' + (@($resolved.Items | ForEach-Object { $_.TopName }) -join ',') | Should -Be 'Alpha,Zeta' + $resolved.Items[0].Slot | Should -Be 'Alpha' + $resolved.Items[0].RealPath | Should -Be $script:CatReal + } + + It '字面路径条目:一个 path 项(ArchivePath 是末级名)' { + $entry = ConvertFrom-BackupListLine -Line 'C:\Programs\FooClolor' + $resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3 + $resolved.IsName | Should -BeFalse + $resolved.ArchiveFlavor | Should -Be 'path' + @($resolved.Items).Count | Should -Be 1 + $resolved.Items[0].ArchivePath | Should -Be 'FooClolor' + $resolved.Items[0].Kind | Should -Be 'path' + $resolved.Items[0].Origin | Should -Be 'path' + $resolved.Items[0].RealPath | Should -Be 'C:\Programs\FooClolor' + } + + It '字面路径条目上的 @ Path= 覆盖目标路径,但归档名仍按原路径' { + $entry = ConvertFrom-BackupListLine -Line "C:\Programs\Foo @ Path='D:\Elsewhere'" + $resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3 + $resolved.Items[0].RealPath | Should -Be 'D:\Elsewhere' + $resolved.BaseName | Should -Be 'Foo_from_C_+Programs' + } + + It '名录里的路径不存在时仍给出 Items(恢复要靠它还原回原位)' { + $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' + $resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $missingCatalog -MaxDepth 3 + @($resolved.Items).Count | Should -Be 1 + $resolved.Items[0].Exists | Should -BeFalse + $resolved.Items[0].RealPath | Should -Be 'C:\definitely-not-here-98765' + } + + It ':: 覆盖 Path:单 Slot 条目直接生效' { + $entry = ConvertFrom-BackupListLine -Line "single :: $script:CatOther" + $resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3 + @($resolved.Items).Count | Should -Be 1 + $resolved.Items[0].ArchivePath | Should -Be 'Main' + $resolved.Items[0].RealPath | Should -Be $script:CatOther + $resolved.Blocking | Should -BeNullOrEmpty + } + + It ':: / @ Path= 覆盖遇到多 Slot 条目 -> Blocking(不猜是哪一个)' { + foreach ($line in @("multi :: $script:CatOther", "multi @ Path='$script:CatOther'")) { + $entry = ConvertFrom-BackupListLine -Line $line + $resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3 + @($resolved.Items).Count | Should -Be 0 + $resolved.Blocking | Should -Match '不能用一个' + } + } + + It '条目级 :- 覆盖名录里的排除:HasExcludeOverride 为真' { + $entry = ConvertFrom-BackupListLine -Line 'single :- logs\,!*Cache' + $resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3 + $resolved.HasExcludeOverride | Should -BeTrue + (@($resolved.ExcludePatterns) -join '|') | Should -Be 'logs\|!*Cache' + } + + It '名录 Slot 的 Include 会追加成 include 项' { + $entry = ConvertFrom-BackupListLine -Line 'incapp' + $resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3 + @($resolved.Items).Count | Should -Be 2 + $include = @($resolved.Items | Where-Object { $_.Origin -eq 'include' }) + $include.Count | Should -Be 1 + $include[0].ArchivePath | Should -Be 'Mods' + $include[0].Kind | Should -Be 'include' + $include[0].RealPath | Should -Be $script:CatInc + } + + It '条目级 :+ / @ Include= 覆盖名录里的 Include' { + foreach ($line in @("incapp :+ Other:$script:CatOther", "incapp @ Include='Other:$script:CatOther'")) { + $entry = ConvertFrom-BackupListLine -Line $line + $resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3 + $resolved.HasIncludeOverride | Should -BeTrue + (@($resolved.Items | ForEach-Object { $_.ArchivePath }) -join ',') | Should -Be 'A,Other' + @($resolved.Items)[1].RealPath | Should -Be $script:CatOther + } + } + + It '加密:名录里各 Slot 取或;条目级 :encrypt / :!encrypt 覆盖' { + (Resolve-BackupEntry -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-BackupEntry -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-BackupEntry -Entry (ConvertFrom-BackupListLine -Line "single @ Encrypt='true'") -CatalogPath $script:CatFile -MaxDepth 3).Encrypt | Should -BeTrue + } + + It '方向标记会传递到 Resolve-BackupEntry.Direction' { + (Resolve-BackupEntry -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-BackupEntry -Entry (ConvertFrom-BackupListLine -Line 'single') -CatalogPath $script:CatFile -MaxDepth 3).Direction | Should -Be 'both' + } + + It '归档内路径冲突(include 与 Slot 同名)-> Blocking' { + $entry = ConvertFrom-BackupListLine -Line "conflict :+ Data:$script:CatOther" + $resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3 + $resolved.Blocking | Should -Match '归档内路径冲突' + } + + It '归档内路径冲突(父子关系)-> Blocking' { + $entry = ConvertFrom-BackupListLine -Line "conflict :+ Data\sub:$script:CatOther" + $resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3 + $resolved.Blocking | Should -Match '父子关系' + } + + It '名录里没有该名字:Error 给出,Items 为空' { $entry = ConvertFrom-BackupListLine -Line 'no-such-thing' $resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:CatFile -MaxDepth 3 - $resolved.RootName | Should -Not -Be 'LEAKED' - $resolved.RootName | Should -Be 'no-such-thing' + $resolved.Error | Should -Match 'no-such-thing' + @($resolved.Items).Count | Should -Be 0 } } @@ -480,7 +1100,9 @@ Describe '集成:真实 7z 压缩与排除规则' -Skip:(-not $script:HasSeven It '排除规则与空格处理在真实归档上生效' { $patterns = @('!*Cache', 'component_crx_cache', 'Default\Code Cache', 'Default\Extensions', 'Default\IndexedDB') - $excludeArguments = Get-ArchiveExcludeArgument -ItemName $script:IntegrationItem -Patterns $patterns + $item = New-BaknretArchiveItem -ArchivePath $script:IntegrationItem -RealPath $script:IntegrationPath ` + -Kind 'path' -Origin 'path' -Exists $true -IsFile $false + $excludeArguments = @((Get-BaknretExcludeArgument -Item $item -Patterns $patterns).Arguments) $archive = Join-Path $script:IntegrationRoot 'excl.7z' $arguments = @('a', '-t7z', '-mx=1', '-bso0', '-bsp0') + $excludeArguments + @($archive, $script:IntegrationItem) @@ -523,7 +1145,7 @@ Describe '集成:真实 7z 压缩与排除规则' -Skip:(-not $script:HasSeven } # ============================================================================ -Describe '集成:Backup.ps1 / Restore.ps1 端到端' -Skip:(-not ($script:HasSevenZip -and $script:HasPwsh)) { +Describe '集成:Backup.ps1 / Restore.ps1 端到端(字面路径条目)' -Skip:(-not ($script:HasSevenZip -and $script:HasPwsh)) { # ============================================================================ BeforeAll { @@ -532,7 +1154,7 @@ Describe '集成:Backup.ps1 / Restore.ps1 端到端' -Skip:(-not ($script:HasS $script:E2EBackupDir = Join-Path $script:E2ERoot 'Backups' $script:E2EList = Join-Path $script:E2ERoot 'list.txt' $script:E2EHashes = New-VerifiableSourceTree -Root $script:E2ESource - Write-ListFile -Path $script:E2EList -Content "# e2e`n$script:E2ESource :: logs\,!*Cache`n" | Out-Null + Write-ListFile -Path $script:E2EList -Content "# e2e`n$script:E2ESource :- logs\,!*Cache`n" | Out-Null $script:BackupRun = Invoke-BaknretScript -Script $script:BackupScript -Parameters @{ BackupListPath = $script:E2EList @@ -542,22 +1164,26 @@ Describe '集成:Backup.ps1 / Restore.ps1 端到端' -Skip:(-not ($script:HasS } $script:E2EManifestPath = Join-Path $script:E2EBackupDir 'manifest.json' $script:E2EManifest = Read-BaknretManifest -Path $script:E2EManifestPath + $script:E2EArchive = @(Get-ChildItem -LiteralPath $script:E2EBackupDir -File -Filter *.7z)[0] } It '备份退出码为 0(旧实现会把成功的压缩判成失败)' { $script:BackupRun.ExitCode | Should -Be 0 } - It '归档已生成且 manifest 记录了条目、动作与校验结果' { - $archives = @(Get-ChildItem -LiteralPath $script:E2EBackupDir -File -Filter *.7z) - $archives.Count | Should -Be 1 - $archives[0].Length | Should -BeGreaterThan 0 + It '归档已生成且 manifest 记录了条目、动作、校验结果与 layouts' { + @(Get-ChildItem -LiteralPath $script:E2EBackupDir -File -Filter *.7z).Count | Should -Be 1 + $script:E2EArchive.Length | Should -BeGreaterThan 0 - $record = $script:E2EManifest.items[$archives[0].BaseName] + $record = $script:E2EManifest.items[$script:E2EArchive.BaseName] $record | Should -Not -BeNullOrEmpty $record.action | Should -Be 'backed-up' $record.verified | Should -BeTrue $record.exitCode | Should -Be 0 + $record.roots | Should -Contain 'My Code Space' + @($record.layouts).Count | Should -Be 1 + $record.layouts[0].name | Should -Be 'My Code Space' + $record.layouts[0].kind | Should -Be 'dir' } It '备份过程写了日志文件' { @@ -565,11 +1191,12 @@ Describe '集成:Backup.ps1 / Restore.ps1 端到端' -Skip:(-not ($script:HasS $logs.Count | Should -BeGreaterThan 0 } - It '归档里保留了应当保留的内容,且不含被排除项' { + It '字面路径条目沿用 <末级名> 布局;归档里保留应保留内容、不含被排除项' { + (Get-BaknretArchiveTopName -ArchivePath 'My Code Space\keep.txt') | Should -Be 'My Code Space' + $verify = Join-Path $script:E2ERoot 'verify' New-Item -ItemType Directory -Path $verify -Force | Out-Null - $archive = @(Get-ChildItem -LiteralPath $script:E2EBackupDir -File -Filter *.7z)[0] - (Invoke-ExternalCommand -FilePath $script:SevenZip -ArgumentList @('x', '-bso0', '-bsp0', '-y', "-o$verify", $archive.FullName)) | Should -Be 0 + (Invoke-ExternalCommand -FilePath $script:SevenZip -ArgumentList @('x', '-bso0', '-bsp0', '-y', "-o$verify", $script:E2EArchive.FullName)) | Should -Be 0 Test-Path -LiteralPath (Join-Path $verify 'My Code Space\keep.txt') | Should -BeTrue Test-Path -LiteralPath (Join-Path $verify 'My Code Space\sub\b.txt') | Should -BeTrue @@ -579,20 +1206,12 @@ Describe '集成:Backup.ps1 / Restore.ps1 端到端' -Skip:(-not ($script:HasS # ---- 回归:manifest.roots 曾经记录的是软件名,而不是归档里真实的顶层条目名 ---- It '[回归] manifest.roots 记录的是归档内真实的顶层条目名' { - $archive = @(Get-ChildItem -LiteralPath $script:E2EBackupDir -File -Filter *.7z)[0] - $record = $script:E2EManifest.items[$archive.BaseName] - - # 源目录名带空格,正好同时压一下"顶层名不能被空白拆开"这条 + $record = $script:E2EManifest.items[$script:E2EArchive.BaseName] $record.roots | Should -Contain 'My Code Space' # 和归档里的真实内容对一次账,而不是只信 manifest 自己 - $listing = & $script:SevenZip l -ba -slt $archive.FullName 2>$null - $topLevel = @($listing | - Where-Object { $_ -like 'Path = *' } | - ForEach-Object { $_.Substring(7) } | - Where-Object { $_ -notmatch '\\' } | - Select-Object -Unique) - $topLevel | Should -Be @('My Code Space') + $topLevel = @(Get-ArchiveTopLevelNames -ArchivePath $script:E2EArchive.FullName -SevenZip $script:SevenZip) + (@($topLevel | Sort-Object) -join ',') | Should -Be 'My Code Space' } It 'Restore -DryRun 退出码 0,且一个字节都不写(manifest SHA256 不变)' { @@ -642,13 +1261,12 @@ Describe '集成:Backup.ps1 / Restore.ps1 端到端' -Skip:(-not ($script:HasS It '真实恢复之后 manifest 才被更新(lastRestoreAt)' { $manifest = Read-BaknretManifest -Path $script:E2EManifestPath - $record = @($manifest.items.Values)[0] + $record = $manifest.items[$script:E2EArchive.BaseName] $record.lastRestoreAt | Should -Not -BeNullOrEmpty } It '孤儿归档会被点名报告,但不影响退出码' { - $archive = @(Get-ChildItem -LiteralPath $script:E2EBackupDir -File -Filter *.7z)[0] - Copy-Item -LiteralPath $archive.FullName -Destination (Join-Path $script:E2EBackupDir 'Orphaned-Archive.7z') -Force + Copy-Item -LiteralPath $script:E2EArchive.FullName -Destination (Join-Path $script:E2EBackupDir 'Orphaned-Archive.7z') -Force $run = Invoke-BaknretScript -Script $script:BackupScript -Parameters @{ BackupListPath = $script:E2EList @@ -695,3 +1313,73 @@ Describe '集成:Backup.ps1 / Restore.ps1 端到端' -Skip:(-not ($script:HasS $run.ExitCode | Should -Be 1 } } + +# ============================================================================ +Describe '集成:方向标记与孤儿审计' -Skip:(-not ($script:HasSevenZip -and $script:HasPwsh)) { +# ============================================================================ + + # `+` / `-` 的归档名必须在方向过滤**之前**登记:这些条目自己不产生归档, + # 但它们对应的归档是有主的,不能被孤儿审计当成没人要的孤儿。 + # 同时放一个真孤儿做对照,证明审计确实在跑。 + + BeforeAll { + $script:DirectionRoot = Join-Path $script:Sandbox 'direction' + $script:DirectionSource = Join-Path $script:DirectionRoot 'src\Keep Me' + New-Item -ItemType Directory -Path $script:DirectionSource -Force | Out-Null + Set-Content -LiteralPath (Join-Path $script:DirectionSource 'data.txt') 'keep' + + $script:DirectionBackupDir = Join-Path $script:DirectionRoot 'Backups' + $script:DirectionList = Join-Path $script:DirectionRoot 'list.txt' + + # 正确方向:先正常备份出归档 + Write-ListFile -Path $script:DirectionList -Content "$script:DirectionSource`n" | Out-Null + $null = Invoke-BaknretScript -Script $script:BackupScript -Parameters @{ + BackupListPath = $script:DirectionList + BackupDir = $script:DirectionBackupDir + Force = $true + QuietTool = $true + } + $script:DirectionArchiveName = @(Get-ChildItem -LiteralPath $script:DirectionBackupDir -File -Filter *.7z)[0].BaseName + + # 对照组:一个清单里没有条目指向的归档,必须被点名 + Copy-Item -LiteralPath (Join-Path $script:DirectionBackupDir "$script:DirectionArchiveName.7z") ` + -Destination (Join-Path $script:DirectionBackupDir 'Stray.7z') -Force + + # 行首 -:仅恢复,备份端应当跳过它,但要把它算作"有主" + Write-ListFile -Path $script:DirectionList -Content "- $script:DirectionSource`n" | Out-Null + $script:BackupDirectionRun = Invoke-BaknretScript -Script $script:BackupScript -Parameters @{ + BackupListPath = $script:DirectionList + BackupDir = $script:DirectionBackupDir + QuietTool = $true + } + + # 行首 +:仅备份,恢复端应当跳过它,同样要登记归档名 + Remove-Item -LiteralPath $script:DirectionSource -Recurse -Force + Write-ListFile -Path $script:DirectionList -Content "+ $script:DirectionSource`n" | Out-Null + $script:RestoreDirectionRun = Invoke-BaknretScript -Script $script:RestoreScript -Parameters @{ + BackupListPath = $script:DirectionList + BackupDir = $script:DirectionBackupDir + Force = $true + Verbose = $true + } + + Remove-Item -LiteralPath (Join-Path $script:DirectionBackupDir 'Stray.7z') -Force -ErrorAction SilentlyContinue + } + + It '行首 - 的条目:备份跳过它,但仍把它算作"有主"(不报成孤儿)' { + $script:BackupDirectionRun.ExitCode | Should -Be 0 + $script:BackupDirectionRun.Output | Should -Match '行首 -' + # 对照组先在:真孤儿被点名;而 `-` 条目对应的归档没有被点名 + $script:BackupDirectionRun.Output | Should -Match 'Stray\.7z' + $script:BackupDirectionRun.Output | Should -Not -Match ('- ' + [regex]::Escape($script:DirectionArchiveName) + '\.7z') + Test-Path -LiteralPath (Join-Path $script:DirectionBackupDir "$script:DirectionArchiveName.7z") | Should -BeTrue + } + + It '行首 + 的条目:恢复跳过它、不写回目标,同时登记归档名' { + $script:RestoreDirectionRun.ExitCode | Should -Be 0 + $script:RestoreDirectionRun.Output | Should -Match '行首 \+' + Test-Path -LiteralPath $script:DirectionSource | Should -BeFalse + $script:RestoreDirectionRun.Output | Should -Match 'Stray\.7z' + $script:RestoreDirectionRun.Output | Should -Not -Match ('- ' + [regex]::Escape($script:DirectionArchiveName) + '\.7z') + } +} diff --git a/tests/Restore-Drill.ps1 b/tests/Restore-Drill.ps1 index 2e588c4..afa8fd3 100644 --- a/tests/Restore-Drill.ps1 +++ b/tests/Restore-Drill.ps1 @@ -8,14 +8,23 @@ * 本脚本证明的是"**这一批真实归档**解得开,而且解出来的东西和源一致"。 关键设计:**绝不碰真实目录**。做法是给一份临时名录(SoftwareCatalog), - 把软件名映射到临时目标目录,于是 Restore.ps1 会把归档解到临时目录, + 把归档里的顶层条目名映射到临时目标目录,于是 Restore.ps1 会把归档解到临时目录, 而不是 ~\.ssh、C:\Programs\... 这些真地方。真实归档本身只被读取。 + 归档内的一层名字怎么定,取决于**这个归档是哪种布局**(Backups\ 里两种都有): + * 重构后的新布局:包内顶层是 Slot 名(`\<内容>`,文件 Slot 就是名为 + `` 的文件)——manifest 记录的 layouts 里有这个名字; + * 重构前的旧布局:包内顶层是源路径的末级名(`<末级名>\...`)——manifest 没有 layouts。 + 本脚本按 manifest 判断,把临时名录的 Slot 名设成归档里**真实存在的那一层名字**, + 因此新旧布局都能被 Restore.ps1 正常解出来,而不是依赖"解不出来再回退"。 + 对拍规则(关键:先把"源变了"和"归档坏了"分开): - * 恢复树里每个文件都必须在活源里存在 —— 否则失败(说明归档里混进了别的东西); * 内容不一致时看活源文件的修改时间:晚于归档时间 ⇒ 源在备份之后被改过, 只提示、不算失败;不晚于归档时间却内容不同 ⇒ 归档或解压有问题,算失败; - * 活源里在备份之后新增 / 删掉的文件只提示; + * 归档里有、活源里没有的文件:如果它所在的活源目录(或最近的还在的祖辈) + 的修改时间晚于归档时间 ⇒ 是备份之后从源里删掉的,只提示、不算失败; + 否则 ⇒ 归档里混进了源里没有的东西,算失败; + * 活源里在备份之后新增的文件只提示; * 一个条目一个文件都对不上 —— 失败(多半是空归档,必须点名)。 真实机器上的归档常常是几周前的,所以"必须和今天逐字节一致"不是合理判据; @@ -26,8 +35,8 @@ pwsh -File .\tests\Restore-Drill.ps1 .EXAMPLE - # 只演练指定条目,并保留下临时工作目录 - pwsh -File .\tests\Restore-Drill.ps1 -Entries '.ssh','legendary' -KeepWorkRoot + # 只演练指定条目(写 BackupList.txt 里那样的行:软件名或绝对路径),并保留临时目录 + pwsh -File .\tests\Restore-Drill.ps1 -Entries 'OpenSSH','C:\Programs\MiFlash' -KeepWorkRoot #> [CmdletBinding()] @@ -35,11 +44,13 @@ param( # 归档所在目录;默认取 BackupConfig.psd1 里的 BackupDir [string]$BackupDir, - # 要演练的条目(软件名)。默认是一组"小、静态、无排除规则"的条目 + # 要演练的条目,写法与 BackupList.txt 的一行相同(软件名或绝对路径)。 + # 默认是一组"小、静态、无排除规则"的条目;不存在的源 / 归档会被干净地跳过。 [string[]]$Entries = @( - '.ssh', 'legendary', 'scoop-config', 'opencode', - 'PowerShell', 'WindowsPowerShell', 'MiFlash', 'MiFlash_Unlock', - 'Startup', 'WindowsTerminal', 'Aria' + 'OpenSSH', 'Legendary', 'OpenCode', 'PowerShell', 'WindowsPowerShell', + 'WindowsTerminal', 'TranslucentTB', 'Kazumi', 'PiliPlus', + 'C:\Programs\MiFlash', 'C:\Programs\MiFlash_Unlock', + 'D:\UserData\Documents\Aria' ), [string]$ConfigPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'BackupConfig.psd1'), @@ -82,9 +93,23 @@ if (-not $WorkRoot) { } New-Item -ItemType Directory -Path $WorkRoot -Force | Out-Null +$manifest = Read-BaknretManifest -Path (Join-Path $BackupDir 'manifest.json') +$archiveFiles = @(Get-ChildItem -LiteralPath $BackupDir -File -Force -ErrorAction SilentlyContinue | + Where-Object { $_.Extension.ToLower() -in @('.7z', '.rar', '.zip', '.tar') }) + +# Restore.ps1 恢复成功后会**写回 manifest.json**(记 lastRestoreAt)。真实 Backups\ 只能读, +# 所以给子进程一个临时 BackupDir:里面放一份 manifest 副本 + 指向真实归档的符号链接 +# (建不出符号链接就退化成复制)。这样归档还是那批真货,但写只会写进临时目录。 +$scratchBackupRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('bnr-drill-backups-' + [guid]::NewGuid().ToString('N').Substring(0, 8)) +New-Item -ItemType Directory -Path $scratchBackupRoot -Force | Out-Null +$realManifestPath = Join-Path $BackupDir 'manifest.json' +if (Test-Path -LiteralPath $realManifestPath) { + Copy-Item -LiteralPath $realManifestPath -Destination (Join-Path $scratchBackupRoot 'manifest.json') -Force +} + Write-Host '' Write-Host '== 真实归档恢复演练:归档 -> 临时目标 -> 与活源逐字节对拍 ==' -ForegroundColor Cyan -Write-Host " 归档目录:$BackupDir" +Write-Host " 归档目录:$BackupDir(只读;恢复写盘只写临时目录)" Write-Host " 软件名录:$catalogPath" Write-Host " 工作目录:$WorkRoot" Write-Host '' @@ -93,6 +118,34 @@ Write-Host '' # 工具 # --------------------------------------------------------------------------- +function Test-RemovedFromLiveAfterBackup { + <# + .SYNOPSIS + 归档里有、活源里没有的文件,是不是"备份之后从源里删掉了"。 + + .DESCRIPTION + 从活源根往下走,停在第一个不存在的层级,看最近的那个还在的祖辈的修改时间: + 晚于归档时间 ⇒ 这个文件是在备份之后被删的(源变了,不是归档坏了); + 不晚于归档时间 ⇒ 它本该还在,归档里却有别人没有的东西,算失败。 + #> + param( + [Parameter(Mandatory = $true)][string]$LiveRoot, + [Parameter(Mandatory = $true)][string]$Relative, + [Parameter(Mandatory = $true)][datetime]$ArchiveTime + ) + + $probe = $LiveRoot + foreach ($segment in @($Relative -split '[\\/]' | Where-Object { $_ })) { + $next = Join-Path $probe $segment + if (-not (Test-Path -LiteralPath $next)) { break } + $probe = $next + } + + $item = Get-Item -LiteralPath $probe -Force -ErrorAction SilentlyContinue + if (-not $item) { return $false } + return ($item.LastWriteTime -gt $ArchiveTime) +} + function Compare-RestoredTree { <# .SYNOPSIS @@ -115,6 +168,7 @@ function Compare-RestoredTree { Restored = 0 Matched = 0 Stale = @() + Removed = @() Changed = @() Extra = @() Missing = @() @@ -151,7 +205,11 @@ function Compare-RestoredTree { $liveFile = Join-Path $liveRoot $relative if (-not (Test-Path -LiteralPath $liveFile)) { - $report.Extra += $relative + if (Test-RemovedFromLiveAfterBackup -LiveRoot $liveRoot -Relative $relative -ArchiveTime $ArchiveTime) { + $report.Removed += $relative + } else { + $report.Extra += $relative + } continue } @@ -179,6 +237,66 @@ function Compare-RestoredTree { return $report } +function Get-ArchiveRelativeName { + <# + .SYNOPSIS + 决定一个归档项在**这个归档里**实际叫什么名字。 + + .DESCRIPTION + manifest 里有 layouts(重构后写的归档)时,项名就是 Slot 名; + 没有 layouts(重构前的归档)时,包内那一层是源路径的末级名。 + 名字对不上就解不出东西,所以这里必须按归档的真实布局来选。 + #> + param($Item, $LayoutKinds) + + if ($LayoutKinds.Count -gt 0) { + if ($LayoutKinds.ContainsKey([string]$Item.ArchivePath)) { return [string]$Item.ArchivePath } + return $null + } + + return (Split-Path -Path ([string]$Item.RealPath) -Leaf) +} + +function Invoke-ScratchRestore { + <# + .SYNOPSIS + 用子进程跑 Restore.ps1,返回退出码与它自己的日志文件。 + + .DESCRIPTION + 绝不能 `$lines = & pwsh @args 2>&1`:那会给子进程建管道,本机沙箱直接拒绝 + (Access to the path '\\.\pipe\LOCAL\dotnet_...' is denied)。 + Invoke-ExternalCommand 继承 stdio、不建管道,退出码可靠,所以这里用它启动子进程; + 子进程的输出不用管道拿,而是读它自己写下的 restore-*.log。 + #> + param( + [Parameter(Mandatory = $true)][string]$ScratchList, + [Parameter(Mandatory = $true)][string]$ScratchConfig, + [Parameter(Mandatory = $true)][string]$ScratchLogDir, + [Parameter(Mandatory = $true)][string]$ScratchBackupDir + ) + + $pwshExe = (Get-Command pwsh -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source) + if (-not $pwshExe) { $pwshExe = 'pwsh' } + + New-Item -ItemType Directory -Path $ScratchLogDir -Force | Out-Null + $before = @(Get-ChildItem -LiteralPath $ScratchLogDir -File -Filter 'restore-*.log' -ErrorAction SilentlyContinue | + Select-Object -ExpandProperty FullName) + + $code = Invoke-ExternalCommand -FilePath $pwshExe -ArgumentList @( + '-NoProfile', '-NonInteractive', '-File', $restoreScript, + '-BackupListPath', $ScratchList, + '-ConfigPath', $ScratchConfig, + '-BackupDir', $ScratchBackupDir, + '-Force' + ) + + $log = Get-ChildItem -LiteralPath $ScratchLogDir -File -Filter 'restore-*.log' -ErrorAction SilentlyContinue | + Where-Object { $before -notcontains $_.FullName } | + Sort-Object LastWriteTime | Select-Object -Last 1 + + return [pscustomobject]@{ Code = $code; Log = $log } +} + # --------------------------------------------------------------------------- # 演练 # --------------------------------------------------------------------------- @@ -186,8 +304,10 @@ function Compare-RestoredTree { $rows = @() $failures = @() $checked = 0 +$entryIndex = 0 foreach ($name in $Entries) { + $entryIndex++ $entry = ConvertFrom-BackupListLine -Line $name if (-not $entry) { continue } @@ -199,84 +319,147 @@ foreach ($name in $Entries) { continue } - $archivePath = Join-Path $BackupDir ($resolved.BaseName + '.7z') - if (-not (Test-Path -LiteralPath $archivePath)) { - $rows += [pscustomobject]@{ Entry = $name; Status = 'SKIP'; Detail = "归档不存在:$($resolved.BaseName).7z" } - continue - } - $archiveTime = (Get-Item -LiteralPath $archivePath).LastWriteTime - - $sources = @($resolved.Sources) - if ($sources.Count -eq 0) { - $rows += [pscustomobject]@{ Entry = $name; Status = 'SKIP'; Detail = '名录解析不出源路径' } + $items = @($resolved.Items) + if ($items.Count -eq 0) { + $reason = if ($resolved.Error) { $resolved.Error } else { '解析不出归档项' } + $rows += [pscustomobject]@{ Entry = $name; Status = 'SKIP'; Detail = $reason } continue } - if (@($sources | Where-Object { Test-Path -LiteralPath $_.SourcePath }).Count -eq 0) { + # 找到归档:manifest 记录优先,其次按归档基础名 / 源路径末级名精确匹配文件。 + # Backups\ 里既有按软件名命名的归档,也有按路径算法命名的旧归档。 + $legacyLeaves = @($items | ForEach-Object { Split-Path -Path ([string]$_.RealPath) -Leaf } | Where-Object { $_ }) + + $archiveFile = $null + $record = $null + if ($manifest.items.Contains($resolved.BaseName)) { $record = $manifest.items[$resolved.BaseName] } + if ($record -and ($record.PSObject.Properties.Name -contains 'archive') -and $record.archive) { + $candidate = Join-Path $BackupDir ([string]$record.archive) + if (Test-Path -LiteralPath $candidate) { $archiveFile = Get-Item -LiteralPath $candidate } + } + + if (-not $archiveFile) { + $matched = @() + foreach ($file in $archiveFiles) { + if ($file.BaseName -ieq $resolved.BaseName) { $matched += $file; continue } + foreach ($leaf in $legacyLeaves) { + if ($file.BaseName -ieq $leaf) { $matched += $file; break } + } + } + if ($matched.Count -gt 1) { + $rows += [pscustomobject]@{ Entry = $name; Status = 'SKIP'; Detail = "多个归档都可能是它:$(($matched | ForEach-Object { $_.Name }) -join '、')" } + continue + } + if ($matched.Count -eq 1) { $archiveFile = $matched[0] } + } + + if (-not $archiveFile) { + $rows += [pscustomobject]@{ Entry = $name; Status = 'SKIP'; Detail = "归档不存在(基础名 $($resolved.BaseName))" } + continue + } + + $archiveTime = $archiveFile.LastWriteTime + + # 让子进程的 BackupDir 里也"有"这个归档:优先符号链接(零拷贝),不行才复制 + $scratchArchive = Join-Path $scratchBackupRoot $archiveFile.Name + if (-not (Test-Path -LiteralPath $scratchArchive)) { + try { + New-Item -ItemType SymbolicLink -Path $scratchArchive -Target $archiveFile.FullName -ErrorAction Stop | Out-Null + } catch { + Copy-Item -LiteralPath $archiveFile.FullName -Destination $scratchArchive -Force + } + } + + # 归档里那一层的真名:manifest.layouts 决定(新布局 = Slot 名,旧布局 = 末级名) + if (-not $record -and $manifest.items.Contains($archiveFile.BaseName)) { $record = $manifest.items[$archiveFile.BaseName] } + $layoutKinds = @{} + if ($record -and ($record.PSObject.Properties.Name -contains 'layouts') -and $record.layouts) { + foreach ($layout in @($record.layouts)) { + if (-not $layout) { continue } + $layoutName = [string]$layout.name + if (-not [string]::IsNullOrWhiteSpace($layoutName)) { $layoutKinds[$layoutName] = [string]$layout.kind } + } + } + + $entryRoot = Join-Path (Join-Path $WorkRoot 'restore') ("e$entryIndex") + New-Item -ItemType Directory -Path $entryRoot -Force | Out-Null + + $pairs = @() + $slotLines = @() + $skipReason = $null + + for ($index = 0; $index -lt $items.Count; $index++) { + $item = $items[$index] + $livePath = [string]$item.RealPath + if ([string]::IsNullOrWhiteSpace($livePath)) { continue } + + $liveItem = Get-Item -LiteralPath $livePath -Force -ErrorAction SilentlyContinue + if (-not $liveItem) { continue } # 源没了,跳过(归档里也不该有它) + + $archiveName = Get-ArchiveRelativeName -Item $item -LayoutKinds $layoutKinds + if ([string]::IsNullOrWhiteSpace($archiveName)) { + $skipReason = "manifest.layouts 里没有归档项 '$($item.ArchivePath)'(名录改过?)" + break + } + if (@($pairs | Where-Object { $_.Name -ieq $archiveName }).Count -gt 0) { + $skipReason = "多个源都映射到归档内的同一个名字 '$archiveName',无法判定谁是谁(旧归档常见)" + break + } + + $target = Join-Path $entryRoot ([string]$index) + if ($liveItem.PSIsContainer) { + New-Item -ItemType Directory -Path $target -Force | Out-Null + } else { + [System.IO.File]::WriteAllText($target, '') + } + + $pairs += [pscustomobject]@{ Name = $archiveName; Restored = $target; Live = $livePath } + $slotLines += " '$archiveName' = @{ Path = '$target' }" + } + + if ($skipReason) { + $rows += [pscustomobject]@{ Entry = $name; Status = 'SKIP'; Detail = $skipReason } + continue + } + + if ($pairs.Count -eq 0) { $rows += [pscustomobject]@{ Entry = $name; Status = 'SKIP'; Detail = '所有源目录当前都不存在,无法对拍' } continue } - $entryRoot = Join-Path (Join-Path $WorkRoot 'restore') $name - New-Item -ItemType Directory -Path $entryRoot -Force | Out-Null + # 临时名录:键 = 归档基础名(这样 Restore 能通过 manifest / 文件名找到归档), + # 每个 Slot 的 Path 指向一个临时目标 —— Restore 就解到这里,碰不到真实目录。 + $catalogKey = $archiveFile.BaseName + $scratchCatalog = Join-Path $WorkRoot ("catalog-e$entryIndex.psd1") + $scratchList = Join-Path $WorkRoot ("list-e$entryIndex.txt") + $scratchConfig = Join-Path $WorkRoot ("config-e$entryIndex.psd1") + $scratchLogDir = Join-Path $WorkRoot ("logs\e$entryIndex") - # 每个源各自映射到一个临时目标:临时名录保持**同样的个数与顺序**, - # 于是 Restore 会把第 i 个源还原到第 i 个临时目录,再和第 i 个活源逐字节对拍。 - # (一个条目可以挂多个目录:软件名录的数组写法、以及清单里的 :+ 追加。) - $scratchEntries = @() - $pairs = @() - for ($index = 0; $index -lt $sources.Count; $index++) { - $source = $sources[$index] - $leaf = @($source.RelativePaths)[0] - $scratchTarget = Join-Path (Join-Path $entryRoot $index) $leaf - $scratchEntries += $scratchTarget - $pairs += [pscustomobject]@{ - Restored = $scratchTarget - Live = $source.SourcePath - Exists = (Test-Path -LiteralPath $source.SourcePath) - } - } - - # 临时名录:把这些目录全指到临时目标,Restore 就解到这里,碰不到真实目录 - $scratchCatalog = Join-Path $WorkRoot ("catalog-$name.psd1") - $scratchList = Join-Path $WorkRoot ("list-$name.txt") - $scratchConfig = Join-Path $WorkRoot ("config-$name.psd1") - - $itemLines = @($scratchEntries | ForEach-Object { " @{ Path = '$_' }" }) -join "`n" [System.IO.File]::WriteAllText($scratchCatalog, - "@{`n '$name' = @(`n$itemLines`n )`n}`n", [System.Text.UTF8Encoding]::new($false)) - [System.IO.File]::WriteAllText($scratchList, "$name`n", [System.Text.UTF8Encoding]::new($false)) + "@{`n '$catalogKey' = @{`n$($slotLines -join "`n")`n }`n}`n", [System.Text.UTF8Encoding]::new($false)) + [System.IO.File]::WriteAllText($scratchList, "$catalogKey`n", [System.Text.UTF8Encoding]::new($false)) [System.IO.File]::WriteAllText($scratchConfig, @" @{ - BackupDir = '$BackupDir' - LogDir = '$(Join-Path $WorkRoot 'logs')' + BackupDir = '$scratchBackupRoot' + LogDir = '$scratchLogDir' SoftwareCatalog = '$scratchCatalog' CatalogMaxDepth = $($config.CatalogMaxDepth) VerifyArchive = `$true } "@, [System.Text.UTF8Encoding]::new($false)) - Write-Host ("-- 演练 {0}(归档 {1}.7z,{2} 个目录)" -f $name, $resolved.BaseName, $sources.Count) -ForegroundColor Gray + Write-Host ("-- 演练 {0}(归档 {1},{2} 个源)" -f $name, $archiveFile.Name, $pairs.Count) -ForegroundColor Gray - # 用**子进程**跑 Restore.ps1:它结尾会 exit,子进程既不会打断演练, - # 给出的也是真正的进程退出码(和 Pester 套件里的做法一致)。 - $restoreExit = 0 - $restoreOutput = @() - try { - $restoreOutput = & pwsh -NoProfile -NonInteractive -File $restoreScript ` - -BackupListPath $scratchList -ConfigPath $scratchConfig -BackupDir $BackupDir -Force 2>&1 - $restoreExit = $LASTEXITCODE - } catch { - $restoreExit = -1 - Write-Host (" Restore.ps1 调用失败:$_") -ForegroundColor Red - } + $restore = Invoke-ScratchRestore -ScratchList $scratchList -ScratchConfig $scratchConfig -ScratchLogDir $scratchLogDir -ScratchBackupDir $scratchBackupRoot - if ($restoreExit -ne 0) { - foreach ($line in @($restoreOutput | Select-Object -Last 12)) { - Write-Host (" | {0}" -f $line) -ForegroundColor DarkGray + if ($restore.Code -ne 0) { + if ($restore.Log) { + foreach ($line in @(Get-Content -LiteralPath $restore.Log.FullName -ErrorAction SilentlyContinue | Select-Object -Last 12)) { + Write-Host (" | {0}" -f $line) -ForegroundColor DarkGray + } } - $rows += [pscustomobject]@{ Entry = $name; Status = 'FAIL'; Detail = "Restore.ps1 退出码 $restoreExit" } - $failures += "$name :Restore.ps1 退出码 $restoreExit" + $rows += [pscustomobject]@{ Entry = $name; Status = 'FAIL'; Detail = "Restore.ps1 退出码 $($restore.Code)" } + $failures += "$name :Restore.ps1 退出码 $($restore.Code)" continue } @@ -285,17 +468,17 @@ foreach ($name in $Entries) { $restoredCount = 0 $extra = @() $stale = @() + $removed = @() $changed = @() $notArchived = @() foreach ($pair in $pairs) { - # 活源本来就没了的不对拍(归档里也不该有它) - if (-not $pair.Exists) { continue } $one = Compare-RestoredTree -RestoredPath $pair.Restored -LivePath $pair.Live -ArchiveTime $archiveTime $matched += $one.Matched $restoredCount += $one.Restored $extra += $one.Extra $stale += $one.Stale + $removed += $one.Removed $changed += $one.Changed $notArchived += $one.Missing } @@ -312,6 +495,10 @@ foreach ($name in $Entries) { # 活源在归档之后被改过:源变了,不是归档坏了,只提示 $detail += ";源在备份后变过 $($stale.Count) 个(不算失败)" } + if ($removed.Count -gt 0) { + # 归档里有、活源里没了,且源目录在归档之后动过:也是"源变了",只提示 + $detail += ";备份后从源里删掉 $($removed.Count) 个(不算失败)" + } if ($changed.Count -gt 0) { if ($AllowChanged) { $detail += ";与活源不一致 $($changed.Count) 个(-AllowChanged,已容忍)" @@ -338,6 +525,9 @@ foreach ($name in $Entries) { # 报告 # --------------------------------------------------------------------------- +# 临时 BackupDir 用完即删:删符号链接只会删链接本身,真实的归档不受影响 +Remove-Item -LiteralPath $scratchBackupRoot -Recurse -Force -ErrorAction SilentlyContinue + Write-Host '' Write-Host '演练结果:' -ForegroundColor Cyan $rows | Format-Table -AutoSize | Out-String -Width 200 | Write-Host diff --git a/tests/Run-E2E.ps1 b/tests/Run-E2E.ps1 index 9502876..0fd5602 100644 --- a/tests/Run-E2E.ps1 +++ b/tests/Run-E2E.ps1 @@ -1,17 +1,24 @@ <# .SYNOPSIS - BakNRet 端到端验收:真实备份 -> 校验排除 -> 删源 -> 恢复 -> 逐字节对拍。 + BakNRet 端到端验收:真实备份 -> 校验归档布局与排除 -> 删源 -> 恢复 -> 逐字节对拍。 .DESCRIPTION - 单元测试只验证函数行为,这个脚本验证整条链路真的能用: - 1. 造一个含可排除内容的源目录(目录名故意带空格,顺带验证命令行引用); - 2. 跑 Backup.ps1,断言退出码为 0、归档生成、manifest 记录正确; - 3. 解压归档,断言被排除的内容确实不在里面; - 4. 删掉源目录,跑 Restore.ps1,断言文件逐字节还原、被排除的内容没有被还原; - 5. 断言 Backup -DryRun 与 Restore -DryRun 都不写盘; - 6. 源路径不存在时记为 missing-source,而不是静默忽略。 + 单元测试只验证函数行为,这个脚本验证整条链路真的能用。覆盖重构后的新契约: - 全程只在临时目录里操作,不会碰到真实备份。 + 1. 字面路径条目:`:-` 排除 -> 删源 -> 恢复 -> 逐字节对拍(历史 `<末级名>\...` 布局); + 2. 软件名录条目:一个软件一个归档,包内顶层是各 Slot(`\<内容>`); + 文件 Slot 在包内是一个**名为 Slot 的文件**; + 3. `:+` / Include 把宿主机目录放到指定的归档内位置; + 4. Slot 前缀的排除模式(`:- AlphaData\plain`)只作用于对应 Slot; + 5. 名录 Slot 自己的 Exclude(未写条目级 `:-` 时)同样生效; + 6. `::` 覆盖单 Slot 条目的真实路径; + 7. 行首 `+` / `-` 方向:备份端跳过 `-`、恢复端跳过 `+`, + 且 `-` 条目的归档名仍然算"有主",不会被孤儿审计误报; + 8. manifest 记录 `roots` 与 `layouts`(每条归档项是 dir 还是 file); + 9. 重构前旧布局归档的恢复(manifest 无 layouts 时按 `<末级名>` 回退); + 10. @pathname 用名录里的真实路径命名,DryRun 不写盘,失败路径留记录。 + + 全程只在临时目录里操作,不会碰到真实 Backups\。 .EXAMPLE pwsh -File .\tests\Run-E2E.ps1 @@ -37,6 +44,78 @@ Reset-TestResult if (-not $WorkRoot) { $WorkRoot = Join-Path $env:TEMP ('bnr-' + [guid]::NewGuid().ToString('N').Substring(0, 6)) } +New-Item -ItemType Directory -Path $WorkRoot -Force | Out-Null + +$sevenZip = Get-Command 7z -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source + +Write-Host "" +Write-Host '== 端到端:备份 -> 布局/排除 -> 删源 -> 恢复 -> 对拍 ==' -ForegroundColor Cyan +Write-Host " 工作目录:$WorkRoot" + +# --------------------------------------------------------------------------- +# 工具 +# --------------------------------------------------------------------------- + +function New-E2EConfig { + <# .SYNOPSIS 写一份只指向临时目录的配置,避免污染仓库日志。 #> + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$LogDir, + [string]$SoftwareCatalog, + [int]$CatalogMaxDepth = 5 + ) + $lines = @( + '@{' + " LogDir = '$LogDir'" + " ToolOutput = 'quiet'" + ' CompressionLevel = 1' + ' MinFreeSpaceGB = 0' + ) + if ($SoftwareCatalog) { $lines += " SoftwareCatalog = '$SoftwareCatalog'" } + $lines += " CatalogMaxDepth = $CatalogMaxDepth" + $lines += '}' + [System.IO.File]::WriteAllText($Path, ($lines -join "`r`n"), [System.Text.UTF8Encoding]::new($false)) +} + +function Get-NewestLog { + <# .SYNOPSIS 取日志目录里最新的 backup-*.log / restore-*.log。 #> + param([Parameter(Mandatory = $true)][string]$LogDir, [Parameter(Mandatory = $true)][string]$Prefix) + return Get-ChildItem -LiteralPath $LogDir -File -Filter "$Prefix-*.log" -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime | Select-Object -Last 1 +} + +function Get-ArchiveNames { + param([Parameter(Mandatory = $true)][string]$Dir) + return @(Get-ChildItem -LiteralPath $Dir -File -Filter *.7z -ErrorAction SilentlyContinue | + Select-Object -ExpandProperty BaseName) +} + +function Get-OrphanNames { + <# .SYNOPSIS 从备份日志里解析出"孤儿归档"那一段点名的归档名。 #> + param([Parameter(Mandatory = $true)][string]$LogPath) + + $names = @() + $inSection = $false + foreach ($line in @(Get-Content -LiteralPath $LogPath -Encoding UTF8)) { + if ($line -match '孤儿归档') { $inSection = $true; continue } + if (-not $inSection) { continue } + if ($line -match '-\s+([^\s()]+?)(') { + $names += $Matches[1] + } else { + $inSection = $false + } + } + return @($names) +} + +function Get-Sha256 { + param([Parameter(Mandatory = $true)][string]$Path) + return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash +} + +# ============================================================================ +# 1. 字面路径条目:备份 -> 排除 -> 删源 -> 恢复 -> 逐字节对拍 +# ============================================================================ $sourceParent = Join-Path $WorkRoot 'src' $source = Join-Path $sourceParent 'My Code Space' # 名字带空格,专门压一下命令行引用 @@ -44,14 +123,9 @@ $backupDir = Join-Path $WorkRoot 'Backups' $listPath = Join-Path $WorkRoot 'list.txt' $dryBackupDir = Join-Path $WorkRoot 'Backups-dry' $verifyDir = Join-Path $WorkRoot 'verify' - -Write-Host "" -Write-Host '== 端到端:备份 -> 排除 -> 删源 -> 恢复 -> 对拍 ==' -ForegroundColor Cyan -Write-Host " 工作目录:$WorkRoot" - -# ============================================================================ -# 1. 造数据 -# ============================================================================ +$logDir1 = Join-Path $WorkRoot 'logs1' +$cfg1 = Join-Path $WorkRoot 'cfg1.psd1' +New-E2EConfig -Path $cfg1 -LogDir $logDir1 foreach ($dir in 'logs', 'sub', 'Cache') { New-Item -ItemType Directory -Path (Join-Path $source $dir) -Force | Out-Null @@ -67,18 +141,14 @@ $blob = New-Object byte[] 8192 (New-Object System.Random 42).NextBytes($blob) [System.IO.File]::WriteAllBytes((Join-Path $source 'blob.bin'), $blob) -[System.IO.File]::WriteAllText($listPath, "# e2e`n$source :: logs\,!*Cache`n", [System.Text.UTF8Encoding]::new($false)) +[System.IO.File]::WriteAllText($listPath, "# e2e`n$source :- logs\,!*Cache`n", [System.Text.UTF8Encoding]::new($false)) $expectedHashes = @{} foreach ($relative in 'keep.txt', 'sub\b.txt', 'blob.bin') { - $expectedHashes[$relative] = (Get-FileHash -LiteralPath (Join-Path $source $relative) -Algorithm SHA256).Hash + $expectedHashes[$relative] = Get-Sha256 (Join-Path $source $relative) } -# ============================================================================ -# 2. 备份 -# ============================================================================ - -& $backupScript -BackupListPath $listPath -BackupDir $backupDir -Force -QuietTool +& $backupScript -BackupListPath $listPath -BackupDir $backupDir -ConfigPath $cfg1 -Force -QuietTool $backupExitCode = $LASTEXITCODE Test-Case '备份退出码为 0(旧实现会把成功的压缩判成失败)' { @@ -94,7 +164,7 @@ Test-Case '归档已生成' { $manifestPath = Join-Path $backupDir 'manifest.json' -Test-Case 'manifest 记录了条目、动作与校验结果' { +Test-Case 'manifest 记录了条目、动作、校验结果、roots 与 layouts' { Assert-FileExists $manifestPath $manifest = Read-BaknretManifest -Path $manifestPath Assert-Equal 1 $manifest.items.Count @@ -105,28 +175,26 @@ Test-Case 'manifest 记录了条目、动作与校验结果' { Assert-Equal $true $record.verified Assert-Equal 0 $record.exitCode Assert-Equal $source $record.source - Assert-True ($record.sourceFiles -ge 4) '源文件数应不少于 4' + Assert-Equal 5 $record.sourceFiles '源里 5 个文件(排除只影响打包,不影响统计)' + Assert-Equal 1 $record.roots.Count + Assert-Equal 'My Code Space' $record.roots[0] + Assert-Equal 1 $record.layouts.Count + Assert-Equal 'My Code Space' $record.layouts[0].name + Assert-Equal 'dir' $record.layouts[0].kind } -Test-Case '备份过程写了日志文件' { - $logDir = Join-Path $projectRoot 'logs' - $logs = @(Get-ChildItem -LiteralPath $logDir -File -Filter 'backup-*.log' -ErrorAction SilentlyContinue) +Test-Case '备份过程写了日志文件(写进临时 LogDir,不污染仓库)' { + $logs = @(Get-ChildItem -LiteralPath $logDir1 -File -Filter 'backup-*.log' -ErrorAction SilentlyContinue) Assert-True ($logs.Count -gt 0) '应生成 backup-*.log' } -# ============================================================================ -# 3. 解压归档,验证排除真的生效 -# ============================================================================ - -New-Item -ItemType Directory -Path $verifyDir -Force | Out-Null -$sevenZip = Get-Command 7z -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source - if ($sevenZip) { + New-Item -ItemType Directory -Path $verifyDir -Force | Out-Null $null = Invoke-ExternalCommand -FilePath $sevenZip ` -ArgumentList @('x', '-bso0', '-bsp0', '-y', "-o$verifyDir", $archives[0].FullName) } -Test-Case '归档里保留了应当保留的内容' { +Test-Case '字面路径条目保留历史布局(包内顶层是源目录名)' { Assert-FileExists (Join-Path $verifyDir 'My Code Space\keep.txt') Assert-FileExists (Join-Path $verifyDir 'My Code Space\sub\b.txt') Assert-FileExists (Join-Path $verifyDir 'My Code Space\blob.bin') @@ -137,17 +205,13 @@ Test-Case '归档里不含被排除的 logs\ 与 !*Cache 命中项' { Assert-FileMissing (Join-Path $verifyDir 'My Code Space\Cache\c.bin') '!*Cache 应命中 Cache 目录' } -# ============================================================================ -# 4. 删源后恢复,逐字节对拍 -# ============================================================================ - Remove-Item -LiteralPath $source -Recurse -Force Test-Case '源目录确实已被删除(保证下面的恢复不是空操作)' { Assert-FileMissing $source } -& $restoreScript -BackupListPath $listPath -BackupDir $backupDir -Force +& $restoreScript -BackupListPath $listPath -BackupDir $backupDir -ConfigPath $cfg1 -Force $restoreExitCode = $LASTEXITCODE Test-Case '恢复退出码为 0' { @@ -158,7 +222,7 @@ Test-Case '恢复出的文件与源逐字节一致' { foreach ($relative in $expectedHashes.Keys) { $restored = Join-Path $source $relative Assert-FileExists $restored - Assert-Equal $expectedHashes[$relative] (Get-FileHash -LiteralPath $restored -Algorithm SHA256).Hash "对拍 $relative" + Assert-Equal $expectedHashes[$relative] (Get-Sha256 $restored) "对拍 $relative" } } @@ -168,18 +232,20 @@ Test-Case '被排除的内容没有被恢复出来' { } # ============================================================================ -# 4.5 保护规则:有警告时不拿不完整的归档覆盖完整归档 +# 2. 保护规则:有警告时不拿不完整的归档覆盖完整归档 # ============================================================================ $lockSource = Join-Path $sourceParent 'Locked Case' $lockBackupDir = Join-Path $WorkRoot 'Backups-lock' $lockList = Join-Path $WorkRoot 'lock.txt' +$logDir2 = Join-Path $WorkRoot 'logs2' +$cfg2 = Join-Path $WorkRoot 'cfg2.psd1' +New-E2EConfig -Path $cfg2 -LogDir $logDir2 New-Item -ItemType Directory -Path $lockSource -Force | Out-Null Set-Content -LiteralPath (Join-Path $lockSource 'a.txt') -Value 'aaa' -Encoding UTF8 [System.IO.File]::WriteAllText($lockList, "$lockSource`n", [System.Text.UTF8Encoding]::new($false)) -# 第一轮:没有占用,归档是"干净"的 -& $backupScript -BackupListPath $lockList -BackupDir $lockBackupDir -Force -QuietTool +& $backupScript -BackupListPath $lockList -BackupDir $lockBackupDir -ConfigPath $cfg2 -Force -QuietTool $cleanExitCode = $LASTEXITCODE $cleanArchive = Get-ChildItem -LiteralPath $lockBackupDir -File -Filter *.7z | Select-Object -First 1 $cleanSize = $cleanArchive.Length @@ -199,7 +265,7 @@ Set-Content -LiteralPath $lockedPath -Value 'locked' -Encoding UTF8 $lockStream = [System.IO.File]::Open($lockedPath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::None) try { - & $backupScript -BackupListPath $lockList -BackupDir $lockBackupDir -Force -QuietTool + & $backupScript -BackupListPath $lockList -BackupDir $lockBackupDir -ConfigPath $cfg2 -Force -QuietTool $warnExitCode = $LASTEXITCODE $afterArchive = Get-ChildItem -LiteralPath $lockBackupDir -File -Filter *.7z | Select-Object -First 1 $afterRecord = (Read-BaknretManifest -Path $lockManifestPath).items[$afterArchive.BaseName] @@ -213,7 +279,7 @@ try { } # 明确接受之后才允许覆盖 - & $backupScript -BackupListPath $lockList -BackupDir $lockBackupDir -Force -QuietTool -AcceptWarnings + & $backupScript -BackupListPath $lockList -BackupDir $lockBackupDir -ConfigPath $cfg2 -Force -QuietTool -AcceptWarnings $acceptExitCode = $LASTEXITCODE $acceptedRecord = (Read-BaknretManifest -Path $lockManifestPath).items[$afterArchive.BaseName] @@ -228,12 +294,321 @@ try { } # ============================================================================ -# 5. DryRun 不写盘 +# 3. 软件名录:Slot 布局(目录 Slot / 文件 Slot / Include / Slot 前缀排除) +# ============================================================================ + +$catRoot = Join-Path $WorkRoot 'catalog' +$catAppRoot = Join-Path $catRoot 'apps' +$dirA = Join-Path $catAppRoot 'A' +$dirA2 = Join-Path $catAppRoot 'A2' +$settingsFile = Join-Path $catAppRoot 'settings.json' +$incDir = Join-Path $catAppRoot 'inc' +$catBackupDir = Join-Path $catRoot 'Backups' +$catFile = Join-Path $catRoot 'SoftwareCatalog.psd1' +$catList = Join-Path $catRoot 'list.txt' +$catConfig = Join-Path $catRoot 'config.psd1' +$catLogDir = Join-Path $catRoot 'logs' +$catExtract = Join-Path $catRoot 'verify' + +foreach ($dir in (Join-Path $dirA 'sub'), (Join-Path $dirA 'plain'), (Join-Path $dirA 'skip'), (Join-Path $dirA2 'skip'), $incDir) { + New-Item -ItemType Directory -Path $dir -Force | Out-Null +} +Set-Content -LiteralPath (Join-Path $dirA 'keep.txt') -Value 'A-keep' -Encoding UTF8 +Set-Content -LiteralPath (Join-Path $dirA 'sub\keep2.txt') -Value 'A-sub' -Encoding UTF8 +Set-Content -LiteralPath (Join-Path $dirA 'plain\p.bin') -Value 'A-plain' -Encoding UTF8 +Set-Content -LiteralPath (Join-Path $dirA 'skip\s.bin') -Value 'A-skip' -Encoding UTF8 +Set-Content -LiteralPath (Join-Path $dirA2 'keep.txt') -Value 'A2-keep' -Encoding UTF8 +Set-Content -LiteralPath (Join-Path $dirA2 'skip\s.bin') -Value 'A2-skip' -Encoding UTF8 +Set-Content -LiteralPath $settingsFile -Value '{"slot":"file"}' -Encoding UTF8 +Set-Content -LiteralPath (Join-Path $incDir 'i.txt') -Value 'included' -Encoding UTF8 + +[System.IO.File]::WriteAllText($catFile, @" +@{ + 'my-app' = @{ + AlphaData = @{ Path = '$dirA' } + BetaFile = @{ Path = '$settingsFile' } + } + 'cat-excl' = @{ + Data = @{ Path = '$dirA2'; Exclude = '!*skip' } + } +} +"@, [System.Text.UTF8Encoding]::new($false)) + +# 条目级排除用 Slot 前缀点名(AlphaData\plain)+ 任意层级(!*skip);:+ 把 inc 放到归档内 Modules\ +[System.IO.File]::WriteAllText($catList, "my-app :- AlphaData\plain,!*skip :+ Modules:$incDir`ncat-excl`n", [System.Text.UTF8Encoding]::new($false)) +New-E2EConfig -Path $catConfig -LogDir $catLogDir -SoftwareCatalog $catFile + +$catHashes = @{ + (Join-Path $dirA 'keep.txt') = Get-Sha256 (Join-Path $dirA 'keep.txt') + (Join-Path $dirA 'sub\keep2.txt') = Get-Sha256 (Join-Path $dirA 'sub\keep2.txt') + $settingsFile = Get-Sha256 $settingsFile + (Join-Path $incDir 'i.txt') = Get-Sha256 (Join-Path $incDir 'i.txt') +} + +& $backupScript -BackupListPath $catList -BackupDir $catBackupDir -ConfigPath $catConfig -Force -QuietTool +$catExitCode = $LASTEXITCODE + +Test-Case '名录条目:一个软件一个归档,归档名 = 软件名;独立条目各自成包' { + Assert-Equal 0 $catExitCode + Assert-FileExists (Join-Path $catBackupDir 'my-app.7z') + Assert-FileExists (Join-Path $catBackupDir 'cat-excl.7z') +} + +Test-Case 'manifest.roots 列出各归档项的顶层名,layouts 标出 dir / file' { + $record = (Read-BaknretManifest -Path (Join-Path $catBackupDir 'manifest.json')).items['my-app'] + Assert-True ($null -ne $record) + $roots = @($record.roots | Sort-Object) + Assert-Equal 3 $roots.Count + Assert-Equal 'AlphaData' $roots[0] + Assert-Equal 'BetaFile' $roots[1] + Assert-Equal 'Modules' $roots[2] + + $layoutMap = @{} + foreach ($layout in $record.layouts) { $layoutMap[$layout.name] = $layout.kind } + Assert-Equal 'dir' $layoutMap['AlphaData'] + Assert-Equal 'file' $layoutMap['BetaFile'] + Assert-Equal 'dir' $layoutMap['Modules'] +} + +New-Item -ItemType Directory -Path $catExtract -Force | Out-Null +if ($sevenZip) { + $null = Invoke-ExternalCommand -FilePath $sevenZip -ArgumentList @('x', '-bso0', '-bsp0', '-y', "-o$catExtract", (Join-Path $catBackupDir 'my-app.7z')) +} + +Test-Case '归档内顶层是 Slot 名:\<内容>' { + Assert-FileExists (Join-Path $catExtract 'AlphaData\keep.txt') 'AlphaData 必须是包内的一层目录' + Assert-FileExists (Join-Path $catExtract 'AlphaData\sub\keep2.txt') + Assert-FileMissing (Join-Path $catExtract 'A\keep.txt') '包内不该出现宿主机上的目录名' + Assert-FileMissing (Join-Path $catExtract 'my-app') '包内不该多出一层软件名' +} + +Test-Case '文件 Slot 在包内是一个名为 Slot 的文件(没有扩展名)' { + Assert-True (Test-Path -LiteralPath (Join-Path $catExtract 'BetaFile') -PathType Leaf) 'BetaFile 应是文件' + Assert-FileMissing (Join-Path $catExtract 'BetaFile.json') + Assert-FileMissing (Join-Path $catExtract 'settings.json') '文件 Slot 不保留原文件名' +} + +Test-Case ':+ / Include 把宿主机目录放到指定的归档内位置' { + Assert-FileExists (Join-Path $catExtract 'Modules\i.txt') +} + +Test-Case 'Slot 前缀的排除模式只作用在对应 Slot 上(AlphaData\plain)' { + Assert-FileMissing (Join-Path $catExtract 'AlphaData\plain\p.bin') + Assert-True (Test-Path -LiteralPath (Join-Path $catExtract 'AlphaData\keep.txt')) '未被点名的文件必须留着' +} + +Test-Case '任意层级模式 (!*skip) 广播到每个归档项' { + Assert-FileMissing (Join-Path $catExtract 'AlphaData\skip\s.bin') +} + +Test-Case '名录 Slot 自己的 Exclude 在没有条目级 :- 时同样生效' { + $exclExtract = Join-Path $catRoot 'verify-excl' + New-Item -ItemType Directory -Path $exclExtract -Force | Out-Null + if ($sevenZip) { + $null = Invoke-ExternalCommand -FilePath $sevenZip -ArgumentList @('x', '-bso0', '-bsp0', '-y', "-o$exclExtract", (Join-Path $catBackupDir 'cat-excl.7z')) + } + Assert-FileExists (Join-Path $exclExtract 'Data\keep.txt') + Assert-FileMissing (Join-Path $exclExtract 'Data\skip\s.bin') '名录 Slot 的 Exclude 应把 skip 挡在包外' +} + +Remove-Item -LiteralPath $dirA -Recurse -Force +Remove-Item -LiteralPath $settingsFile -Force +Remove-Item -LiteralPath $incDir -Recurse -Force + +Test-Case '删源后按 Slot 恢复:目录 / 文件 / 追加项各自回到自己的 Path' { + & $restoreScript -BackupListPath $catList -BackupDir $catBackupDir -ConfigPath $catConfig -Force + Assert-Equal 0 $LASTEXITCODE + foreach ($path in $catHashes.Keys) { + Assert-FileExists $path + Assert-Equal $catHashes[$path] (Get-Sha256 $path) "对拍 $path" + } +} + +Test-Case '恢复不会把被排除的内容带回来' { + Assert-FileMissing (Join-Path $dirA 'plain\p.bin') + Assert-FileMissing (Join-Path $dirA 'skip\s.bin') + Assert-FileMissing (Join-Path $catAppRoot 'BetaFile') '文件 Slot 的归档内名字不该落到宿主机上' +} + +Test-Case '@pathname 覆盖:用名录里的真实路径跑命名算法(独立备份目录,避免污染共享 manifest)' { + $pathList = Join-Path $catRoot 'list-pathname.txt' + $pathNameDir = Join-Path $catRoot 'Backups-pathname' + [System.IO.File]::WriteAllText($pathList, "my-app @pathname :+ Modules:$settingsFile`n", [System.Text.UTF8Encoding]::new($false)) + # settings.json 刚才被删了,重建一份,让 Include 的宿主机路径存在 + Set-Content -LiteralPath $settingsFile -Value '{"slot":"file"}' -Encoding UTF8 + $expectedBase = Get-BackupBaseName -RawPath $dirA + & $backupScript -BackupListPath $pathList -BackupDir $pathNameDir -ConfigPath $catConfig -Force -QuietTool + Assert-Equal 0 $LASTEXITCODE + Assert-FileExists (Join-Path $pathNameDir ($expectedBase + '.7z')) +} + +# ============================================================================ +# 4. :: 覆盖单 Slot 条目的真实路径 +# ============================================================================ + +$ovrRoot = Join-Path $WorkRoot 'override' +$ovrTarget = Join-Path $ovrRoot 'target' +$ovrBackupDir = Join-Path $ovrRoot 'Backups' +$ovrCatalog = Join-Path $ovrRoot 'catalog.psd1' +$ovrList = Join-Path $ovrRoot 'list.txt' +$ovrConfig = Join-Path $ovrRoot 'config.psd1' +$ovrExtract = Join-Path $ovrRoot 'verify' +New-Item -ItemType Directory -Path $ovrTarget -Force | Out-Null +Set-Content -LiteralPath (Join-Path $ovrTarget 't.txt') -Value 'override-target' -Encoding UTF8 +[System.IO.File]::WriteAllText($ovrCatalog, "@{`n 'ovr-app' = @{ DefaultData = @{ Path = '$ovrRoot\missing-src' } }`n}`n", [System.Text.UTF8Encoding]::new($false)) +[System.IO.File]::WriteAllText($ovrList, "ovr-app :: $ovrTarget`n", [System.Text.UTF8Encoding]::new($false)) +New-E2EConfig -Path $ovrConfig -LogDir (Join-Path $ovrRoot 'logs') -SoftwareCatalog $ovrCatalog + +& $backupScript -BackupListPath $ovrList -BackupDir $ovrBackupDir -ConfigPath $ovrConfig -Force -QuietTool +$ovrExitCode = $LASTEXITCODE +$ovrArchives = @(Get-ChildItem -LiteralPath $ovrBackupDir -File -Filter *.7z -ErrorAction SilentlyContinue) + +Test-Case ':: 覆盖:备份包内容来自被覆盖的路径,且归档项名仍是 Slot 名' { + Assert-Equal 0 $ovrExitCode + Assert-Equal 1 $ovrArchives.Count + New-Item -ItemType Directory -Path $ovrExtract -Force | Out-Null + if ($sevenZip) { + $null = Invoke-ExternalCommand -FilePath $sevenZip -ArgumentList @('x', '-bso0', '-bsp0', '-y', "-o$ovrExtract", $ovrArchives[0].FullName) + } + Assert-FileExists (Join-Path $ovrExtract 'DefaultData\t.txt') + Assert-Equal 'override-target' (Get-Content -LiteralPath (Join-Path $ovrExtract 'DefaultData\t.txt') -Raw).Trim() +} + +Test-Case ':: 覆盖:删掉被覆盖的源后仍能恢复回该路径' { + Remove-Item -LiteralPath $ovrTarget -Recurse -Force + & $restoreScript -BackupListPath $ovrList -BackupDir $ovrBackupDir -ConfigPath $ovrConfig -Force + Assert-Equal 0 $LASTEXITCODE + Assert-FileExists (Join-Path $ovrTarget 't.txt') + Assert-Equal 'override-target' (Get-Content -LiteralPath (Join-Path $ovrTarget 't.txt') -Raw).Trim() +} + +# ============================================================================ +# 5. 方向标记:备份跳 `-`、恢复跳 `+`;`-` 的归档名仍算有主(孤儿审计) +# ============================================================================ + +$dirRoot = Join-Path $WorkRoot 'direction' +$appA = Join-Path $dirRoot 'A' +$appB = Join-Path $dirRoot 'B' +$dirBackupDir = Join-Path $dirRoot 'Backups' +$dirCatalog = Join-Path $dirRoot 'catalog.psd1' +$dirList1 = Join-Path $dirRoot 'list1.txt' +$dirList2 = Join-Path $dirRoot 'list2.txt' +$dirConfig = Join-Path $dirRoot 'config.psd1' +$dirLogDir = Join-Path $dirRoot 'logs' +New-Item -ItemType Directory -Path $appA -Force | Out-Null +New-Item -ItemType Directory -Path $appB -Force | Out-Null +Set-Content -LiteralPath (Join-Path $appA 'a.txt') -Value 'dir-a' -Encoding UTF8 +Set-Content -LiteralPath (Join-Path $appB 'b.txt') -Value 'dir-b' -Encoding UTF8 +[System.IO.File]::WriteAllText($dirCatalog, "@{`n 'app-a' = @{ DefaultData = @{ Path = '$appA' } }`n 'app-b' = @{ DefaultData = @{ Path = '$appB' } }`n}`n", [System.Text.UTF8Encoding]::new($false)) +New-E2EConfig -Path $dirConfig -LogDir $dirLogDir -SoftwareCatalog $dirCatalog +[System.IO.File]::WriteAllText($dirList1, "+ app-a`napp-b`n", [System.Text.UTF8Encoding]::new($false)) +[System.IO.File]::WriteAllText($dirList2, "+ app-a`n- app-b`n", [System.Text.UTF8Encoding]::new($false)) + +& $backupScript -BackupListPath $dirList1 -BackupDir $dirBackupDir -ConfigPath $dirConfig -Force -QuietTool +Test-Case '行首 + = 仅备份:仍然会被打包' { + Assert-Equal 0 $LASTEXITCODE + Assert-FileExists (Join-Path $dirBackupDir 'app-a.7z') + Assert-FileExists (Join-Path $dirBackupDir 'app-b.7z') +} + +# 造一个真孤儿,验证审计仍然会点名它 +Copy-Item -LiteralPath (Join-Path $dirBackupDir 'app-a.7z') -Destination (Join-Path $dirBackupDir 'zzz-orphan.7z') +Start-Sleep -Milliseconds 1100 # 日志按秒命名,避免两次运行撞进同一个文件名 + +& $backupScript -BackupListPath $dirList2 -BackupDir $dirBackupDir -ConfigPath $dirConfig -Force -QuietTool +$dirExitCode = $LASTEXITCODE +$dirLog = Get-NewestLog -LogDir $dirLogDir -Prefix 'backup' + +Test-Case '行首 - = 仅备份端跳过(日志点名),不产生归档' { + Assert-Equal 0 $dirExitCode + $content = Get-Content -LiteralPath $dirLog.FullName -Raw -Encoding UTF8 + Assert-True ($content -like '*跳过(行首 -,仅恢复)*') '日志里应说明为什么跳过' +} + +Test-Case '孤儿审计:`-` 条目的归档名算有主,真孤儿才被点名' { + $orphans = Get-OrphanNames -LogPath $dirLog.FullName + Assert-True ($orphans -contains 'zzz-orphan.7z') '真孤儿必须被点名' + Assert-False ($orphans -contains 'app-b.7z') '`-` 条目的归档不能被误报成孤儿' +} + +Test-Case '恢复端跳过行首 + 的条目、照常恢复 - 的条目' { + Remove-Item -LiteralPath $appA -Recurse -Force + Remove-Item -LiteralPath $appB -Recurse -Force + # -Verbose 打开 DEBUG 日志,才能从日志里读到"为什么跳过"(默认只打 INFO) + & $restoreScript -BackupListPath $dirList2 -BackupDir $dirBackupDir -ConfigPath $dirConfig -Force -Verbose + Assert-Equal 0 $LASTEXITCODE + Assert-FileMissing $appA '行首 + 的条目不该被恢复' + Assert-FileExists (Join-Path $appB 'b.txt') + Assert-Equal 'dir-b' (Get-Content -LiteralPath (Join-Path $appB 'b.txt') -Raw).Trim() + + $restoreLog = Get-NewestLog -LogDir $dirLogDir -Prefix 'restore' + $restoreContent = Get-Content -LiteralPath $restoreLog.FullName -Raw -Encoding UTF8 + Assert-True ($restoreContent -like '*跳过(行首 +,仅备份)*') '日志里应说明为什么跳过' +} + +# ============================================================================ +# 6. 旧布局归档的恢复(manifest 没有 layouts 时按 <末级名> 回退) +# ============================================================================ + +$legacyRoot = Join-Path $WorkRoot 'legacy' +$legacyLive = Join-Path $legacyRoot 'live\settings.json' +$legacyBackupDir = Join-Path $legacyRoot 'Backups' +$legacyCatalog = Join-Path $legacyRoot 'catalog.psd1' +$legacyList = Join-Path $legacyRoot 'list.txt' +$legacyConfig = Join-Path $legacyRoot 'config.psd1' +$legacyLogDir = Join-Path $legacyRoot 'logs' +$legacyScratch = Join-Path $legacyRoot 'scratch' +New-Item -ItemType Directory -Path (Split-Path -Parent $legacyLive) -Force | Out-Null +New-Item -ItemType Directory -Path $legacyBackupDir -Force | Out-Null +New-Item -ItemType Directory -Path $legacyScratch -Force | Out-Null +Set-Content -LiteralPath $legacyLive -Value '{"version":"old-layout"}' -Encoding UTF8 + +# 手工造一份重构前布局的归档:包内顶层直接是源文件的名字 +$legacySourceFile = Join-Path $legacyScratch 'settings.json' +Copy-Item -LiteralPath $legacyLive -Destination $legacySourceFile +if ($sevenZip) { + $null = Invoke-ExternalCommand -FilePath $sevenZip ` + -ArgumentList @('a', '-t7z', '-mx=1', '-bso0', '-bsp0', (Join-Path $legacyBackupDir 'legacy-file.7z'), 'settings.json') ` + -WorkingDirectory $legacyScratch +} + +[System.IO.File]::WriteAllText($legacyCatalog, "@{`n 'legacy-file' = @{ LegacyData = @{ Path = '$legacyLive' } }`n}`n", [System.Text.UTF8Encoding]::new($false)) +[System.IO.File]::WriteAllText($legacyList, "legacy-file`n", [System.Text.UTF8Encoding]::new($false)) +New-E2EConfig -Path $legacyConfig -LogDir $legacyLogDir -SoftwareCatalog $legacyCatalog + +$legacyManifest = Read-BaknretManifest -Path (Join-Path $legacyBackupDir 'manifest.json') +$legacyManifest.items['legacy-file'] = [ordered]@{ + baseName = 'legacy-file' + source = 'legacy-file' + archive = 'legacy-file.7z' + action = 'backed-up' + encrypted = $false +} +Write-BaknretManifest -Path (Join-Path $legacyBackupDir 'manifest.json') -Manifest $legacyManifest | Out-Null + +# 让"恢复确实做了事"可验证:把活文件改成别的内容,恢复后应回到归档里的内容 +Set-Content -LiteralPath $legacyLive -Value '{"version":"changed-after-backup"}' -Encoding UTF8 + +if ($sevenZip) { + & $restoreScript -BackupListPath $legacyList -BackupDir $legacyBackupDir -ConfigPath $legacyConfig -Force + $legacyExitCode = $LASTEXITCODE + + Test-Case '旧布局归档:按 <末级名> 回退,把文件还原回原位' { + Assert-Equal 0 $legacyExitCode + Assert-Equal '{"version":"old-layout"}' (Get-Content -LiteralPath $legacyLive -Raw).Trim() + $legacyLog = Get-NewestLog -LogDir $legacyLogDir -Prefix 'restore' + $legacyContent = Get-Content -LiteralPath $legacyLog.FullName -Raw -Encoding UTF8 + Assert-True ($legacyContent -like '*按旧布局回退*') '回退时必须给出明确告警' + } +} + +# ============================================================================ +# 7. DryRun 不写盘 # ============================================================================ if (Test-Path -LiteralPath $source) { Remove-Item -LiteralPath $source -Recurse -Force } -& $restoreScript -BackupListPath $listPath -BackupDir $backupDir -DryRun +& $restoreScript -BackupListPath $listPath -BackupDir $backupDir -ConfigPath $cfg1 -DryRun $dryRestoreExitCode = $LASTEXITCODE Test-Case 'Restore -DryRun:退出码 0、不创建目标目录' { @@ -241,7 +616,7 @@ Test-Case 'Restore -DryRun:退出码 0、不创建目标目录' { Assert-FileMissing $source } -& $backupScript -BackupListPath $listPath -BackupDir $dryBackupDir -Force -QuietTool -DryRun +& $backupScript -BackupListPath $listPath -BackupDir $dryBackupDir -ConfigPath $cfg1 -Force -QuietTool -DryRun $dryBackupExitCode = $LASTEXITCODE Test-Case 'Backup -DryRun:退出码 0、不写归档也不写 manifest' { @@ -253,68 +628,9 @@ Test-Case 'Backup -DryRun:退出码 0、不写归档也不写 manifest' { } # ============================================================================ -# 6. 软件名录:清单里写软件名,归档名就是软件名 +# 8. 失败路径 # ============================================================================ -$catRoot = Join-Path $WorkRoot 'catalog' -$catSource = Join-Path $catRoot 'src' -$catTarget = Join-Path $catSource 'My App' # 真实目录名与软件名刻意不同 -$catBackupDir = Join-Path $catRoot 'Backups' -$catFile = Join-Path $catRoot 'SoftwareCatalog.psd1' -$catList = Join-Path $catRoot 'list.txt' -$catConfig = Join-Path $catRoot 'config.psd1' - -New-Item -ItemType Directory -Path $catTarget -Force | Out-Null -Set-Content -LiteralPath (Join-Path $catTarget 'data.txt') -Value 'catalog-test' -Encoding UTF8 -Set-Content -LiteralPath (Join-Path $catTarget 'skip.bin') -Value 'nope' -Encoding UTF8 - -[System.IO.File]::WriteAllText($catFile, "@{`n 'my-app' = '$catTarget'`n}`n", [System.Text.UTF8Encoding]::new($false)) -[System.IO.File]::WriteAllText($catList, "my-app :: skip.bin`n", [System.Text.UTF8Encoding]::new($false)) -[System.IO.File]::WriteAllText($catConfig, "@{ SoftwareCatalog = '$catFile' }`n", [System.Text.UTF8Encoding]::new($false)) - -& $backupScript -BackupListPath $catList -BackupDir $catBackupDir -ConfigPath $catConfig -Force -QuietTool -$catExitCode = $LASTEXITCODE -$catArchive = Join-Path $catBackupDir 'my-app.7z' - -Test-Case '清单里写软件名 -> 归档名就是软件名' { - Assert-Equal 0 $catExitCode - Assert-FileExists $catArchive -} - -Test-Case '软件名条目的归档内容与历史布局一致(根目录仍是源目录名)' { - $extract = Join-Path $catRoot 'verify' - New-Item -ItemType Directory -Path $extract -Force | Out-Null - $sevenZip = Get-Command 7z -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source - if ($sevenZip) { - $null = Invoke-ExternalCommand -FilePath $sevenZip -ArgumentList @('x', '-bso0', '-bsp0', '-y', "-o$extract", $catArchive) - # 归档文件名是软件名 my-app,但包内根目录是源目录名 My App - Assert-FileExists (Join-Path $extract 'My App\data.txt') - Assert-FileMissing (Join-Path $extract 'my-app') '包内不应多出一层软件名' - Assert-FileMissing (Join-Path $extract 'My App\skip.bin') '排除模式以源目录名为前缀,仍然生效' - } -} - -Test-Case '@pathname 覆盖:强制用路径命名算法(用独立备份目录,避免污染共享 manifest)' { - $pathList = Join-Path $catRoot 'list-pathname.txt' - $pathNameDir = Join-Path $catRoot 'Backups-pathname' - [System.IO.File]::WriteAllText($pathList, "my-app @pathname`n", [System.Text.UTF8Encoding]::new($false)) - & $backupScript -BackupListPath $pathList -BackupDir $pathNameDir -ConfigPath $catConfig -Force -QuietTool - Assert-Equal 0 $LASTEXITCODE - - # 名录里存的是绝对路径,所以路径命名结果也基于它 - $expectedBase = Get-BackupBaseName -RawPath $catTarget - Assert-FileExists (Join-Path $pathNameDir ($expectedBase + '.7z')) -} - -Test-Case '软件名录条目:删源后能按原路径恢复' { - Remove-Item -LiteralPath $catTarget -Recurse -Force - & $restoreScript -BackupListPath $catList -BackupDir $catBackupDir -ConfigPath $catConfig -Force - Assert-Equal 0 $LASTEXITCODE - Assert-FileExists (Join-Path $catTarget 'data.txt') - Assert-Equal 'catalog-test' (Get-Content -LiteralPath (Join-Path $catTarget 'data.txt') -Raw).Trim() -} - - Test-Case '归档名重复时直接报失败,不静默互相覆盖' { $dupList = Join-Path $catRoot 'dup.txt' [System.IO.File]::WriteAllText($dupList, "my-app`nmy-app`n", [System.Text.UTF8Encoding]::new($false)) @@ -324,28 +640,16 @@ Test-Case '归档名重复时直接报失败,不静默互相覆盖' { Test-Case '字面路径不受名录影响,仍走路径命名' { $literalList = Join-Path $catRoot 'list-literal.txt' - [System.IO.File]::WriteAllText($literalList, "$catTarget`n", [System.Text.UTF8Encoding]::new($false)) - & $backupScript -BackupListPath $literalList -BackupDir $catBackupDir -ConfigPath $catConfig -Force -QuietTool + $literalDir = Join-Path $catRoot 'Backups-literal' + [System.IO.File]::WriteAllText($literalList, "$dirA2`n", [System.Text.UTF8Encoding]::new($false)) + & $backupScript -BackupListPath $literalList -BackupDir $literalDir -ConfigPath $catConfig -Force -QuietTool Assert-Equal 0 $LASTEXITCODE + Assert-FileExists (Join-Path $literalDir ((Get-BackupBaseName -RawPath $dirA2) + '.7z')) } -Test-Case '名录里没有该软件名时记为 missing-source,而不是崩掉' { - $badList = Join-Path $catRoot 'bad.txt' - [System.IO.File]::WriteAllText($badList, "no-such-app`n", [System.Text.UTF8Encoding]::new($false)) - & $backupScript -BackupListPath $badList -BackupDir $catBackupDir -ConfigPath $catConfig -Force -QuietTool - Assert-Equal 0 $LASTEXITCODE '跳过不算失败' - $rec = (Read-BaknretManifest -Path (Join-Path $catBackupDir 'manifest.json')).items['no-such-app'] - Assert-True ($null -ne $rec) '应留下记录' - Assert-Equal 'missing-source' $rec.action -} - -# ============================================================================ -# 7. 失败路径:源不存在时必须留下可核对的记录 -# ============================================================================ - $missingList = Join-Path $WorkRoot 'missing.txt' [System.IO.File]::WriteAllText($missingList, "Z:\definitely-not-here-12345`n", [System.Text.UTF8Encoding]::new($false)) -& $backupScript -BackupListPath $missingList -BackupDir $backupDir -Force -QuietTool +& $backupScript -BackupListPath $missingList -BackupDir $backupDir -ConfigPath $cfg1 -Force -QuietTool $missingExitCode = $LASTEXITCODE Test-Case '源路径不存在被记为 missing-source,退出码仍为 0(跳过不算失败)' { diff --git a/tests/Run-Pester.ps1 b/tests/Run-Pester.ps1 index 2a9fb50..1f15321 100644 --- a/tests/Run-Pester.ps1 +++ b/tests/Run-Pester.ps1 @@ -74,11 +74,26 @@ $configuration.Run.Exit = $false $configuration.Output.Verbosity = $Verbosity if ($Tag) { $configuration.Filter.Tag = $Tag } +# 关掉 Pester 的 TestRegistry:它会去写注册表(HKCU 下的测试键), +# 在受限环境 / 沙箱里会被拒绝,于是**所有**容器都以 +# "Was not able to registry key for TestRegistry" 失败。 +# 本套件不用 TestRegistry(只用临时目录),关掉它不影响任何用例。 +$configuration.TestRegistry.Enabled = $false + $result = Invoke-Pester -Configuration $configuration +# 容器级失败(发现阶段的语法错误、Describe 外的异常)不会进 FailedCount, +# 只会在输出里出现一行 "Container failed" —— 不显式检查就会把"根本没跑起来" +# 报成"全部通过"。这里把它也当成失败。 +$failedContainers = @($result.Containers | Where-Object { $_.Result -eq 'Failed' }) + Write-Host '' -if ($result.FailedCount -gt 0) { - Write-Host ("Pester 测试通过 {0} 项,失败 {1} 项,跳过 {2} 项" -f $result.PassedCount, $result.FailedCount, $result.SkippedCount) -ForegroundColor Red +if ($result.FailedCount -gt 0 -or $failedContainers.Count -gt 0) { + Write-Host ("Pester 测试通过 {0} 项,失败 {1} 项,跳过 {2} 项,容器级失败 {3} 个" -f ` + $result.PassedCount, $result.FailedCount, $result.SkippedCount, $failedContainers.Count) -ForegroundColor Red + foreach ($container in $failedContainers) { + Write-Host (" 容器失败:{0}" -f $container.Item) -ForegroundColor Red + } exit 1 } diff --git a/tests/Run-Tests.ps1 b/tests/Run-Tests.ps1 index df25e16..726faf3 100644 --- a/tests/Run-Tests.ps1 +++ b/tests/Run-Tests.ps1 @@ -7,12 +7,16 @@ 直接语法错误,而为了跑测试去装 Pester 5 不值得。这里自带一个极简断言运行器, 对备份工具这种小脚本来说更可靠。 - 覆盖范围: - * BackupList.txt 语法解析(包含历史上出错的两种写法) - * 归档命名与逆向解析 - * 7z 排除参数翻译(含空格、! 前缀、重复前缀、禁用引号) + 覆盖范围(与重构后的 BackupList / SoftwareCatalog 契约一致): + * BackupList.txt 行语法:方向标记、`::` 路径覆盖、`:-` / `:+`、`:encrypt`、 + `@ Key='Value'` 覆盖、历史写法、行尾 `# 说明`、记号边界 + * 软件名录新结构(`软件名 -> Slot -> @{ Path = ... }`):Slot 排序、前缀补全、 + 多候选报错、`%VAR%` / `$( ... )` 展开、缓存与 -NoCache + * 归档命名与逆向解析(软件名 / 字面路径 / @pathname) + * 归档项与归档内布局(New-BaknretArchiveItem / 暂存 junction / 硬链接) + * 7z 排除参数翻译(!前缀、!re: 正则展开、Slot 前缀分配、去重) * Windows 命令行拼接 - * manifest 读写往返 + * manifest 读写往返与 layouts * 配置默认值与嵌套合并 * 集成测试:真的调用 7z 打包 + 解压,验证排除结果落在文件系统上 @@ -41,128 +45,253 @@ Test-Case '注释与空行返回 $null' { Assert-Null (ConvertFrom-BackupListLine -Line '') } -Test-Case '裸路径' { - $r = ConvertFrom-BackupListLine -Line 'C:\Programs\FooClolor' - Assert-Equal 'C:\Programs\FooClolor' $r.Path - Assert-Equal 0 $r.ExcludePatterns.Count - Assert-Equal 0 $r.Flags.Count +Test-Case '只有方向标记没有目标时返回 $null(不抛异常)' { + Assert-Null (ConvertFrom-BackupListLine -Line '+') + Assert-Null (ConvertFrom-BackupListLine -Line '-') } -Test-Case '逗号分隔的排除表被拆成多个模式(旧实现只认分号,这里曾经整串当成一个)' { - $r = ConvertFrom-BackupListLine -Line 'C:\Programs\March7thAssistant :: a\b,c\d\' +Test-Case '只有修饰符没有目标时返回 $null' { + Assert-Null (ConvertFrom-BackupListLine -Line '::') + Assert-Null (ConvertFrom-BackupListLine -Line ':- x') +} + +Test-Case '裸路径:方向 both、按字面路径处理、没有任何覆盖' { + $r = ConvertFrom-BackupListLine -Line 'C:\Programs\FooClolor' + Assert-Equal 'C:\Programs\FooClolor' $r.Path + Assert-Equal 'both' $r.Direction + Assert-False $r.IsName + Assert-Equal 0 $r.Overrides.Keys.Count + Assert-Equal 0 $r.ExcludePatterns.Count + Assert-Equal 0 $r.Flags.Count + Assert-Equal 0 $r.UnknownKeys.Count +} + +Test-Case '行首 + 表示仅备份' { + $r = ConvertFrom-BackupListLine -Line '+ C:\Programs\FooClolor :- logs\' + Assert-Equal 'backup' $r.Direction + Assert-Equal 'C:\Programs\FooClolor' $r.Path + Assert-Equal 1 $r.ExcludePatterns.Count +} + +Test-Case '行首 - 表示仅恢复' { + $r = ConvertFrom-BackupListLine -Line '- C:\Programs\FooClolor' + Assert-Equal 'restore' $r.Direction + Assert-Equal 'C:\Programs\FooClolor' $r.Path +} + +Test-Case '没有方向标记时是 both(同时备份与恢复)' { + Assert-Equal 'both' (ConvertFrom-BackupListLine -Line 'C:\x').Direction +} + +Test-Case ':- 的逗号分隔排除表被拆成多个模式' { + $r = ConvertFrom-BackupListLine -Line 'C:\Programs\March7thAssistant :- a\b,c\d\' Assert-Equal 'C:\Programs\March7thAssistant' $r.Path Assert-Equal 2 $r.ExcludePatterns.Count Assert-Equal 'a\b' $r.ExcludePatterns[0] Assert-Equal 'c\d\' $r.ExcludePatterns[1] + Assert-True $r.Overrides.ContainsKey('Exclude') } -Test-Case '分号分隔同样支持' { - $r = ConvertFrom-BackupListLine -Line 'C:\x :: a;b' +Test-Case ':- 的分号分隔同样支持' { + $r = ConvertFrom-BackupListLine -Line 'C:\x :- a;b' Assert-Equal 2 $r.ExcludePatterns.Count } Test-Case '引号只包路径时排除表正常解析' { - $r = ConvertFrom-BackupListLine -Line '"C:\Program Files\App" :: cache\*,logs\' + $r = ConvertFrom-BackupListLine -Line '"C:\Program Files\App" :- cache\*,logs\' Assert-Equal 'C:\Program Files\App' $r.Path Assert-Equal 2 $r.ExcludePatterns.Count + Assert-Equal 'cache\*' $r.ExcludePatterns[0] } -Test-Case '整行被一对引号包住时,:: 之后的排除表不被吞进路径(真实第 27 行)' { - $line = '"C:\Programs\ScoopApps\persist :: persist\a\DawnCache,persist\a\GPUCache"' +Test-Case '模式值本身可以带引号(真实清单里的 :- ''a,b'' 写法)' { + $r = ConvertFrom-BackupListLine -Line "C:\x :- 'a,b'" + Assert-Equal 2 $r.ExcludePatterns.Count + Assert-Equal 'a' $r.ExcludePatterns[0] + Assert-Equal 'b' $r.ExcludePatterns[1] +} + +Test-Case '整行被一对引号包住时是一个记号:引号内的 :: / :- 不再是指令' { + $line = '"C:\Programs\ScoopApps\persist :- persist\a\DawnCache,persist\a\GPUCache"' $r = ConvertFrom-BackupListLine -Line $line - Assert-Equal 'C:\Programs\ScoopApps\persist' $r.Path - Assert-Equal 2 $r.ExcludePatterns.Count - Assert-Equal 'persist\a\DawnCache' $r.ExcludePatterns[0] + Assert-Equal 'C:\Programs\ScoopApps\persist :- persist\a\DawnCache,persist\a\GPUCache' $r.Path + Assert-Equal 0 $r.ExcludePatterns.Count + Assert-Equal 0 $r.Overrides.Keys.Count } -Test-Case '@ 标记单独出现' { - $r = ConvertFrom-BackupListLine -Line '%UserProfile%\.ssh @encrypt' - Assert-Equal '%UserProfile%\.ssh' $r.Path - Assert-Equal 1 $r.Flags.Count - Assert-Equal 'encrypt' $r.Flags[0] +Test-Case ':: 现在表示覆盖 Path,而不是 :- 的历史别名' { + $r = ConvertFrom-BackupListLine -Line 'MyApp :: C:\overridden' + Assert-Equal 'MyApp' $r.Path + Assert-True $r.Overrides.ContainsKey('Path') '必须记下 Path 覆盖' + Assert-Equal 'C:\overridden' $r.Overrides['Path'] + Assert-False $r.Overrides.ContainsKey('Exclude') ':: 不能再被当成排除' Assert-Equal 0 $r.ExcludePatterns.Count } -Test-Case '@ 标记跟在排除表后面' { - $r = ConvertFrom-BackupListLine -Line 'C:\x :: a,b @encrypt' - Assert-Equal 'C:\x' $r.Path - Assert-Equal 2 $r.ExcludePatterns.Count - Assert-Equal 'encrypt' $r.Flags[0] +Test-Case ':- 与 @ Exclude= 走同一条路(都是排除)' { + $a = ConvertFrom-BackupListLine -Line 'X :- logs\' + $b = ConvertFrom-BackupListLine -Line "X @ Exclude='logs\'" + Assert-Equal 'logs\' $a.ExcludePatterns[0] + Assert-Equal 'logs\' $b.ExcludePatterns[0] } -Test-Case ':+ 追加目录(可多个、位置无关)' { - $r = ConvertFrom-BackupListLine -Line 'MyApp :+ D:\a :+ D:\b' + +Test-Case ':+ 追加包含项:一个记号可以带多个逗号分隔的值' { + $r = ConvertFrom-BackupListLine -Line 'MyApp :+ ModA:D:\a,ModB:D:\b' Assert-Equal 'MyApp' $r.Path - Assert-Equal 2 $r.AddedPaths.Count - Assert-Equal 'D:\a' $r.AddedPaths[0] - Assert-Equal 'D:\b' $r.AddedPaths[1] + Assert-True $r.Overrides.ContainsKey('Include') + Assert-Equal 2 $r.Includes.Count + Assert-Equal 'ModA:D:\a' $r.Includes[0] + Assert-Equal 'ModB:D:\b' $r.Includes[1] Assert-Equal 0 $r.ExcludePatterns.Count } -Test-Case ':- 排除,与 :+ 混用且顺序任意' { - $r = ConvertFrom-BackupListLine -Line 'MyApp :- logs\ :+ D:\a :- !*Cache' +Test-Case ':- 与 :+ 混用且顺序任意(每个记号带自己的值表)' { + $r = ConvertFrom-BackupListLine -Line 'MyApp :- logs\,!*Cache :+ Mod:D:\a' Assert-Equal 'MyApp' $r.Path - Assert-Equal 1 $r.AddedPaths.Count + Assert-Equal 1 $r.Includes.Count + Assert-Equal 'Mod:D:\a' $r.Includes[0] Assert-Equal 2 $r.ExcludePatterns.Count Assert-Equal 'logs\' $r.ExcludePatterns[0] Assert-Equal '!*Cache' $r.ExcludePatterns[1] } -Test-Case ':: 与 :- 等价' { - $a = ConvertFrom-BackupListLine -Line 'X :: logs\' - $b = ConvertFrom-BackupListLine -Line 'X :- logs\' - Assert-Equal $a.ExcludePatterns[0] $b.ExcludePatterns[0] - Assert-Equal 'logs\' $b.ExcludePatterns[0] +Test-Case '同一行重复写 :- / :+ 时累积(不静默丢掉前一条规则)' { + $r = ConvertFrom-BackupListLine -Line 'MyApp :- logs\ :+ D:\a :- !*Cache' + Assert-Equal 2 $r.ExcludePatterns.Count + Assert-Equal 'logs\' $r.ExcludePatterns[0] + Assert-Equal '!*Cache' $r.ExcludePatterns[1] + Assert-Equal 1 $r.Includes.Count + Assert-Equal 'D:\a' $r.Includes[0] } -Test-Case ':+ 段里的 @标记也能被摘出' { - $r = ConvertFrom-BackupListLine -Line 'MyApp :+ D:\a @encrypt' - Assert-Equal 'MyApp' $r.Path - Assert-Equal 'D:\a' $r.AddedPaths[0] - Assert-Equal 'encrypt' $r.Flags[0] +Test-Case ':encrypt / :!encrypt 直接给出布尔覆盖' { + $on = ConvertFrom-BackupListLine -Line 'MyApp :encrypt' + Assert-True $on.Overrides.ContainsKey('Encrypt') + Assert-Equal $true $on.Overrides['Encrypt'] + $off = ConvertFrom-BackupListLine -Line 'MyApp :!encrypt' + Assert-Equal $false $off.Overrides['Encrypt'] +} + +Test-Case '@ Key=''Value'' 覆盖:Exclude / Include / Encrypt' { + $r = ConvertFrom-BackupListLine -Line "App @ Exclude='a,b' @ Include='Mod:D:\m' @ Encrypt='`$false'" + Assert-Equal 2 $r.ExcludePatterns.Count + Assert-Equal 1 $r.Includes.Count + Assert-Equal 'Mod:D:\m' $r.Includes[0] + Assert-Equal $false $r.Overrides['Encrypt'] +} + +Test-Case '@ Encrypt=''yes'' 之类的真值也能被识别' { + foreach ($truthy in '$true', 'true', '1', 'yes', 'on') { + $r = ConvertFrom-BackupListLine -Line "App @ Encrypt='$truthy'" + Assert-Equal $true $r.Overrides['Encrypt'] "值 $truthy 应视为真" + } + foreach ($falsy in '$false', 'false', '0', 'no', 'off') { + $r = ConvertFrom-BackupListLine -Line "App @ Encrypt='$falsy'" + Assert-Equal $false $r.Overrides['Encrypt'] "值 $falsy 应视为假" + } +} + +Test-Case '未知的 @ 字段被记入 UnknownKeys 而不是静默吞掉' { + $r = ConvertFrom-BackupListLine -Line "App @ UnknownKey='v'" + Assert-Equal 1 $r.UnknownKeys.Count + Assert-Equal 'UnknownKey' $r.UnknownKeys[0] + Assert-Equal 0 $r.Overrides.Keys.Count +} + +Test-Case '历史写法 @encrypt / @!encrypt 仍然被识别成加密覆盖' { + $on = ConvertFrom-BackupListLine -Line '%UserProfile%\.ssh @encrypt' + Assert-Equal '%UserProfile%\.ssh' $on.Path + Assert-True $on.Overrides.ContainsKey('Encrypt') + Assert-Equal $true $on.Overrides['Encrypt'] + Assert-Equal 0 $on.Flags.Count '不再作为无意义的 Flags 传下去' + + $off = ConvertFrom-BackupListLine -Line '%UserProfile%\.ssh @!encrypt' + Assert-Equal $false $off.Overrides['Encrypt'] +} + +Test-Case '历史写法 @pathname / @root=<名> 被记入 Flags' { + Assert-Equal 'pathname' (ConvertFrom-BackupListLine -Line 'App @pathname').Flags[0] + Assert-Equal 'root=xyz' (ConvertFrom-BackupListLine -Line 'App @root=xyz').Flags[0] +} + +Test-Case '@ 标记跟在排除表后面' { + $r = ConvertFrom-BackupListLine -Line 'C:\x :- a,b @encrypt' + Assert-Equal 'C:\x' $r.Path + Assert-Equal 2 $r.ExcludePatterns.Count + Assert-Equal $true $r.Overrides['Encrypt'] } Test-Case '盘符里的单个冒号不被误当分隔符' { $r = ConvertFrom-BackupListLine -Line 'C:\Programs\Foo' Assert-Equal 'C:\Programs\Foo' $r.Path - Assert-Equal 0 $r.AddedPaths.Count + Assert-Equal 0 $r.Includes.Count Assert-Equal 0 $r.ExcludePatterns.Count } -Test-Case '名录 Dirs 数组:一个软件多个目录' { - $dirsSandbox = Join-Path $env:TEMP ("baknret-dirs-" + [guid]::NewGuid().ToString('N').Substring(0, 6)) - New-Item -ItemType Directory -Path (Join-Path $dirsSandbox 'App') -Force | Out-Null - New-Item -ItemType Directory -Path (Join-Path $dirsSandbox 'Config') -Force | Out-Null - $catPath = Join-Path $dirsSandbox 'c.psd1' - $content = "@{`n MyApp = @{ Dirs = @('$dirsSandbox\App', '$dirsSandbox\Config') }`n}`n" - [System.IO.File]::WriteAllText($catPath, $content, [System.Text.UTF8Encoding]::new($false)) - - $catalog = Get-SoftwareCatalog -Path $catPath - Assert-Equal 'Multi' $catalog['MyApp'].Kind - Assert-Equal 2 $catalog['MyApp'].Dirs.Count - - $item = ConvertFrom-BackupListLine -Line 'MyApp' - $resolved = Resolve-BackupEntry -Entry $item -CatalogPath $catPath - Assert-Equal 'MyApp' $resolved.BaseName - Assert-Equal 2 $resolved.Sources.Count '每个目录都该成为一条源' - Assert-Equal 'App' $resolved.Sources[0].RelativePaths[0] - Assert-Equal 'Config' $resolved.Sources[1].RelativePaths[0] - Remove-Item -LiteralPath $dirsSandbox -Recurse -Force -ErrorAction SilentlyContinue +Test-Case '标记必须是独立记号:C:\a:-b 仍然是一个路径' { + $r = ConvertFrom-BackupListLine -Line 'C:\a:-b' + Assert-Equal 'C:\a:-b' $r.Path + Assert-Equal 0 $r.Overrides.Keys.Count + Assert-Null (Test-BaknretMarker -Token 'C:\a:-b') + Assert-Null (Test-BaknretMarker -Token ':+X') } -Test-Case '清单 :+ 追加的目录叠加在名录目录之后' { - $dirsSandbox = Join-Path $env:TEMP ("baknret-adds-" + [guid]::NewGuid().ToString('N').Substring(0, 6)) - New-Item -ItemType Directory -Path (Join-Path $dirsSandbox 'App') -Force | Out-Null - New-Item -ItemType Directory -Path (Join-Path $dirsSandbox 'Extra') -Force | Out-Null - $catPath = Join-Path $dirsSandbox 'c.psd1' - $content = "@{`n MyApp = '$dirsSandbox\App'`n}`n" - [System.IO.File]::WriteAllText($catPath, $content, [System.Text.UTF8Encoding]::new($false)) +Test-Case 'Test-BaknretMarker 识别全部标记(大小写敏感)' { + Assert-Equal 'path' (Test-BaknretMarker -Token '::') + Assert-Equal 'exclude' (Test-BaknretMarker -Token ':-') + Assert-Equal 'include' (Test-BaknretMarker -Token ':+') + Assert-Equal 'encrypt' (Test-BaknretMarker -Token ':encrypt') + Assert-Equal 'noencrypt' (Test-BaknretMarker -Token ':!encrypt') + Assert-Equal 'at' (Test-BaknretMarker -Token '@x') + Assert-Equal 'path' (Test-BaknretMarker -Token ' :: ') +} - $item = ConvertFrom-BackupListLine -Line "MyApp :+ $dirsSandbox\Extra" - $resolved = Resolve-BackupEntry -Entry $item -CatalogPath $catPath - Assert-Equal 2 $resolved.Sources.Count '名录 1 个 + 追加 1 个' - Assert-Equal 'App' $resolved.Sources[0].RelativePaths[0] - Assert-Equal 'Extra' $resolved.Sources[1].RelativePaths[0] - Remove-Item -LiteralPath $dirsSandbox -Recurse -Force -ErrorAction SilentlyContinue +Test-Case '记号里有空格时目标可以带空格(第一个修饰符之前全是目标)' { + $r = ConvertFrom-BackupListLine -Line 'My App With Spaces :- logs\' + Assert-Equal 'My App With Spaces' $r.Path + Assert-Equal 1 $r.ExcludePatterns.Count +} + +Test-Case '行尾 # 说明会被摘出来' { + $r = ConvertFrom-BackupListLine -Line 'App :- a\b # 这是说明' + Assert-Equal 'App' $r.Path + Assert-Equal '这是说明' $r.Comment + Assert-Equal 'a\b' $r.ExcludePatterns[0] +} + +Test-Case '路径里紧贴的 # 不会被当成注释' { + $r = ConvertFrom-BackupListLine -Line 'C:\p\a#b' + Assert-Equal 'C:\p\a#b' $r.Path + Assert-Null $r.Comment +} + +Test-Case '没有说明时 Comment 为 $null' { + Assert-Null (ConvertFrom-BackupListLine -Line 'App').Comment +} + +Test-Case 'Split-BaknretToken:引号内的空白不切分,引号保留' { + $tokens = @(Split-BaknretToken -Text 'a "b c" d :- ''e,f''') + Assert-Equal 5 $tokens.Count + Assert-Equal 'a' $tokens[0] + Assert-Equal '"b c"' $tokens[1] + Assert-Equal 'd' $tokens[2] + Assert-Equal ':-' $tokens[3] + Assert-Equal "'e,f'" $tokens[4] +} + +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 'plain' (Remove-BaknretQuote -Text 'plain') +} + +Test-Case 'Test-LiteralPath:带分隔符或 % 的按路径,其余按软件名' { + Assert-True (Test-LiteralPath -Path 'C:\a') + Assert-True (Test-LiteralPath -Path 'C:/a') + Assert-True (Test-LiteralPath -Path '%UserProfile%\.ssh') + Assert-False (Test-LiteralPath -Path 'MyApp') + Assert-True (Test-LiteralPath -Path '') } # ============================================================================ @@ -175,6 +304,10 @@ Test-Case '基础命名规则' { Assert-Equal 'settings.json_from_C_+Programs' (Get-BackupBaseName -RawPath 'C:\Programs\settings.json') } +Test-Case '盘符冒号被归一化成下划线' { + Assert-Equal 'a_from_C_' (Get-BackupBaseName -RawPath 'C:\a') +} + Test-Case '/ 与 \ 以及重复分隔符结果一致' { $a = Get-BackupBaseName -RawPath '%UserProfile%/.config/scoop' $b = Get-BackupBaseName -RawPath '%UserProfile%\.config\scoop' @@ -196,50 +329,224 @@ Test-Case '命名 <-> 路径往返' { } } -Test-Case '归档名里不含非法文件名字符' { +Test-Case '归档名里不含非法文件名字符(保留 & % +)' { $base = Get-BackupBaseName -RawPath 'C:\a\b?c*d|e' $invalid = [System.IO.Path]::GetInvalidFileNameChars() | Where-Object { $_ -notin @('&', '%', '+') } foreach ($ch in $invalid) { Assert-False ($base.Contains([string]$ch)) "归档名里出现了非法字符 $ch" } + Assert-Equal 'keep%and&and+plus' (Format-CatalogName -Name 'keep%and&and+plus') +} + +Test-Case 'Get-BaknretArchiveTopName:取归档内相对路径的第一段' { + Assert-Equal 'Slot' (Get-BaknretArchiveTopName -ArchivePath 'Slot\a\b') + Assert-Equal 'Slot' (Get-BaknretArchiveTopName -ArchivePath '\Slot\') + Assert-Equal 'leaf' (Get-BaknretArchiveTopName -ArchivePath 'leaf') + Assert-Equal '' (Get-BaknretArchiveTopName -ArchivePath ' ') +} + +# ============================================================================ +Write-Host "`n== 归档项构造与暂存 ==" -ForegroundColor Cyan +# ============================================================================ + +$itemSandbox = Join-Path $env:TEMP ("baknret-item-" + [guid]::NewGuid().ToString('N').Substring(0, 8)) + +Test-Case 'New-BaknretArchiveItem:归档内路径被清理,TopName 是第一段' { + $item = New-BaknretArchiveItem -ArchivePath '\AlphaData\' -RealPath 'C:\x' + Assert-Equal 'AlphaData' $item.ArchivePath + Assert-Equal 'AlphaData' $item.TopName + Assert-Equal 'slot' $item.Kind + Assert-Equal 'catalog' $item.Origin + Assert-False $item.Exists + Assert-False $item.IsFile + Assert-Equal 0 $item.Exclude.Count + + $nested = New-BaknretArchiveItem -ArchivePath 'Alpha\sub' -RealPath 'C:\y' -Kind 'include' -Origin 'include' + Assert-Equal 'Alpha' $nested.TopName + Assert-Equal 'include' $nested.Kind +} + +Test-Case '归档项的属性集与契约一致(没有旧的 Sources / Dirs / Variants)' { + $item = New-BaknretArchiveItem -ArchivePath 'A' -RealPath 'C:\a' + foreach ($expected in 'ArchivePath', 'TopName', 'RealPath', 'Kind', 'Slot', 'Description', 'Origin', 'Exists', 'IsFile', 'Exclude') { + Assert-True ($item.PSObject.Properties.Name -contains $expected) "缺少属性 $expected" + } + foreach ($gone in 'Sources', 'Dirs', 'Variants', 'ResolvedPath', 'RootName') { + Assert-False ($item.PSObject.Properties.Name -contains $gone) "不该再有旧属性 $gone" + } +} + +Test-Case 'New-BaknretArchiveStaging:目录走 junction、文件走硬链接,按归档内名字挂载' { + $root = Join-Path $itemSandbox 'stage-src' + New-Item -ItemType Directory -Path (Join-Path $root 'App') -Force | Out-Null + Set-Content -LiteralPath (Join-Path $root 'App\f.txt') -Value 'x' + Set-Content -LiteralPath (Join-Path $root 'settings.json') -Value '{}' + + $items = @( + (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) + ) + + $staging = New-BaknretArchiveStaging -Items $items -Root (Join-Path $itemSandbox 'stage') + try { + $dirLink = Get-Item -LiteralPath (Join-Path $staging 'AlphaData') -Force + $fileLink = Get-Item -LiteralPath (Join-Path $staging 'BetaFile') -Force + Assert-Equal 'Junction' $dirLink.LinkType + Assert-Equal 'HardLink' $fileLink.LinkType + Assert-True (Test-Path -LiteralPath (Join-Path $staging 'AlphaData\f.txt')) '应能穿过 junction 看到内容' + } finally { + Remove-BaknretArchiveStaging -Root $staging + } + + Assert-False (Test-Path -LiteralPath $staging) '暂存目录应被清理' + Assert-True (Test-Path -LiteralPath (Join-Path $root 'App\f.txt')) '清理暂存绝不能删到真实数据' +} + +Test-Case 'New-BaknretJunction / Remove-BaknretJunction:只删连接点,不动目标' { + $target = Join-Path $itemSandbox 'j-target' + $link = Join-Path $itemSandbox 'j-link' + New-Item -ItemType Directory -Path $target -Force | Out-Null + Set-Content -LiteralPath (Join-Path $target 'keep.txt') -Value 'keep' + + New-BaknretJunction -Path $link -Target $target | Out-Null + Assert-Equal 'Junction' (Get-Item -LiteralPath $link -Force).LinkType + Remove-BaknretJunction -Path $link + Assert-False (Test-Path -LiteralPath $link) + Assert-True (Test-Path -LiteralPath (Join-Path $target 'keep.txt')) '目标内容必须留着' + + try { + New-BaknretJunction -Path $link -Target $target | Out-Null + Remove-BaknretJunction -Path $link + New-BaknretJunction -Path $link -Target $target | Out-Null + Assert-True $true + } finally { + Remove-BaknretJunction -Path $link + } +} + +Test-Case 'New-BaknretJunction:目标已存在时明确抛错' { + $target = Join-Path $itemSandbox 'j2-target' + $link = Join-Path $itemSandbox 'j2-link' + New-Item -ItemType Directory -Path $target -Force | Out-Null + New-Item -ItemType Directory -Path $link -Force | Out-Null + + $threw = $false + try { New-BaknretJunction -Path $link -Target $target | Out-Null } catch { $threw = $true } + Assert-True $threw '同名实体存在时必须抛错,不能静默替换' } # ============================================================================ Write-Host "`n== 7z 排除参数翻译 ==" -ForegroundColor Cyan # ============================================================================ -Test-Case '相对模式自动补上归档根目录名' { - $exclude = Get-ArchiveExcludeArgument -ItemName 'March7thAssistant' -Patterns @('logs\') - Assert-Equal '-x!March7thAssistant\logs' $exclude[0] +Test-Case '旧的 Get-ArchiveExcludeArgument 已经不存在(契约破坏点)' { + Assert-Null (Get-Command Get-ArchiveExcludeArgument -ErrorAction SilentlyContinue) } -Test-Case '已经带根目录名时不重复前缀' { - $exclude = Get-ArchiveExcludeArgument -ItemName 'March7thAssistant' -Patterns @('March7thAssistant\logs\') - Assert-Equal '-x!March7thAssistant\logs' $exclude[0] +Test-Case '相对模式自动补上归档项名(锚定在归档根)' { + $item = New-BaknretArchiveItem -ArchivePath 'March7thAssistant' -RealPath 'C:\x' + $exclude = Get-BaknretExcludeArgument -Item $item -Patterns @('logs\') + Assert-Equal 1 $exclude.Arguments.Count + Assert-Equal '-x!March7thAssistant\logs' $exclude.Arguments[0] + Assert-Null $exclude.Error } -Test-Case '! 前缀翻译成递归组件匹配' { - $exclude = Get-ArchiveExcludeArgument -ItemName 'User Data' -Patterns @('!*Cache') - Assert-Equal '-xr!*Cache' $exclude[0] +Test-Case '模式里再写一遍归档项名不会被当成前缀剥掉(新语义:总是拼在项名之后)' { + $item = New-BaknretArchiveItem -ArchivePath 'March7thAssistant' -RealPath 'C:\x' + $exclude = Get-BaknretExcludeArgument -Item $item -Patterns @('March7thAssistant\logs\') + Assert-Equal '-x!March7thAssistant\March7thAssistant\logs' $exclude.Arguments[0] +} + +Test-Case '! 前缀翻译成任意层级匹配' { + $item = New-BaknretArchiveItem -ArchivePath 'UserData' -RealPath 'C:\x' + $exclude = Get-BaknretExcludeArgument -Item $item -Patterns @('!*Cache') + Assert-Equal '-xr!*Cache' $exclude.Arguments[0] } Test-Case '模式里的空格转成 ? (7z 的模式不支持空格)' { - $exclude = Get-ArchiveExcludeArgument -ItemName 'User Data' -Patterns @('Default\Code Cache') - Assert-Equal '-x!User?Data\Default\Code?Cache' $exclude[0] + $item = New-BaknretArchiveItem -ArchivePath 'UserData' -RealPath 'C:\x' + $exclude = Get-BaknretExcludeArgument -Item $item -Patterns @('Default\Code Cache') + Assert-Equal '-x!UserData\Default\Code?Cache' $exclude.Arguments[0] } Test-Case '生成的参数里绝不出现引号(旧实现 -x!"路径" 让排除全部失效)' { - $patterns = @('Default\Code Cache', '!*Cache', 'logs\', 'March7thAssistant\3rdparty\WebBrowser\UserProfile\Integrated') - foreach ($pattern in $patterns) { - foreach ($entry in (Get-ArchiveExcludeArgument -ItemName 'User Data' -Patterns @($pattern))) { - Assert-False ($entry.Contains('"')) "参数里出现了引号: $entry" - } + $item = New-BaknretArchiveItem -ArchivePath 'UserData' -RealPath 'C:\x' + $patterns = @('Default\Code Cache', '!*Cache', 'logs\', 'UserData\3rdparty\WebBrowser\UserProfile\Integrated') + $exclude = Get-BaknretExcludeArgument -Item $item -Patterns $patterns + Assert-Equal 4 $exclude.Arguments.Count + foreach ($argument in $exclude.Arguments) { + Assert-False ($argument.Contains('"')) "参数里出现了引号: $argument" } } Test-Case '空模式被忽略' { - $exclude = Get-ArchiveExcludeArgument -ItemName 'x' -Patterns @('', ' ', 'real\') - Assert-Equal 1 $exclude.Count + $item = New-BaknretArchiveItem -ArchivePath 'x' -RealPath 'C:\x' + $exclude = Get-BaknretExcludeArgument -Item $item -Patterns @('', ' ', 'real\') + Assert-Equal 1 $exclude.Arguments.Count + Assert-Equal '-x!x\real' $exclude.Arguments[0] +} + +Test-Case '!re:<正则> 展开成源目录里命中的精确路径' { + $root = Join-Path $itemSandbox 'regex-src' + foreach ($dir in 'Cache1', 'Cache2', 'Keep', 'sub\Cache3') { + New-Item -ItemType Directory -Path (Join-Path $root $dir) -Force | Out-Null + } + $item = New-BaknretArchiveItem -ArchivePath 'Data' -RealPath $root + + $exclude = Get-BaknretExcludeArgument -Item $item -Patterns @('!re:^Cache\d+$') + Assert-Null $exclude.Error + $sorted = @($exclude.Arguments | Sort-Object) + Assert-Equal 3 $sorted.Count + Assert-Equal '-x!Data\Cache1' $sorted[0] + Assert-Equal '-x!Data\Cache2' $sorted[1] + Assert-Equal '-x!Data\sub\Cache3' $sorted[2] +} + +Test-Case '非法的 !re: 正则给出明确的 Error,而不是静默漏排除' { + $root = Join-Path $itemSandbox 'regex-src' + $item = New-BaknretArchiveItem -ArchivePath 'Data' -RealPath $root + $exclude = Get-BaknretExcludeArgument -Item $item -Patterns @('!re:*Cache') + Assert-True ($null -ne $exclude.Error) + Assert-Equal 0 $exclude.Arguments.Count +} + +Test-Case '空的 !re: 被忽略' { + $root = Join-Path $itemSandbox 'regex-src' + $item = New-BaknretArchiveItem -ArchivePath 'Data' -RealPath $root + $exclude = Get-BaknretExcludeArgument -Item $item -Patterns @('!re:') + Assert-Equal 0 $exclude.Arguments.Count + Assert-Null $exclude.Error +} + +Test-Case '排除参数超过命令行安全长度时明确报错' { + $root = Join-Path $itemSandbox 'regex-src' + $item = New-BaknretArchiveItem -ArchivePath 'Data' -RealPath $root + $exclude = Get-BaknretExcludeArgument -Item $item -Patterns @('aaaa\bbbb') -MaxCommandLineChars 5 + Assert-True ($null -ne $exclude.Error) +} + +Test-Case 'Split-BaknretPatternScope:`<项名>\` 前缀把模式分配给对应归档项' { + $items = @( + (New-BaknretArchiveItem -ArchivePath 'DefaultConfig' -RealPath 'C:\c'), + (New-BaknretArchiveItem -ArchivePath 'GlobalPersist' -RealPath 'C:\g'), + (New-BaknretArchiveItem -ArchivePath 'UserPersist' -RealPath 'C:\u') + ) + $map = Split-BaknretPatternScope -Items $items -Patterns @('GlobalPersist\steam\steamapps', 'plain\one', '!*Cache') + + Assert-Equal 'steam\steamapps' $map[1][0] + Assert-Equal 'plain\one' $map[0][0] '没点名任何项的模式对每一项各展开一份' + Assert-Equal 'plain\one' $map[2][0] + foreach ($index in 0, 1, 2) { + Assert-True ($map[$index] -contains '!*Cache') "! 开头的模式应广播到第 $index 项" + } +} + +Test-Case 'Merge-BaknretExcludeArgument:去重且保序' { + $merged = Merge-BaknretExcludeArgument -ArgumentLists @(@('-x!a', '-xr!c'), @('-x!a', '-x!b')) + Assert-Equal 3 $merged.Count + Assert-Equal '-x!a' $merged[0] + Assert-Equal '-xr!c' $merged[1] + Assert-Equal '-x!b' $merged[2] } # ============================================================================ @@ -272,7 +579,7 @@ Write-Host "`n== manifest 与配置 ==" -ForegroundColor Cyan $sandbox = Join-Path $env:TEMP ("baknret-tests-" + [guid]::NewGuid().ToString('N').Substring(0, 8)) -Test-Case 'manifest 读写往返' { +Test-Case 'manifest 读写往返(含 layouts)' { New-Item -ItemType Directory -Path $sandbox -Force | Out-Null $path = Join-Path $sandbox 'manifest.json' @@ -280,12 +587,23 @@ Test-Case 'manifest 读写往返' { Assert-Equal 0 $m.items.Count $m.compressor = [pscustomobject]@{ name = '7z'; version = '26.03' } - $m.items['FooClolor_from_C_+Programs'] = [ordered]@{ baseName = 'FooClolor_from_C_+Programs'; action = 'backed-up'; archiveBytes = 12345 } + $m.items['my-app'] = [ordered]@{ + baseName = 'my-app' + action = 'backed-up' + roots = @('AlphaData', 'BetaFile') + layouts = @( + [ordered]@{ name = 'AlphaData'; kind = 'dir' }, + [ordered]@{ name = 'BetaFile'; kind = 'file' } + ) + } Write-BaknretManifest -Path $path -Manifest $m | Out-Null $again = Read-BaknretManifest -Path $path Assert-Equal 1 $again.items.Count - Assert-Equal 'backed-up' $again.items['FooClolor_from_C_+Programs'].action + Assert-Equal 'backed-up' $again.items['my-app'].action + Assert-Equal 2 $again.items['my-app'].layouts.Count + Assert-Equal 'BetaFile' $again.items['my-app'].layouts[1].name + Assert-Equal 'file' $again.items['my-app'].layouts[1].kind $bytes = [System.IO.File]::ReadAllBytes($path) Assert-False (($bytes[0] -eq 0xEF) -and ($bytes[1] -eq 0xBB)) 'manifest 不应带 BOM' @@ -327,103 +645,354 @@ Test-Case '口令:环境变量可读取,取不到返回 $null' { } # ============================================================================ -Write-Host "`n== 软件名录 ==" -ForegroundColor Cyan +Write-Host "`n== 软件名录(新 Slot 结构) ==" -ForegroundColor Cyan # ============================================================================ $catalogSandbox = Join-Path $env:TEMP ("baknret-cat-" + [guid]::NewGuid().ToString('N').Substring(0, 8)) -New-Item -ItemType Directory -Path $catalogSandbox -Force | Out-Null +New-Item -ItemType Directory -Path (Join-Path $catalogSandbox 'App') -Force | Out-Null +New-Item -ItemType Directory -Path (Join-Path $catalogSandbox 'Config') -Force | Out-Null +$appDir = Join-Path $catalogSandbox 'App' +$configDir = Join-Path $catalogSandbox 'Config' Test-Case '名录解析:裸键、引号键、带 - 与 . 的名字' { $catPath = Join-Path $catalogSandbox 'SoftwareCatalog.psd1' - $content = "@{`n FooClolor = 'C:\Programs\FooClolor'`n 'scoop-config' = '%UserProfile%\.config\scoop'`n '.ssh' = '%UserProfile%\.ssh'`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($false)) - $catalog = Get-SoftwareCatalog -Path $catPath + $catalog = Get-SoftwareCatalog -Path $catPath -NoCache Assert-Equal 3 $catalog.Count Assert-True $catalog.ContainsKey('FooClolor') Assert-True $catalog.ContainsKey('scoop-config') '带连字符的键必须能解析' Assert-True $catalog.ContainsKey('.ssh') '带点的键必须能解析' + Assert-Equal 'Single' $catalog['FooClolor'].Kind +} + +Test-Case '名录:Slot 按名字排序(哈希表顺序不可依赖)' { + $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($false)) + + $catalog = Get-SoftwareCatalog -Path $catPath -NoCache + Assert-Equal 'Multi' $catalog['FooClolor'].Kind + Assert-Equal 2 $catalog['FooClolor'].Slots.Count + Assert-Equal 'Alpha' $catalog['FooClolor'].Slots[0].Name + Assert-Equal 'Zeta' $catalog['FooClolor'].Slots[1].Name +} + +Test-Case '名录:Slot 对象字段与契约一致' { + $catPath = Join-Path $catalogSandbox 'fields.psd1' + $filePath = Join-Path $catalogSandbox 'App\settings.json' + Set-Content -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($false)) + + $catalog = Get-SoftwareCatalog -Path $catPath -NoCache + $entry = $catalog['Rich'] + foreach ($expected in 'Name', 'Path', 'Description', 'Slots', 'Kind', 'Error', 'Missing', 'Raw') { + Assert-True ($entry.PSObject.Properties.Name -contains $expected) "名录条目缺少 $expected" + } + foreach ($gone in 'Dirs', 'Variants', 'ResolvedPath', 'RootName') { + Assert-False ($entry.PSObject.Properties.Name -contains $gone) "名录条目不该再有旧属性 $gone" + } + + $beta = $entry.Slots[1] + foreach ($expected in 'Name', 'Declared', 'Resolved', 'Exists', 'IsFile', 'Suffixed', 'Description', 'Exclude', 'Include', 'Encrypt') { + Assert-True ($beta.PSObject.Properties.Name -contains $expected) "Slot 缺少 $expected" + } + Assert-Equal 'Beta' $beta.Name + Assert-Equal $filePath $beta.Resolved + Assert-True $beta.IsFile 'Path 指向文件时 IsFile 必须为真' + Assert-True $beta.Encrypt + Assert-Equal 'beta 说明' $beta.Description + + $alpha = $entry.Slots[0] + Assert-False $alpha.IsFile + Assert-Equal 2 $alpha.Exclude.Count + Assert-Equal '!*Cache' $alpha.Exclude[0] + Assert-Equal 1 $alpha.Include.Count + Assert-Equal "Modules:$appDir" $alpha.Include[0] } Test-Case '名录解析:目录带版本后缀时按前缀补全(legendary_2.0.4)' { $root = Join-Path $catalogSandbox 'probe' New-Item -ItemType Directory -Path (Join-Path $root 'legendary_2.0.4') -Force | Out-Null $catPath = Join-Path $catalogSandbox 'suffix.psd1' - [System.IO.File]::WriteAllText($catPath, "@{`n legendary = '$root\legendary'`n}`n", [System.Text.UTF8Encoding]::new($false)) + [System.IO.File]::WriteAllText($catPath, "@{`n legendary = @{ DefaultConfig = @{ Path = '$root\legendary' } }`n}`n", [System.Text.UTF8Encoding]::new($false)) - $catalog = Get-SoftwareCatalog -Path $catPath + $catalog = Get-SoftwareCatalog -Path $catPath -NoCache Assert-Equal 'Single' $catalog['legendary'].Kind - Assert-True ($catalog['legendary'].ResolvedPath -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' } Test-Case '名录解析:不会把 Legendary 误配成 LegendarySomething' { $root = Join-Path $catalogSandbox 'strict' New-Item -ItemType Directory -Path (Join-Path $root 'LegendarySomething') -Force | Out-Null $catPath = Join-Path $catalogSandbox 'strict.psd1' - [System.IO.File]::WriteAllText($catPath, "@{`n Legendary = '$root\Legendary'`n}`n", [System.Text.UTF8Encoding]::new($false)) + [System.IO.File]::WriteAllText($catPath, "@{`n Legendary = @{ DefaultConfig = @{ Path = '$root\Legendary' } }`n}`n", [System.Text.UTF8Encoding]::new($false)) - $catalog = Get-SoftwareCatalog -Path $catPath + $catalog = Get-SoftwareCatalog -Path $catPath -NoCache Assert-Equal 'Unresolved' $catalog['Legendary'].Kind '必须以下一个字符是 _ 或 - 为界' + Assert-False $catalog['Legendary'].Slots[0].Exists + Assert-Equal 1 $catalog['Legendary'].Missing.Count } -Test-Case '软件名条目:默认用软件名做归档名' { - $item = ConvertFrom-BackupListLine -Line 'FooClolor' - $resolved = Resolve-BackupEntry -Entry $item -CatalogPath (Join-Path $catalogSandbox 'SoftwareCatalog.psd1') - Assert-Equal 'FooClolor' $resolved.BaseName - Assert-Equal 'name' $resolved.ArchiveFlavor - Assert-True $resolved.IsName +Test-Case 'Find-ChildDirectoryByName:_ / - 边界之外不补全' { + $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-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 'Unrelated') -Force | Out-Null + + $found = @(Find-ChildDirectoryByName -Parent $root -Name 'Legendary') + Assert-Equal 2 $found.Count + Assert-True (@($found | Where-Object { $_ -like '*Legendary_1.0' }).Count -eq 1) + Assert-True (@($found | Where-Object { $_ -like '*Legendary-2.0' }).Count -eq 1) } -Test-Case '字面路径条目:仍用路径命名算法(现有清单无需改写)' { - $item = ConvertFrom-BackupListLine -Line 'C:\Programs\FooClolor' - $resolved = Resolve-BackupEntry -Entry $item -CatalogPath (Join-Path $catalogSandbox 'SoftwareCatalog.psd1') - Assert-False $resolved.IsName - Assert-Equal 'path' $resolved.ArchiveFlavor - Assert-Equal 'FooClolor_from_C_+Programs' $resolved.BaseName +Test-Case '名录:一个 Slot 补全出多个目录时明确报错(否则会混成一棵树)' { + $root = Join-Path $catalogSandbox 'multi-cand' + New-Item -ItemType Directory -Path (Join-Path $root 'Legendary_1') -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $root 'Legendary_2') -Force | Out-Null + $catPath = Join-Path $catalogSandbox 'multicand.psd1' + [System.IO.File]::WriteAllText($catPath, "@{`n Legendary = @{ DefaultConfig = @{ Path = '$root\Legendary' } }`n}`n", [System.Text.UTF8Encoding]::new($false)) + + $catalog = Get-SoftwareCatalog -Path $catPath -NoCache + $entry = $catalog['Legendary'] + Assert-True ($null -ne $entry.Error) + Assert-True ($entry.Error -like '*拆成多个 Slot*') '错误里要给出可操作的指引' } -Test-Case '@pathname 用真实路径命名,而不是软件名' { - $item = ConvertFrom-BackupListLine -Line 'FooClolor @pathname' - $resolved = Resolve-BackupEntry -Entry $item -CatalogPath (Join-Path $catalogSandbox 'SoftwareCatalog.psd1') - Assert-Equal 'path' $resolved.ArchiveFlavor - Assert-Equal 'FooClolor_from_C_+Programs' $resolved.BaseName +Test-Case '名录:Slot 写法不对 / 缺少 Path 都记成 Error,而不是崩掉' { + $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($false)) + $catalog = Get-SoftwareCatalog -Path $bad -NoCache + Assert-Equal 'Invalid' $catalog['A'].Kind + Assert-True ($catalog['A'].Error -like '*Slot S*') + Assert-Equal 'Invalid' $catalog['B'].Kind + Assert-True ($catalog['B'].Error -like '*缺少 Path*') } -Test-Case '名录里没有该名字:BaseName 退回可读目录名,并给出 Error' { - $item = ConvertFrom-BackupListLine -Line 'no-such-thing' - $resolved = Resolve-BackupEntry -Entry $item -CatalogPath (Join-Path $catalogSandbox 'SoftwareCatalog.psd1') - Assert-Equal 'no-such-thing' $resolved.BaseName - Assert-True ($null -ne $resolved.Error) '应给出错误说明' - Assert-Equal 0 $resolved.Sources.Count +Test-Case '名录:裸字符串 / 数组等旧结构会被 ERROR 跳过(不再当目录用)' { + $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($false)) + $catalog = Get-SoftwareCatalog -Path $legacy -NoCache + Assert-Equal 0 $catalog.Count '旧结构必须被跳过,不能猜成目录' + + $dirs = Join-Path $catalogSandbox 'dirs.psd1' + [System.IO.File]::WriteAllText($dirs, "@{`n C = @{ Dirs = @('C:\1','C:\2') }`n}`n", [System.Text.UTF8Encoding]::new($false)) + $catalog = Get-SoftwareCatalog -Path $dirs -NoCache + Assert-Equal 1 $catalog.Count + Assert-Equal 'Invalid' $catalog['C'].Kind + Assert-True ($catalog['C'].Error -like '*Dirs*') } -Test-Case '名录里的路径不存在时仍给出 Sources(恢复要靠它还原回原位)' { - $catPath = Join-Path $catalogSandbox 'gone.psd1' - [System.IO.File]::WriteAllText($catPath, "@{`n 'gone-app' = 'C:\definitely\not\here'`n}`n", [System.Text.UTF8Encoding]::new($false)) - $item = ConvertFrom-BackupListLine -Line 'gone-app' - $resolved = Resolve-BackupEntry -Entry $item -CatalogPath $catPath - Assert-True ($null -ne $resolved.Error) '备份端据 Error 跳过' - Assert-Equal 1 $resolved.Sources.Count '恢复端据 Sources 还原' - Assert-Equal 'C:\definitely\not\here' $resolved.Sources[0].SourcePath +Test-Case '名录:%VAR% 与 $( ... ) 子表达式都会被展开' { + Assert-Equal (Join-Path $env:TEMP 'x') (Expand-CatalogPathText -Text '%TEMP%\x') + Assert-Equal (Join-Path $env:TEMP 'sub') (Expand-CatalogPathText -Text '$(Join-Path $env:TEMP "sub")') + Assert-Equal '$(Join-Path' (Expand-CatalogPathText -Text '$(Join-Path') '括号不配对时原样保留,不抛异常' } -Test-Case 'Includes:分文件维护的名录会被合并' { - $basePath = Join-Path $catalogSandbox 'Base.psd1' +Test-Case '名录:Includes 分文件维护会被合并' { $extraPath = Join-Path $catalogSandbox 'Extra.psd1' - [System.IO.File]::WriteAllText($extraPath, "@{`n 'extra-app' = 'C:\Programs\Extra'`n}`n", [System.Text.UTF8Encoding]::new($false)) - [System.IO.File]::WriteAllText($basePath, "@{`n Includes = @('Extra.psd1')`n 'base-app' = 'C:\Programs\Base'`n}`n", [System.Text.UTF8Encoding]::new($false)) + $basePath = Join-Path $catalogSandbox 'Base.psd1' + [System.IO.File]::WriteAllText($extraPath, "@{`n 'extra-app' = @{ D = @{ Path = '$appDir' } }`n}`n", [System.Text.UTF8Encoding]::new($false)) + [System.IO.File]::WriteAllText($basePath, "@{`n Includes = @('Extra.psd1')`n 'base-app' = @{ D = @{ Path = '$configDir' } }`n}`n", [System.Text.UTF8Encoding]::new($false)) - $catalog = Get-SoftwareCatalog -Path $basePath + $catalog = Get-SoftwareCatalog -Path $basePath -NoCache Assert-True $catalog.ContainsKey('base-app') Assert-True $catalog.ContainsKey('extra-app') 'Includes 引入的条目也应在' } -Test-Case '软件名会被规范化成合法文件名' { - Assert-Equal 'keep%and&and+plus' (Format-CatalogName -Name 'keep%and&and+plus') - Assert-True ((Format-CatalogName -Name 'a/b:c') -notmatch '[/:]') '非法字符应被替换' +Test-Case '名录:结果按文件指纹缓存,-NoCache 强制重读' { + $catPath = Join-Path $catalogSandbox 'cache.psd1' + [System.IO.File]::WriteAllText($catPath, "@{`n Cached = @{ D = @{ Path = '$appDir' } }`n}`n", [System.Text.UTF8Encoding]::new($false)) + + $first = Get-SoftwareCatalog -Path $catPath + $second = Get-SoftwareCatalog -Path $catPath + Assert-True ([object]::ReferenceEquals($first, $second)) '同一文件同一指纹应命中缓存' + + $fresh = Get-SoftwareCatalog -Path $catPath -NoCache + Assert-False ([object]::ReferenceEquals($first, $fresh)) '-NoCache 必须重新读' + + [System.IO.File]::WriteAllText($catPath, "@{`n Cached = @{ D = @{ Path = '$configDir' } }`n}`n", [System.Text.UTF8Encoding]::new($false)) + $changed = Get-SoftwareCatalog -Path $catPath + Assert-Equal $configDir $changed['Cached'].Slots[0].Resolved '文件变了缓存必须失效' } -Remove-Item -LiteralPath $catalogSandbox -Recurse -Force -ErrorAction SilentlyContinue +# ============================================================================ +Write-Host "`n== Resolve-BackupEntry(归档项解析) ==" -ForegroundColor Cyan +# ============================================================================ + +$resolveSandbox = Join-Path $env:TEMP ("baknret-resolve-" + [guid]::NewGuid().ToString('N').Substring(0, 8)) +$dirA = Join-Path $resolveSandbox 'A' +$dirB = Join-Path $resolveSandbox 'B' +$dirExtra = Join-Path $resolveSandbox 'Extra' +foreach ($d in $dirA, $dirB, $dirExtra) { New-Item -ItemType Directory -Path $d -Force | Out-Null } +$resolveCatalog = Join-Path $resolveSandbox 'catalog.psd1' +[System.IO.File]::WriteAllText($resolveCatalog, @" +@{ + FooClolor = @{ DefaultData = @{ Path = '$dirA'; Encrypt = `$true } } + MultiApp = @{ Alpha = @{ Path = '$dirA' }; Beta = @{ Path = '$dirB' } } +} +"@, [System.Text.UTF8Encoding]::new($false)) + +Test-Case '软件名条目:一个 Slot -> 一个归档项,路径是 Slot 名,归档名是软件名' { + $item = ConvertFrom-BackupListLine -Line 'FooClolor' + $resolved = Resolve-BackupEntry -Entry $item -CatalogPath $resolveCatalog + + Assert-True $resolved.IsName + Assert-Equal 'name' $resolved.ArchiveFlavor + Assert-Equal 'FooClolor' $resolved.BaseName + Assert-Equal 'both' $resolved.Direction + Assert-Equal 1 $resolved.Items.Count + Assert-Equal 'DefaultData' $resolved.Items[0].ArchivePath + Assert-Equal 'DefaultData' $resolved.Items[0].TopName + Assert-Equal $dirA $resolved.Items[0].RealPath + Assert-Equal 'slot' $resolved.Items[0].Kind + Assert-Equal 'DefaultData' $resolved.Items[0].Slot + Assert-Equal 'catalog' $resolved.Items[0].Origin + Assert-True $resolved.Items[0].Exists +} + +Test-Case 'Resolve-BackupEntry 的返回值里没有旧的 Sources / RootName 等属性' { + $item = ConvertFrom-BackupListLine -Line 'FooClolor' + $resolved = Resolve-BackupEntry -Entry $item -CatalogPath $resolveCatalog + 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" + } + foreach ($gone in 'Sources', 'RootName', 'Variants', 'ResolvedPath') { + Assert-False ($resolved.PSObject.Properties.Name -contains $gone) "不该再有旧属性 $gone" + } +} + +Test-Case '字面路径条目:仍用路径命名算法,归档内是历史布局 <末级名>' { + $item = ConvertFrom-BackupListLine -Line 'C:\Programs\FooClolor' + $resolved = Resolve-BackupEntry -Entry $item -CatalogPath $resolveCatalog + Assert-False $resolved.IsName + Assert-Equal 'path' $resolved.ArchiveFlavor + Assert-Equal 'FooClolor_from_C_+Programs' $resolved.BaseName + Assert-Equal 1 $resolved.Items.Count + Assert-Equal 'FooClolor' $resolved.Items[0].ArchivePath + Assert-Equal 'path' $resolved.Items[0].Kind +} + +Test-Case '@pathname 用名录里的真实路径跑命名算法' { + $item = ConvertFrom-BackupListLine -Line 'FooClolor @pathname' + $resolved = Resolve-BackupEntry -Entry $item -CatalogPath $resolveCatalog + Assert-Equal (Get-BackupBaseName -RawPath $dirA) $resolved.BaseName + Assert-Equal 'DefaultData' $resolved.Items[0].ArchivePath '归档内名字仍由 Slot 决定' +} + +Test-Case ':: / @ Path= 覆盖单 Slot 条目的真实路径' { + $item = ConvertFrom-BackupListLine -Line "FooClolor :: $dirB" + $resolved = Resolve-BackupEntry -Entry $item -CatalogPath $resolveCatalog + Assert-Equal 1 $resolved.Items.Count + Assert-Equal $dirB $resolved.Items[0].RealPath + Assert-Equal 'DefaultData' $resolved.Items[0].ArchivePath + Assert-Null $resolved.Blocking + + $atItem = ConvertFrom-BackupListLine -Line "FooClolor @ Path='$dirB'" + $atResolved = Resolve-BackupEntry -Entry $atItem -CatalogPath $resolveCatalog + Assert-Equal $dirB $atResolved.Items[0].RealPath +} + +Test-Case ':: 覆盖路径时,多 Slot 的条目会 Blocking(说不清是哪个 Slot)' { + $item = ConvertFrom-BackupListLine -Line "MultiApp :: $dirB" + $resolved = Resolve-BackupEntry -Entry $item -CatalogPath $resolveCatalog + Assert-True ($null -ne $resolved.Blocking) + Assert-True ($resolved.Blocking -like '*2 个 Slot*') +} + +Test-Case '名录里没有该名字:BaseName 退回可读目录名,并给出 Error' { + $item = ConvertFrom-BackupListLine -Line 'no-such-thing' + $resolved = Resolve-BackupEntry -Entry $item -CatalogPath $resolveCatalog + Assert-Equal 'no-such-thing' $resolved.BaseName + Assert-True ($null -ne $resolved.Error) '应给出错误说明' + Assert-Equal 0 $resolved.Items.Count +} + +Test-Case '名录里的路径不存在时仍给出归档项(恢复要靠它还原回原位)' { + $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($false)) + $item = ConvertFrom-BackupListLine -Line 'gone-app' + $resolved = Resolve-BackupEntry -Entry $item -CatalogPath $catPath + Assert-Equal 1 $resolved.Items.Count '恢复端据 Items 还原' + Assert-Equal 'C:\definitely\not\here' $resolved.Items[0].RealPath + Assert-False $resolved.Items[0].Exists + Assert-Equal 'Unresolved' $resolved.CatalogEntry.Kind +} + +Test-Case ':+ 追加项:<归档内相对路径>:<宿主机绝对路径>' { + $item = ConvertFrom-BackupListLine -Line "FooClolor :+ Modules:$dirExtra" + $resolved = Resolve-BackupEntry -Entry $item -CatalogPath $resolveCatalog + Assert-Equal 2 $resolved.Items.Count + Assert-Equal 'DefaultData' $resolved.Items[0].ArchivePath + Assert-Equal 'Modules' $resolved.Items[1].ArchivePath + Assert-Equal $dirExtra $resolved.Items[1].RealPath + Assert-Equal 'include' $resolved.Items[1].Kind + Assert-Equal 'include' $resolved.Items[1].Origin + Assert-True $resolved.HasIncludeOverride +} + +Test-Case '条目级 :+ 覆盖名录里的 Include(不是叠加)' { + $incDir = Join-Path $resolveSandbox 'Inc' + New-Item -ItemType Directory -Path $incDir -Force | Out-Null + $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($false)) + + $plain = Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line 'IncApp') -CatalogPath $catPath + Assert-Equal 2 $plain.Items.Count + Assert-Equal 'CatalogInc' $plain.Items[1].ArchivePath + + $overridden = Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line "IncApp :+ EntryInc:$dirExtra") -CatalogPath $catPath + Assert-Equal 2 $overridden.Items.Count + Assert-Equal 'EntryInc' $overridden.Items[1].ArchivePath + Assert-Equal $dirExtra $overridden.Items[1].RealPath +} + +Test-Case '归档内路径冲突:同名追加项 -> Blocking' { + $item = ConvertFrom-BackupListLine -Line "FooClolor :+ DefaultData:$dirExtra" + $resolved = Resolve-BackupEntry -Entry $item -CatalogPath $resolveCatalog + Assert-True ($null -ne $resolved.Blocking) + Assert-True ($resolved.Blocking -like '*冲突*') +} + +Test-Case '归档内路径冲突:父子关系 -> Blocking' { + $item = ConvertFrom-BackupListLine -Line "FooClolor :+ DefaultData\sub:$dirExtra" + $resolved = Resolve-BackupEntry -Entry $item -CatalogPath $resolveCatalog + Assert-True ($null -ne $resolved.Blocking) + Assert-True ($resolved.Blocking -like '*父子*') +} + +Test-Case '加密:名录 Slot 的 Encrypt 之和,条目级覆盖优先' { + $catalogEncrypt = Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line 'FooClolor') -CatalogPath $resolveCatalog + Assert-True $catalogEncrypt.Encrypt + + $off = Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line 'FooClolor :!encrypt') -CatalogPath $resolveCatalog + Assert-False $off.Encrypt + + $on = Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line 'MultiApp :encrypt') -CatalogPath $resolveCatalog + Assert-True $on.Encrypt '名录没开、条目开了 -> 开' + Assert-False $on.HasExcludeOverride +} + +Test-Case '方向标记随条目一路传到 Resolve-BackupEntry' { + Assert-Equal 'backup' (Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line '+ FooClolor') -CatalogPath $resolveCatalog).Direction + Assert-Equal 'restore' (Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line '- FooClolor') -CatalogPath $resolveCatalog).Direction +} + +Test-Case '条目级 :- 覆盖名录里的 Exclude(不是叠加)' { + $catPath = Join-Path $resolveSandbox 'withexc.psd1' + [System.IO.File]::WriteAllText($catPath, "@{`n ExcApp = @{ DefaultData = @{ Path = '$dirA'; Exclude = '!*CatalogCache' } }`n}`n", [System.Text.UTF8Encoding]::new($false)) + + $plain = Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line 'ExcApp') -CatalogPath $catPath + Assert-False $plain.HasExcludeOverride + Assert-Equal 0 $plain.ExcludePatterns.Count + Assert-Equal '!*CatalogCache' $plain.Items[0].Exclude[0] + + $overridden = Resolve-BackupEntry -Entry (ConvertFrom-BackupListLine -Line 'ExcApp :- !*Listed') -CatalogPath $catPath + Assert-True $overridden.HasExcludeOverride + Assert-Equal '!*Listed' $overridden.ExcludePatterns[0] +} # ============================================================================ Write-Host "`n== 集成测试:真的跑 7z,验证排除结果落在文件系统上 ==" -ForegroundColor Cyan @@ -451,7 +1020,9 @@ if (-not $sevenZip) { Set-Content -LiteralPath (Join-Path $source 'Default\IndexedDB\i.bin') 'i' $patterns = @('!*Cache', 'component_crx_cache', 'Default\Code Cache', 'Default\Extensions', 'Default\IndexedDB') - $excludeArgs = Get-ArchiveExcludeArgument -ItemName $itemName -Patterns $patterns + $archiveItem = New-BaknretArchiveItem -ArchivePath $itemName -RealPath $source -Kind 'path' -Exists $true + $excludeArgs = @((Get-BaknretExcludeArgument -Item $archiveItem -Patterns $patterns).Arguments) + Assert-True ($excludeArgs.Count -ge 5) '排除参数应被翻译出来' $archive = Join-Path $sandbox 'excl.7z' $argument = @('a', '-t7z', '-mx=1', '-bso0', '-bsp0') + $excludeArgs + @($archive, $itemName) @@ -471,6 +1042,35 @@ if (-not $sevenZip) { Assert-False (Test-Path -LiteralPath (Join-Path $verify "$itemName\Default\IndexedDB\i.bin")) 'IndexedDB 应被排除' } + Test-Case '!re:<正则> 展开出的排除参数在真实归档上生效' { + $root = Join-Path $sandbox 'src-re' + $itemName = 'Data' + $source = Join-Path $root $itemName + foreach ($d in 'Cache1', 'Cache2', 'Keep', 'sub\Cache3') { + New-Item -ItemType Directory -Path (Join-Path $source $d) -Force | Out-Null + Set-Content -LiteralPath (Join-Path $source "$d\x.bin") 'x' + } + + $archiveItem = New-BaknretArchiveItem -ArchivePath $itemName -RealPath $source -Kind 'slot' -Exists $true + $excludeArgs = @((Get-BaknretExcludeArgument -Item $archiveItem -Patterns @('!re:^Cache\d+$')).Arguments) + Assert-Equal 3 $excludeArgs.Count '两个顶层 Cache 目录 + 嵌套的 sub\Cache3' + + $archive = Join-Path $sandbox 're.7z' + $argument = @('a', '-t7z', '-mx=1', '-bso0', '-bsp0') + $excludeArgs + @($archive, $itemName) + $code = Invoke-ExternalCommand -FilePath $sevenZip -ArgumentList $argument -WorkingDirectory $root + Assert-Equal 0 $code '7z 打包退出码' + + $verify = Join-Path $sandbox 'verify-re' + New-Item -ItemType Directory -Path $verify -Force | Out-Null + $code = Invoke-ExternalCommand -FilePath $sevenZip -ArgumentList @('x', '-bso0', '-bsp0', '-y', "-o$verify", $archive) + Assert-Equal 0 $code '7z 解压退出码' + + Assert-True (Test-Path -LiteralPath (Join-Path $verify "$itemName\Keep\x.bin")) '不命中正则的目录要留着' + Assert-False (Test-Path -LiteralPath (Join-Path $verify "$itemName\Cache1\x.bin")) 'Cache1 应被正则排除' + Assert-False (Test-Path -LiteralPath (Join-Path $verify "$itemName\Cache2\x.bin")) 'Cache2 应被正则排除' + Assert-False (Test-Path -LiteralPath (Join-Path $verify "$itemName\sub\Cache3\x.bin")) '嵌套的 Cache3 也应被排除' + } + Test-Case '对照组:不加排除时被排除的文件确实在归档里(证明上一条不是空归档)' { $root = Join-Path $sandbox 'src' $itemName = 'User Data' @@ -486,6 +1086,23 @@ if (-not $sevenZip) { Assert-True (Test-Path -LiteralPath (Join-Path $verify "$itemName\Default\Cache\c.bin")) '对照组应当包含被排除的那个文件' } + Test-Case 'Get-ArchiveTopLevelNames 能列出归档内的顶层条目名' { + $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 'Beta') -Force | Out-Null + Set-Content -LiteralPath (Join-Path $root 'Alpha\a.txt') 'a' + Set-Content -LiteralPath (Join-Path $root 'Beta\b.txt') 'b' + $archive = Join-Path $sandbox 'topnames.7z' + $code = Invoke-ExternalCommand -FilePath $sevenZip -ArgumentList @('a', '-t7z', '-mx=1', '-bso0', '-bsp0', $archive, 'Alpha', 'Beta') -WorkingDirectory $root + Assert-Equal 0 $code + + $names = @(Get-ArchiveTopLevelNames -ArchivePath $archive -SevenZip $sevenZip) + $sorted = @($names | Sort-Object) + Assert-Equal 2 $sorted.Count + Assert-Equal 'Alpha' $sorted[0] + Assert-Equal 'Beta' $sorted[1] + } + Test-Case '7z t 对完好归档返回 0,对损坏归档返回非 0' { $good = Join-Path $sandbox 'full.7z' $code = Invoke-ExternalCommand -FilePath $sevenZip -ArgumentList @('t', '-bso0', '-bsp0', $good) @@ -512,6 +1129,9 @@ Test-Case 'Invoke-ExternalCommand 能拿到真实退出码(旧实现用 Start- # ============================================================================ Remove-Item -LiteralPath $sandbox -Recurse -Force -ErrorAction SilentlyContinue +Remove-Item -LiteralPath $itemSandbox -Recurse -Force -ErrorAction SilentlyContinue +Remove-Item -LiteralPath $catalogSandbox -Recurse -Force -ErrorAction SilentlyContinue +Remove-Item -LiteralPath $resolveSandbox -Recurse -Force -ErrorAction SilentlyContinue $failed = Write-TestSummary -Title '单元测试' if ($failed -gt 0) { exit 1 } diff --git a/tools/Rename-Archives.ps1 b/tools/Rename-Archives.ps1 index 3be67ba..942ed13 100644 --- a/tools/Rename-Archives.ps1 +++ b/tools/Rename-Archives.ps1 @@ -1,14 +1,20 @@ <# .SYNOPSIS - 把按路径命名的旧归档重命名成软件名,并重建 manifest.json。 + 把归档名对齐到当前清单规则,并重建 manifest.json。 .DESCRIPTION - 重构前的归档名是 `<末级名>_from_<上级路径>`(如 FooClolor_from_C_+Programs.7z)。 - 引入软件名录后,归档名默认就是软件名(FooClolor.7z)。这个脚本负责把存量归档搬过去。 + 归档名由清单条目决定: + + * 软件名条目 -> 归档名 = 软件名(`Edge.7z`); + * 手写路径条目 -> 归档名 = `<末级名>_from_<上级路径>`(`FooClolor_from_C_+Programs.7z`)。 + + 条目写法变过(把软件名改成手写路径、改名、合并条目……)之后,磁盘上的旧归档名就与当前 + 规则对不上了 —— 那样的归档恢复不到,会被当成孤儿。这个脚本负责把它们搬过去。 做法: - 1. 遍历清单条目,算出"旧名"(路径命名算法)与"新名"(当前规则); - 2. 只在两者不同、且旧名归档确实存在时才处理; + 1. 遍历清单条目,算出**当前规则下的目标名**,以及一组**候选旧名** + (路径命名算法 / 名录里的软件名 / manifest 里记过的归档名); + 2. 目标名已经存在就跳过;否则在候选旧名里找实际存在的归档; 3. 重命名(不是复制,同卷上是元数据操作,不搬数据); 4. 重建 manifest.json,把旧记录的历史字段(成功次数、SHA256 等)迁过去; 5. 比对重命名前后的文件大小做完整性自检。 @@ -74,6 +80,29 @@ function Find-ArchiveByBaseName { $manifestOld = Read-BaknretManifest -Path $manifestPath +# manifest 里的历史归档名按 source / resolvedSource 建索引: +# 条目写法改过(软件名 -> 手写路径、改名、合并)之后,键对不上了, +# 但"这条清单行原本指向哪儿"通常还留在这两个字段里,靠它才能把旧归档接上。 +$manifestBySource = @{} +foreach ($key in @($manifestOld.items.Keys)) { + $record = $manifestOld.items[$key] + if (-not $record) { continue } + + $archiveName = $key + if (($record.PSObject.Properties.Name -contains 'archive') -and $record.archive) { + $archiveName = [System.IO.Path]::GetFileNameWithoutExtension([string]$record.archive) + } + + foreach ($field in 'source', 'resolvedSource', 'catalog') { + if (-not ($record.PSObject.Properties.Name -contains $field)) { continue } + $value = [string]$record.$field + if ([string]::IsNullOrWhiteSpace($value)) { continue } + $mapKey = $value.Trim().ToLower() + if (-not $manifestBySource.ContainsKey($mapKey)) { $manifestBySource[$mapKey] = @() } + $manifestBySource[$mapKey] += $archiveName + } +} + $plan = @() $unchanged = 0 $missingOld = 0 @@ -85,23 +114,9 @@ foreach ($line in (Get-Content -LiteralPath $BackupListPath)) { $item = ConvertFrom-BackupListLine -Line $line if (-not $item) { continue } - # 旧名 = 对"真实源路径"跑路径命名算法。 - # 注意:清单里现在写的是软件名,直接把它丢给 Get-BackupBaseName 会得到一个 - # 恰好和软件名一模一样的"旧名"(legendary -> legendary),于是永远算不出 - # 真正的旧名。必须先解析出真实路径。 $resolved = Resolve-BackupEntry -Entry $item -CatalogPath $catalogPath -MaxDepth $config.CatalogMaxDepth $newName = $resolved.BaseName - - $oldNameSource = $item.Path - if ($resolved.IsName -and $resolved.CatalogEntry) { $oldNameSource = $resolved.CatalogEntry.Path } - if ([string]::IsNullOrWhiteSpace([string]$oldNameSource)) { - # 数组形式的名录条目没有唯一的"原路径",推不出旧归档名,跳过即可 - Write-Host (" 跳过 {0}:名录条目是数组形式,算不出旧归档名" -f $item.Path) -ForegroundColor DarkGray - continue - } - $oldName = Get-BackupBaseName -RawPath $oldNameSource - - if (-not $oldName -or -not $newName) { continue } + if (-not $newName) { continue } if ($seenNew.ContainsKey($newName)) { $conflicts += "归档名 '$newName' 被 '$($seenNew[$newName])' 和 '$($item.Path)' 同时使用" @@ -109,11 +124,57 @@ foreach ($line in (Get-Content -LiteralPath $BackupListPath)) { } $seenNew[$newName] = $item.Path - $entries += [pscustomobject]@{ Item = $item; OldName = $oldName; NewName = $newName; Resolved = $resolved } + # 候选旧名(按可能性排序): + # 1. 路径命名算法(对名录条目要用 Slot 的 Path,直接拿软件名算出来的是错的); + # 2. 名录里的软件名(旧规则:归档名 = 软件名); + # 3. manifest 里为这条记录记过的归档名。 + $candidates = @() - if ($oldName -eq $newName) { $unchanged++; continue } + $pathSource = $item.Path + if ($resolved.IsName) { + $slots = @($resolved.CatalogEntry.Slots) + $pathSource = if ($slots.Count -eq 1) { $slots[0].Declared } else { $null } + } + if ($pathSource) { + $derived = Get-BackupBaseName -RawPath $pathSource + if ($derived) { $candidates += $derived } + } + if ($resolved.IsName) { + $candidates += (Format-CatalogName -Name $item.Path) + } + if ($manifestOld.items.Contains($newName)) { + $recorded = $manifestOld.items[$newName] + if (($recorded.PSObject.Properties.Name -contains 'archive') -and $recorded.archive) { + $candidates += [System.IO.Path]::GetFileNameWithoutExtension([string]$recorded.archive) + } + } - $oldFile = Find-ArchiveByBaseName -BaseName $oldName -Directory $BackupDir -Formats $supportedFormats + # manifest 里"指向过同一个源"的历史归档名 + $lookupKeys = @([string]$item.Path) + foreach ($entryItem in @($resolved.Items)) { + if ($entryItem.Declared) { $lookupKeys += [string]$entryItem.Declared } + if ($entryItem.RealPath) { $lookupKeys += [string]$entryItem.RealPath } + } + foreach ($lookupKey in $lookupKeys) { + if ([string]::IsNullOrWhiteSpace($lookupKey)) { continue } + $mapKey = $lookupKey.Trim().ToLower() + if ($manifestBySource.ContainsKey($mapKey)) { $candidates += @($manifestBySource[$mapKey]) } + } + + $candidates = @($candidates | Where-Object { $_ -and $_ -ne $newName } | Select-Object -Unique) + + $entries += [pscustomobject]@{ Item = $item; NewName = $newName; Resolved = $resolved; Candidates = $candidates } + + if (Find-ArchiveByBaseName -BaseName $newName -Directory $BackupDir -Formats $supportedFormats) { + $unchanged++ + continue + } + + $oldFile = $null + foreach ($candidate in $candidates) { + $foundCandidate = Find-ArchiveByBaseName -BaseName $candidate -Directory $BackupDir -Formats $supportedFormats + if ($foundCandidate) { $oldFile = $foundCandidate; break } + } if (-not $oldFile) { $missingOld++; continue } $plan += [pscustomobject]@{ @@ -190,10 +251,14 @@ $now = (Get-Date).ToString('o') foreach ($entry in $entries) { $file = Find-ArchiveByBaseName -BaseName $entry.NewName -Directory $BackupDir -Formats $supportedFormats - # 历史字段优先从新键取,其次从旧键(路径命名)取 + # 历史字段优先从新键取,其次从候选旧名里取 $previous = $null if ($manifestOld.items.Contains($entry.NewName)) { $previous = $manifestOld.items[$entry.NewName] } - elseif ($manifestOld.items.Contains($entry.OldName)) { $previous = $manifestOld.items[$entry.OldName] } + else { + foreach ($candidate in $entry.Candidates) { + if ($manifestOld.items.Contains($candidate)) { $previous = $manifestOld.items[$candidate]; break } + } + } $getPrevious = { param([string]$Field) @@ -205,7 +270,10 @@ foreach ($entry in $entries) { baseName = $entry.NewName source = $entry.Item.Path resolvedSource = [Environment]::ExpandEnvironmentVariables($entry.Item.Path) - roots = @($entry.Resolved.Sources | ForEach-Object { $_.RootName }) + roots = @($entry.Resolved.Items | ForEach-Object { $_.TopName } | Select-Object -Unique) + layouts = @($entry.Resolved.Items | ForEach-Object { + [ordered]@{ name = $_.ArchivePath; kind = $(if ($_.IsFile) { 'file' } else { 'dir' }) } + }) catalog = $(if ($entry.Resolved.CatalogEntry) { $entry.Resolved.CatalogEntry.Path } else { $null }) archive = $(if ($file) { $file.Name } else { $entry.NewName + '.7z' }) action = $(if ($file) { 'backed-up' } else { 'missing-source' }) @@ -218,7 +286,7 @@ foreach ($entry in $entries) { verified = $false warnings = $false attemptWarnings = $false - encrypted = ($entry.Item.Flags -contains 'encrypt') + encrypted = [bool]$entry.Resolved.Encrypt sourceFiles = (& $getPrevious 'sourceFiles') sourceBytes = (& $getPrevious 'sourceBytes') archiveBytes = $(if ($file) { $file.Length } else { $null }) diff --git a/tools/lab/Lab-Common.ps1 b/tools/lab/Lab-Common.ps1 new file mode 100644 index 0000000..2326bbe --- /dev/null +++ b/tools/lab/Lab-Common.ps1 @@ -0,0 +1,182 @@ +<# +.SYNOPSIS + BakNRet 隔离测试环境的共享配置与工具函数(Hyper-V 真机级 VM)。 + +.DESCRIPTION + 被 tools\lab\New-BakNRetLab.ps1(一次性搭建)与 tools\lab\Lab.ps1(日常使用)共同导入。 + + 设计约定: + * 宿主机侧的一切状态(VHDX、日志、凭据、暂存包)都放在 $LabRoot 下,它刻意位于 + **仓库之外**(默认 D:\VMs\BakNRet-Lab);测试用的归档、日志因此不会落进真实仓库, + 真实仓库的 Backups\ 与 logs\ 在整套流程里只被读取、从不写入。 + * VM 内的仓库副本在 C:\BakNRet,工具负载在 C:\BakNRet-Lab。 + * 与 VM 的一切交互走 PowerShell Direct(VMBus),不依赖网络、不共享宿主机目录。 +#> + + +$script:LabConfig = [ordered]@{ + VmName = 'BakNRet-Lab' + LabRoot = 'D:\VMs\BakNRet-Lab' + VhdxPath = 'D:\VMs\BakNRet-Lab\vhdx\BakNRet-Lab.vhdx' + VhdxSizeGB = 80 + IsoPath = 'F:\Images\Windows\Win11_25H2_Chinese_Simplified_x64_v2.iso' + ImageIndex = 4 # Windows 11 专业版 + SwitchName = 'Default Switch' + MemoryStartupGB = 8 + CpuCount = 8 + GuestRepoPath = 'C:\BakNRet' + GuestLabPath = 'C:\BakNRet-Lab' + GuestUser = 'lab' + CheckpointName = 'clean-baseline' + RepoRoot = (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) +} + +function Get-LabConfig { return $script:LabConfig } + +function Get-LabPath { + <# .SYNOPSIS 取宿主机侧实验目录下的路径(自动建父目录)。 #> + param([Parameter(Mandatory)][string]$Relative) + $full = Join-Path $script:LabConfig.LabRoot $Relative + $parent = Split-Path -Parent $full + if ($parent -and -not (Test-Path -LiteralPath $parent)) { New-Item -ItemType Directory -Force -Path $parent | Out-Null } + return $full +} + +function Write-LabLog { + <# .SYNOPSIS 统一日志:同时进控制台与 $LabRoot\logs\lab.log。 #> + param([Parameter(Mandatory)][string]$Message, [ValidateSet('INFO','WARN','ERROR','STEP')][string]$Level = 'INFO') + $line = "[{0}] [{1,-5}] {2}" -f (Get-Date).ToString('HH:mm:ss'), $Level, $Message + switch ($Level) { + 'STEP' { Write-Host $line -ForegroundColor Cyan } + 'WARN' { Write-Host $line -ForegroundColor Yellow } + 'ERROR' { Write-Host $line -ForegroundColor Red } + default { Write-Host $line } + } + Add-Content -LiteralPath (Get-LabPath 'logs\lab.log') -Value $line -Encoding UTF8 -ErrorAction SilentlyContinue +} + +function Test-LabElevated { + param() + return ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +function Assert-LabElevated { + <# .SYNOPSIS 需要管理员:非同权限时给出可直接复制的提权命令。 #> + param([Parameter(Mandatory)][string]$Why) + if (Test-LabElevated) { return } + $gsudo = (Get-Command gsudo -ErrorAction SilentlyContinue | Select-Object -First 1).Source + $self = $MyInvocation.PSCommandPath + $hint = if ($gsudo) { "`n $gsudo pwsh -NoProfile -File `"$self`" $($MyInvocation.Line)" } else { '' } + throw "需要管理员权限:$Why$hint" +} + +function Get-LabCredentialPath { return (Join-Path $script:LabConfig.LabRoot 'state\credentials.json') } + +function Save-LabCredential { + <# .SYNOPSIS 把 VM 内 lab 账户的口令写进宿主机侧的 credentials.json(在仓库之外)。 #> + param([Parameter(Mandatory)][string]$Password) + $path = Get-LabCredentialPath + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $path) | Out-Null + [ordered]@{ + VmName = $script:LabConfig.VmName + User = $script:LabConfig.GuestUser + Password = $Password + SavedAt = (Get-Date).ToString('s') + } | ConvertTo-Json | Set-Content -LiteralPath $path -Encoding UTF8 + return $path +} + +function Get-LabCredential { + <# .SYNOPSIS 读出 VM 凭据为 PSCredential。 #> + param() + $path = Get-LabCredentialPath + if (-not (Test-Path -LiteralPath $path)) { throw "找不到 VM 凭据:$path(先跑 New-BakNRetLab.ps1)" } + $j = Get-Content -LiteralPath $path -Raw -Encoding UTF8 | ConvertFrom-Json + $sec = ConvertTo-SecureString $j.Password -AsPlainText -Force + return [pscredential]::new("$($j.User)", $sec) +} + +function New-LabPassword { + <# .SYNOPSIS 生成只含字母数字的口令(避免 XML / 命令行转义问题)。 #> + param([int]$Length = 24) + $chars = 'abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789' + return -join (1..$Length | ForEach-Object { $chars[(Get-Random -Minimum 0 -Maximum $chars.Length)] }) +} + +function Get-LabVm { + param() + return Get-VM -Name $script:LabConfig.VmName -ErrorAction SilentlyContinue +} + +function Wait-LabVMRunning { + <# .SYNOPSIS 等 VM 进入 Running。 #> + param([int]$TimeoutSeconds = 300) + $sw = [Diagnostics.Stopwatch]::StartNew() + while ($sw.Elapsed.TotalSeconds -lt $TimeoutSeconds) { + $vm = Get-LabVm + if ($vm -and $vm.State -eq 'Running') { return $true } + Start-Sleep -Seconds 3 + } + return $false +} + +function New-LabSession { + <# .SYNOPSIS 建立 PowerShell Direct 会话(VMBus,不经网络)。 #> + param([int]$RetrySeconds = 600) + $cred = Get-LabCredential + $sw = [Diagnostics.Stopwatch]::StartNew() + $lastError = $null + while ($sw.Elapsed.TotalSeconds -lt $RetrySeconds) { + try { + $s = New-PSSession -VMName $script:LabConfig.VmName -Credential $cred -ErrorAction Stop + Write-LabLog "PowerShell Direct 会话已建立(等待 $([math]::Round($sw.Elapsed.TotalSeconds)) 秒)" + return $s + } catch { + $lastError = $_.Exception.Message + Start-Sleep -Seconds 5 + } + } + throw "无法建立 PowerShell Direct 会话:$lastError" +} + +function Invoke-LabCommand { + <# .SYNOPSIS 在 VM 里跑一段脚本并回传结果(自动建/收会话)。 #> + param( + [Parameter(Mandatory)][scriptblock]$ScriptBlock, + [object[]]$ArgumentList = @(), + [int]$RetrySeconds = 600 + ) + $s = New-LabSession -RetrySeconds $RetrySeconds + try { + return Invoke-Command -Session $s -ScriptBlock $ScriptBlock -ArgumentList $ArgumentList -ErrorAction Stop + } finally { + Remove-PSSession -Session $s -ErrorAction SilentlyContinue + } +} + +function Copy-LabFileToGuest { + <# .SYNOPSIS 宿主机 -> VM 传文件(Copy-VMFile,需要 Guest Service Interface)。 #> + param( + [Parameter(Mandatory)][string]$SourcePath, + [Parameter(Mandatory)][string]$DestinationPath + ) + Copy-VMFile -VMName $script:LabConfig.VmName -SourcePath $SourcePath ` + -DestinationPath $DestinationPath -CreateFullPath -FileSource Host -Force +} + +function Get-HostSevenZip { + <# .SYNOPSIS 宿主机 7z 路径(用来打包仓库快照)。 #> + param() + $c = Get-Command 7z -ErrorAction SilentlyContinue | Select-Object -First 1 + if (-not $c) { throw '宿主机找不到 7z' } + return $c.Source +} + +function Test-LabGuestReady { + <# .SYNOPSIS 判断 VM 内供给是否完成(provision.ok)。 #> + param() + try { + $r = Invoke-LabCommand -ScriptBlock { Test-Path 'C:\BakNRet-Lab\state\provision.ok' } -RetrySeconds 60 + return [bool]$r + } catch { return $false } +} diff --git a/tools/lab/Lab.ps1 b/tools/lab/Lab.ps1 new file mode 100644 index 0000000..51c03c3 --- /dev/null +++ b/tools/lab/Lab.ps1 @@ -0,0 +1,423 @@ +<# +.SYNOPSIS + BakNRet 隔离测试环境(Hyper-V 真机级 VM)的日常入口。 + +.DESCRIPTION + 与 New-BakNRetLab.ps1 的分工:那个负责**搭**,这个负责**用**。 + + 动词: + status 看 VM 状态、检查点、供给事实、沙盒归档与最近日志 + start/stop 启停 VM + wait 等 VM 内供给完成(首次搭建后) + sync 把当前仓库快照推进 VM(排除 Backups\ logs\ .git\ .tools\),并装好 Pester + seed 在 VM 里生成「带刺」的沙盒假数据(真 NTFS 连接点、被占用文件、长路径、中文路径…) + backup 在 VM 里用沙盒清单/配置真跑 Backup.ps1(可选 -DryRun) + restore 用真实归档做恢复演练(Restore-Drill.ps1),逐字节对拍 + acl-test 安全描述符演练:scoop 装的 vscode 备份/恢复后仍可读写;ProgramData 那种 + 「属主 + CREATOR OWNER」的目录恢复后属主必须仍是原账户(另有负对照) + test 在 VM 里跑仓库自带的测试套件(pester / zero / e2e / all) + shell 打开到 VM 的交互式 PowerShell Direct 会话 + console 打印 VM 内的供给日志与最新备份日志 + checkpoint 打检查点(默认带时间戳;-CheckpointName 可指定) + reset 回到 clean-baseline 检查点(秒回干净状态) + destroy 删除 VM 与系统盘(需要 -Confirm) + + 一切都在 VM 内进行:宿主机的仓库、Backups\、logs\ 不会被这套流程写入。 + +.EXAMPLE + gsudo pwsh -NoProfile -File .\tools\lab\Lab.ps1 status +.EXAMPLE + gsudo pwsh -NoProfile -File .\tools\lab\Lab.ps1 sync + gsudo pwsh -NoProfile -File .\tools\lab\Lab.ps1 seed -Force + gsudo pwsh -NoProfile -File .\tools\lab\Lab.ps1 backup + gsudo pwsh -NoProfile -File .\tools\lab\Lab.ps1 restore +.EXAMPLE + gsudo pwsh -NoProfile -File .\tools\lab\Lab.ps1 test -Suite all +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory, Position = 0)] + [ValidateSet('status','start','stop','wait','sync','seed','backup','restore','acl-test','test','shell','console','checkpoint','reset','destroy')] + [string]$Verb, + + [ValidateSet('all','pester','zero','e2e')][string]$Suite = 'all', + + # 恢复演练要处理的条目(写法同 BackupList.txt 的一行) + [string[]]$Entries, + + [switch]$DryRun, + [switch]$Force, + [switch]$AcceptWarnings, + [switch]$KeepWork, + + # acl-test 专用:跳过"装 scoop + scoop install vscode"(省掉几百 MB 下载, + # 只验证 ProgramData 那段的属主 / CREATOR OWNER) + [switch]$SkipScoop, + + [string]$CheckpointName, + [switch]$Confirm +) + +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'Lab-Common.ps1') +$cfg = Get-LabConfig + +$guestSandbox = "$($cfg.GuestRepoPath)\tools\lab\payload\sandbox" +$guestList = "$guestSandbox\BackupList.txt" +$guestConfig = "$guestSandbox\BackupConfig.psd1" +$guestFixture = "$($cfg.GuestRepoPath)\tools\lab\payload\lab-fixtures.ps1" +$guestBackupDir = 'C:\BakNRet-Lab\Backups' + +Assert-LabElevated -Why "Hyper-V 操作与 PowerShell Direct 都需要管理员(动词:$Verb)" + +# --------------------------------------------------------------------------- +# 内部工具 +# --------------------------------------------------------------------------- + +function Get-VmSummary { + $vm = Get-LabVm + if (-not $vm) { return $null } + $mem = Get-VMMemory -VMName $cfg.VmName + return [pscustomobject]@{ + Name = $vm.Name + State = $vm.State + Uptime = [int]$vm.Uptime.TotalSeconds + Cpu = $vm.ProcessorCount + MemoryGB = [math]::Round($mem.Startup / 1GB, 1) + Gen = $vm.Generation + UptimeText = "$([int]$vm.Uptime.TotalMinutes) 分钟" + } +} + +function Invoke-GuestScriptFile { + <# .SYNOPSIS 在 VM 里用 pwsh 跑脚本文件,回传退出码与日志尾部。 #> + param( + [Parameter(Mandatory)][string]$ScriptPath, + # 不设 Mandatory:不需要参数的套件会传空数组,Mandatory 会拒绝空数组绑定 + [string[]]$ScriptArgs = @(), + [Parameter(Mandatory)][string]$Tag, + [int]$TailLines = 30 + ) + $stamp = Get-Date -Format 'yyyyMMdd-HHmmss' + $logPath = "C:\BakNRet-Lab\logs\$Tag-$stamp.log" + # 参数用 JSON 传:数组直接经 Invoke-Command -ArgumentList 过去会退化成嵌套数组, + # 到 VM 里 Start-Process -ArgumentList 就会报「无法转换为 System.String」。 + $argsJson = if (@($ScriptArgs).Count -eq 0) { '[]' } else { ConvertTo-Json -InputObject @($ScriptArgs) -Compress } + if (@($ScriptArgs).Count -eq 1 -and -not $argsJson.StartsWith('[') -and -not $argsJson.StartsWith('{')) { $argsJson = "[$argsJson]" } + return Invoke-LabCommand -ScriptBlock { + param($script, $argsJson, $logPath, $tailLines) + # ConvertFrom-Json 把 JSON 数组当成「一个对象」写出,直接 @(...) 会套成嵌套数组, + # 传到 Start-Process -ArgumentList 就报「无法转换为 System.String」。显式枚举摊平。 + $scriptArgs = @() + if ($argsJson) { + $parsed = ConvertFrom-Json -InputObject $argsJson + $scriptArgs = @($parsed | ForEach-Object { [string]$_ }) + } + # 子进程被重定向的 stdout 是**控制台代码页**(中文 Windows 上是 GBK/936), + # 用 -Encoding UTF8 读会整片乱码;而且 PS7 的 Get-Content -Encoding 不接受 + # Encoding 对象。这里按「替换字符更少」的胜出者解码。 + function Read-TextTail([string]$path, [int]$lines) { + if (-not (Test-Path -LiteralPath $path)) { return @() } + $bytes = [IO.File]::ReadAllBytes($path) + $asUtf8 = [Text.Encoding]::UTF8.GetString($bytes) + $asAnsi = [Text.Encoding]::GetEncoding([Globalization.CultureInfo]::CurrentCulture.TextInfo.ANSICodePage).GetString($bytes) + $badUtf8 = 0; foreach ($ch in $asUtf8.ToCharArray()) { if ($ch -eq [char]0xFFFD) { $badUtf8++ } } + $badAnsi = 0; foreach ($ch in $asAnsi.ToCharArray()) { if ($ch -eq [char]0xFFFD) { $badAnsi++ } } + $text = if ($badUtf8 -le $badAnsi) { $asUtf8 } else { $asAnsi } + return @($text -split "`r?`n" | Where-Object { $_ -ne '' } | Select-Object -Last $lines) + } + $all = @('-NoProfile','-ExecutionPolicy','Bypass','-File',$script) + $scriptArgs + $out = $logPath + $err = "$logPath.err" + $p = Start-Process -FilePath 'pwsh.exe' -ArgumentList $all -NoNewWindow -Wait -PassThru -RedirectStandardOutput $out -RedirectStandardError $err + [pscustomobject]@{ + ExitCode = $p.ExitCode + LogPath = $out + Tail = @(Read-TextTail $out $tailLines) + ErrTail = @(Read-TextTail $err 10) + } + } -ArgumentList $ScriptPath, $argsJson, $logPath, $TailLines +} + +function Invoke-LabSync { + $zip = Get-LabPath 'stage\repo.zip' + $sevenZip = Get-HostSevenZip + Write-LabLog "打包仓库快照:$($cfg.RepoRoot)(排除 Backups\ logs\ .git\ .tools\)" 'STEP' + Push-Location $cfg.RepoRoot + try { + & $sevenZip a -tzip $zip '.\*' '-xr!Backups' '-xr!logs' '-xr!.git' '-xr!.tools' '-xr!*.tmp.7z' '-xr!*.tmp.zip' -y | Out-Null + } finally { Pop-Location } + Write-LabLog ("快照大小 {0} MB" -f [math]::Round((Get-Item -LiteralPath $zip).Length / 1MB, 2)) + + Write-LabLog '推送到 VM(Copy-VMFile,走 VMBus)' 'STEP' + Copy-LabFileToGuest -SourcePath $zip -DestinationPath "$($cfg.GuestLabPath)\stage\repo.zip" + + Write-LabLog '在 VM 内解开到 C:\BakNRet 并装好 Pester' 'STEP' + $info = Invoke-LabCommand -ScriptBlock { + param($guestRepo, $guestLab) + $sevenZip = 'C:\Program Files\7-Zip\7z.exe' + if (-not (Test-Path -LiteralPath $sevenZip)) { $sevenZip = Join-Path $guestLab 'payload\7zip\7z.exe' } + if (Test-Path -LiteralPath $guestRepo) { Remove-Item -LiteralPath $guestRepo -Recurse -Force } + New-Item -ItemType Directory -Force -Path $guestRepo | Out-Null + $null = & $sevenZip x "$guestLab\stage\repo.zip" "-o$guestRepo" -y + $pesterDst = Join-Path $guestRepo '.tools\modules\Pester\5.9.1' + New-Item -ItemType Directory -Force -Path $pesterDst | Out-Null + robocopy "$guestLab\payload\Pester\5.9.1" $pesterDst /MIR /NFL /NDL /NJH /NJS /NP | Out-Null + [pscustomobject]@{ + SyncedAt = (Get-Date).ToString('s') + Files = (Get-ChildItem -LiteralPath $guestRepo -Recurse -File -ErrorAction SilentlyContinue | Measure-Object).Count + HasBackup = (Test-Path (Join-Path $guestRepo 'Backup.ps1')) + HasPester = (Test-Path (Join-Path $pesterDst 'Pester.psd1')) + } + } -ArgumentList $cfg.GuestRepoPath, $cfg.GuestLabPath + Write-LabLog ("同步完成:{0} 个文件,Backup.ps1={1},Pester={2}" -f $info.Files, $info.HasBackup, $info.HasPester) 'STEP' + return $info +} + +function Show-GuestOutput { + param($Result, [switch]$Quiet) + if (-not $Quiet) { + foreach ($line in @($Result.Tail)) { Write-Host " $line" } + foreach ($line in @($Result.ErrTail)) { if ($line) { Write-Host " ! $line" -ForegroundColor Yellow } } + } + $color = if ($Result.ExitCode -eq 0) { 'Green' } else { 'Red' } + Write-Host (" 退出码 = {0}" -f $Result.ExitCode) -ForegroundColor $color +} + +# --------------------------------------------------------------------------- +# 动词 +# --------------------------------------------------------------------------- + +switch ($Verb) { + + 'status' { + $s = Get-VmSummary + if (-not $s) { + Write-Host 'VM 不存在。先跑 tools\lab\New-BakNRetLab.ps1 搭建。' -ForegroundColor Yellow + break + } + Write-Host '' + Write-Host ('== BakNRet 隔离测试环境 ==') -ForegroundColor Cyan + Write-Host ("VM : {0} [{1}] 已运行 {2}" -f $s.Name, $s.State, $s.UptimeText) + Write-Host ("规格 : Gen{0} / {1} vCPU / {2} GB / Default Switch" -f $s.Gen, $s.Cpu, $s.MemoryGB) + Write-Host ("实验室目录: {0}" -f $cfg.LabRoot) + Write-Host ("VHDX : {0} ({1} GB 实际占用)" -f $cfg.VhdxPath, [math]::Round((Get-Item -LiteralPath $cfg.VhdxPath).Length / 1GB, 2)) + $snaps = @(Get-VMSnapshot -VMName $cfg.VmName -ErrorAction SilentlyContinue) + Write-Host ("检查点 : {0}" -f $(if ($snaps) { ($snaps | ForEach-Object { "$($_.Name) [$($_.CreationTime.ToString('MM-dd HH:mm'))]" }) -join ', ' } else { '(无)' })) + + if ($s.State -eq 'Running') { + try { + $g = Invoke-LabCommand -RetrySeconds 30 -ScriptBlock { + $ok = Test-Path 'C:\BakNRet-Lab\state\provision.ok' + $os = Get-CimInstance Win32_OperatingSystem + $arch = @() + if (Test-Path 'C:\BakNRet-Lab\Backups') { + $arch = @(Get-ChildItem 'C:\BakNRet-Lab\Backups' -Filter *.7z -ErrorAction SilentlyContinue | + ForEach-Object { [pscustomobject]@{ Name = $_.BaseName; MB = [math]::Round($_.Length / 1MB, 2) } }) + } + $src = 'C:\BakNRet-Lab\sources' + [pscustomobject]@{ + Provisioned = $ok + OsBuild = $os.BuildNumber + OsCaption = $os.Caption + GuestPS = $PSVersionTable.PSVersion.ToString() + RepoFiles = $(if (Test-Path 'C:\BakNRet') { (Get-ChildItem 'C:\BakNRet' -Recurse -File -ErrorAction SilentlyContinue | Measure-Object).Count } else { 0 }) + SourceMB = $(if (Test-Path $src) { [math]::Round(((Get-ChildItem $src -Recurse -File -Force -ErrorAction SilentlyContinue | Measure-Object Length -Sum).Sum) / 1MB, 1) } else { 0 }) + Archives = $arch + LastLog = (Get-ChildItem 'C:\BakNRet-Lab\logs' -Filter 'backup-*.log' -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime | Select-Object -Last 1 -ExpandProperty Name) + } + } + Write-Host ("VM 内 : 供给={0} {1} (build {2}) PS={3}" -f $g.Provisioned, $g.OsCaption, $g.OsBuild, $g.GuestPS) + Write-Host ("仓库副本 : C:\BakNRet {0} 个文件" -f $g.RepoFiles) + Write-Host ("沙盒源数据 : {0} MB" -f $g.SourceMB) + if ($g.Archives.Count -gt 0) { + Write-Host ("沙盒归档 : {0} 个({1} MB 合计)" -f $g.Archives.Count, [math]::Round((($g.Archives | Measure-Object MB -Sum).Sum), 1)) + $g.Archives | Sort-Object MB -Descending | ForEach-Object { Write-Host (" {0,-52} {1,8} MB" -f $_.Name, $_.MB) } + } else { Write-Host '沙盒归档 : (还没有,跑 Lab.ps1 backup)' } + if ($g.LastLog) { Write-Host ("最近备份日志: {0}" -f $g.LastLog) } + } catch { + Write-Host ("VM 内查询失败(可能还没起来):{0}" -f $_.Exception.Message) -ForegroundColor Yellow + } + } + Write-Host '' + } + + 'start' { + $vm = Get-LabVm + if (-not $vm) { throw 'VM 不存在,先跑 New-BakNRetLab.ps1' } + if ($vm.State -ne 'Running') { Start-VM -Name $cfg.VmName; $null = Wait-LabVMRunning -TimeoutSeconds 180 } + Write-LabLog "VM 状态:$((Get-LabVm).State)" 'STEP' + } + + 'stop' { + $vm = Get-LabVm + if ($vm -and $vm.State -eq 'Running') { + Write-LabLog '正常关机(走集成服务)' 'STEP' + Stop-VM -Name $cfg.VmName -ErrorAction SilentlyContinue + Start-Sleep -Seconds 3 + if ((Get-LabVm).State -ne 'Off') { Write-LabLog '未关机,强制断电' 'WARN'; Stop-VM -Name $cfg.VmName -TurnOff -Force } + } + Write-LabLog "VM 状态:$((Get-LabVm).State)" 'STEP' + } + + 'wait' { + $sw = [Diagnostics.Stopwatch]::StartNew() + while ($sw.Elapsed.TotalMinutes -lt 30) { + if (Test-LabGuestReady) { + Write-LabLog ("VM 已就绪(等待 {0} 分钟)" -f [math]::Round($sw.Elapsed.TotalMinutes, 1)) 'STEP' + $facts = Invoke-LabCommand -ScriptBlock { Get-Content 'C:\BakNRet-Lab\state\provisioned.json' -Raw } + Write-Host $facts + break + } + Start-Sleep -Seconds 10 + } + if (-not (Test-LabGuestReady)) { throw '等待超时:VM 内供给仍未完成' } + } + + 'sync' { $null = Invoke-LabSync } + + 'seed' { + Write-LabLog '先把当前工作树同步进 VM' 'STEP'; $null = Invoke-LabSync + $argList = @() + if ($Force) { $argList += '-Force' } + Write-LabLog '在 VM 内生成沙盒假数据' 'STEP' + $r = Invoke-GuestScriptFile -ScriptPath $guestFixture -ScriptArgs $argList -Tag 'fixtures' -TailLines 20 + Show-GuestOutput $r + } + + 'backup' { + Write-LabLog '先把当前工作树同步进 VM' 'STEP'; $null = Invoke-LabSync + $argList = @('-BackupListPath', $guestList, '-ConfigPath', $guestConfig) + if ($DryRun) { $argList += '-DryRun' } + if ($Force) { $argList += '-Force' } + if ($AcceptWarnings) { $argList += '-AcceptWarnings' } + Write-LabLog "在 VM 内跑 Backup.ps1(DryRun=$DryRun,Force=$Force)" 'STEP' + $r = Invoke-GuestScriptFile -ScriptPath "$($cfg.GuestRepoPath)\Backup.ps1" -ScriptArgs $argList -Tag 'backup' -TailLines 40 + Show-GuestOutput $r + } + + 'restore' { + Write-LabLog '先把当前工作树同步进 VM' 'STEP'; $null = Invoke-LabSync + if (-not $Entries -or $Entries.Count -eq 0) { + $Entries = @( + 'AppMultiSlot', 'AppFileSlot', '软件目录甲', 'JunctionToData', + 'C:\BakNRet-Lab\sources\AppBig', 'C:\BakNRet-Lab\sources\AppDeep' + ) + } + # 数组参数不能跨进程传(-File 只会绑第一个值),改用 ';' 分隔的纯文本, + # 由 payload\run-drill.ps1 在 VM 内做真正的数组绑定 + $entriesCsv = (@($Entries) | ForEach-Object { [string]$_ }) -join ';' + $argList = @('-BackupDir', $guestBackupDir, '-ConfigPath', $guestConfig, '-EntriesCsv', $entriesCsv) + if ($KeepWork) { $argList += '-KeepWorkRoot' } + Write-LabLog ("恢复演练:{0} 个条目" -f @($Entries).Count) 'STEP' + $r = Invoke-GuestScriptFile -ScriptPath "$($cfg.GuestRepoPath)\tools\lab\payload\run-drill.ps1" -ScriptArgs $argList -Tag 'drill' -TailLines 45 + Show-GuestOutput $r + } + + 'acl-test' { + Write-LabLog '先把当前工作树同步进 VM' 'STEP'; $null = Invoke-LabSync + + $argList = @('-RepoPath', $cfg.GuestRepoPath, '-WorkRoot', 'C:\BakNRet-Lab\acl') + if ($SkipScoop) { $argList += '-SkipScoop' } + if ($KeepWork) { $argList += '-KeepWorkRoot' } + + Write-LabLog '安全描述符演练:scoop 装的 vscode + ProgramData 属主 / CREATOR OWNER' 'STEP' + $r = Invoke-GuestScriptFile -ScriptPath "$($cfg.GuestRepoPath)\tools\lab\payload\run-acl-scenario.ps1" -ScriptArgs $argList -Tag 'acl' -TailLines 60 + Show-GuestOutput $r + + # 其它动词都不回传 guest 退出码(只有 test 会扔异常),这个必须扔: + # 否则演练失败时宿主侧仍然退出 0,等于没有门禁。 + if ($r.ExitCode -ne 0) { + throw ("ACL 演练失败(退出码 {0}),VM 内日志 {1}" -f $r.ExitCode, $r.LogPath) + } + } + + 'test' { + $map = [ordered]@{ + pester = @{ Path = 'tests\Run-Pester.ps1'; Args = @(); Name = 'Pester 套件' } + zero = @{ Path = 'tests\Run-Tests.ps1'; Args = @(); Name = '零依赖套件' } + e2e = @{ Path = 'tests\Run-E2E.ps1'; Args = @(); Name = '端到端验收' } + } + $pick = if ($Suite -eq 'all') { @($map.Keys) } else { @($Suite) } + + Write-LabLog '先把当前工作树同步进 VM' 'STEP' + $null = Invoke-LabSync + + $results = @() + foreach ($key in $pick) { + $item = $map[$key] + $argList = @($item.Args) + if ($key -eq 'e2e' -and $KeepWork) { $argList += '-KeepWorkRoot' } + Write-LabLog ("跑 {0}({1})" -f $item.Name, $item.Path) 'STEP' + # 走 UTF-8 包装器:测试自己抓子进程输出时按 UTF-8 读回, + # 而 VM 的控制台输出编码是 ANSI(936),直接跑会有 8 项中文断言失败(见 README「已知问题」) + $wrapperPath = "$($cfg.GuestRepoPath)\tools\lab\payload\run-suite-utf8.ps1" + $suiteArgs = @("$($cfg.GuestRepoPath)\$($item.Path)") + $argList + $r = Invoke-GuestScriptFile -ScriptPath $wrapperPath -ScriptArgs $suiteArgs -Tag "test-$key" -TailLines 8 + Show-GuestOutput $r -Quiet + foreach ($line in @($r.Tail) | Where-Object { $_ -match '全部通过|通过 \d+ 项,失败|通过\s*\d+' }) { Write-Host " $line" } + $results += [pscustomobject]@{ Suite = $item.Name; ExitCode = $r.ExitCode; Log = $r.LogPath } + } + + Write-Host '' + Write-Host '== 套件结果 ==' -ForegroundColor Cyan + $results | ForEach-Object { + $color = if ($_.ExitCode -eq 0) { 'Green' } else { 'Red' } + Write-Host (" {0,-14} 退出码 {1} 日志 {2}" -f $_.Suite, $_.ExitCode, $_.Log) -ForegroundColor $color + } + $bad = @($results | Where-Object ExitCode -ne 0) + if ($bad.Count -gt 0) { throw ("有 {0} 套件失败" -f $bad.Count) } + } + + 'shell' { + Write-LabLog '进入 VM(PowerShell Direct)。退出用 exit。' 'STEP' + $cred = Get-LabCredential + Enter-PSSession -VMName $cfg.VmName -Credential $cred + } + + 'console' { + $r = Invoke-LabCommand -ScriptBlock { + $out = @() + foreach ($f in 'C:\BakNRet-Lab\logs\provision.log') { + if (Test-Path $f) { $out += "===== $f ====="; $out += @(Get-Content $f -Tail 40 -Encoding UTF8) } + } + $latest = Get-ChildItem 'C:\BakNRet-Lab\logs' -Filter 'backup-*.log' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime | Select-Object -Last 1 + if ($latest) { $out += "===== $($latest.FullName) ====="; $out += @(Get-Content $latest.FullName -Tail 60 -Encoding UTF8) } + $out + } + $r | ForEach-Object { Write-Host $_ } + } + + 'checkpoint' { + if (-not $CheckpointName) { $CheckpointName = 'lab-' + (Get-Date -Format 'MMdd-HHmm') } + Checkpoint-VM -Name $cfg.VmName -SnapshotName $CheckpointName + Write-LabLog "已创建检查点 $CheckpointName" 'STEP' + } + + 'reset' { + if (-not $CheckpointName) { $CheckpointName = $cfg.CheckpointName } + $snap = Get-VMSnapshot -VMName $cfg.VmName -ErrorAction SilentlyContinue | Where-Object Name -eq $CheckpointName + if (-not $snap) { throw "找不到检查点 $CheckpointName" } + Write-LabLog "回到检查点 $CheckpointName" 'STEP' + Restore-VMSnapshot -VMSnapshot $snap -Confirm:$false + $null = Wait-LabVMRunning -TimeoutSeconds 240 + Write-LabLog ("VM 状态:{0}" -f (Get-LabVm).State) 'STEP' + } + + 'destroy' { + if (-not $Confirm) { throw '这会删除 VM 与系统盘。确认请加 -Confirm。' } + $vm = Get-LabVm + if ($vm) { + if ($vm.State -ne 'Off') { Stop-VM -Name $cfg.VmName -TurnOff -Force } + Remove-VM -Name $cfg.VmName -Force + Write-LabLog "已删除虚拟机 $($cfg.VmName)" 'STEP' + } + if (Test-Path -LiteralPath $cfg.VhdxPath) { + Remove-Item -LiteralPath $cfg.VhdxPath -Force + Write-LabLog "已删除系统盘 $($cfg.VhdxPath)" 'STEP' + } + Write-LabLog '($LabRoot 下的日志与凭据保留,便于排查)' 'WARN' + } +} \ No newline at end of file diff --git a/tools/lab/New-BakNRetLab.ps1 b/tools/lab/New-BakNRetLab.ps1 new file mode 100644 index 0000000..2ebad2c --- /dev/null +++ b/tools/lab/New-BakNRetLab.ps1 @@ -0,0 +1,252 @@ +<# +.SYNOPSIS + 从零搭出 BakNRet 的 Hyper-V 隔离测试 VM(真机级:真 NTFS、真 ACL、真连接点、真重启)。 + +.DESCRIPTION + 全流程无人值守、不需要点任何安装向导,也不需要 VM 的图形界面: + + 1. disk —— 挂载 Windows ISO,建 80 GB 动态 VHDX,按 UEFI 规范 GPT 分区, + 用 DISM 把 install.wim 的指定版本展开进去,注入 7-Zip / PowerShell 7 / + Pester / 供给脚本,写入 C:\Windows\Panther\unattend.xml,最后 bcdboot 写引导; + 2. vm —— 建 Gen2 虚拟机(8 GB / 8 vCPU、Default Switch、"来宾服务接口"打开、 + 关闭安全启动以便离线注入的引导链可用),挂载系统盘并启动; + 3. provision —— 等首次登录的供给脚本跑完,读回 VM 自报的真机事实,然后打检查点 + clean-baseline(之后 Lab.ps1 -Verb reset 可秒回到干净状态)。 + + 幂等:已存在的 VHDX / VM 会复用,除非显式加 -Recreate。 + +.PARAMETER ListImages + 只打印 ISO 里的映像索引清单,不建任何东西。 + +.PARAMETER Stage + all(默认)/ disk / vm / provision,可单独重跑某一段排查问题。 + +.EXAMPLE + gsudo pwsh -NoProfile -File .\tools\lab\New-BakNRetLab.ps1 -ListImages +.EXAMPLE + gsudo pwsh -NoProfile -File .\tools\lab\New-BakNRetLab.ps1 +#> + +[CmdletBinding()] +param( + [ValidateSet('all','disk','vm','provision')][string]$Stage = 'all', + [switch]$Recreate, + [switch]$ListImages, + [int]$ImageIndex = 0 +) + +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'Lab-Common.ps1') +$cfg = Get-LabConfig + +if ($ImageIndex -gt 0) { $cfg.ImageIndex = $ImageIndex } + +# --------------------------------------------------------------------------- +# ISO 与映像清单 +# --------------------------------------------------------------------------- + +function Get-IsoVolume { + $di = Get-DiskImage -ImagePath $cfg.IsoPath -ErrorAction SilentlyContinue + if (-not $di -or -not $di.Attached) { $di = Mount-DiskImage -ImagePath $cfg.IsoPath -PassThru } + Start-Sleep -Milliseconds 1200 + return $di +} + +function Get-ImageList { + param([Parameter(Mandatory)][string]$IsoLetter) + $wim = @('install.wim','install.esd') | + ForEach-Object { Join-Path "$IsoLetter`:\sources" $_ } | + Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1 + if (-not $wim) { throw "ISO ($IsoLetter`:) 里找不到 sources\install.wim|esd" } + $info = & dism.exe /English /Get-WimInfo /WimFile:"$wim" 2>&1 + $list = @(); $cur = $null + foreach ($line in $info) { + if ($line -match '^Index\s*:\s*(\d+)') { if ($cur) { $list += $cur }; $cur = [ordered]@{ Index = [int]$Matches[1]; Name = ''; Size = '' } } + elseif ($cur -and $line -match '^Name\s*:\s*(.+?)\s*$') { $cur.Name = $Matches[1] } + elseif ($cur -and $line -match '^Size\s*:\s*(.+?)\s*$') { $cur.Size = $Matches[1] } + } + if ($cur) { $list += $cur } + return [pscustomobject]@{ WimPath = $wim; Images = $list } +} + +if ($ListImages) { + Assert-LabElevated -Why '挂载 ISO 需要管理员' + $di = Get-IsoVolume + $letter = ($di | Get-Volume).DriveLetter + $il = Get-ImageList -IsoLetter $letter + Write-Host "映像文件:$($il.WimPath)" -ForegroundColor Cyan + $il.Images | ForEach-Object { " [{0}] {1} {2}" -f $_.Index, $_.Name, $_.Size } + return +} + +# --------------------------------------------------------------------------- +# 1. 系统盘 +# --------------------------------------------------------------------------- + +function New-LabSystemDisk { + Assert-LabElevated -Why '创建/分区 VHDX 与 DISM 展开映像' + $espGuid = '{c12a7328-f81f-11d2-ba4b-00a0c93ec93b}' + + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $cfg.VhdxPath) | Out-Null + if ((Test-Path -LiteralPath $cfg.VhdxPath) -and $Recreate) { + Write-LabLog "删除已有 VHDX:$($cfg.VhdxPath)" 'WARN' + $mounted = Get-VHD -Path $cfg.VhdxPath -ErrorAction SilentlyContinue + if ($mounted -and $mounted.Attached) { Dismount-VHD -Path $cfg.VhdxPath } + Remove-Item -LiteralPath $cfg.VhdxPath -Force + } + if (-not (Test-Path -LiteralPath $cfg.VhdxPath)) { + New-VHD -Path $cfg.VhdxPath -SizeBytes ($cfg.VhdxSizeGB * 1GB) -Dynamic | Out-Null + Write-LabLog "已创建动态 VHDX($($cfg.VhdxSizeGB) GB):$($cfg.VhdxPath)" 'STEP' + } + + $vhd = Mount-VHD -Path $cfg.VhdxPath -Passthru + $disk = $vhd | Get-Disk + + if ($disk.PartitionStyle -eq 'RAW') { + # Initialize-Disk 会顺手塞一个 MSR,先删掉,按 UEFI 规范自己建:ESP(300MB FAT32) + Windows(剩余 NTFS) + Initialize-Disk -Number $disk.Number -PartitionStyle GPT -Confirm:$false | Out-Null + Get-Partition -DiskNumber $disk.Number -ErrorAction SilentlyContinue | + Where-Object { $_.Type -eq 'Reserved' } | ForEach-Object { Remove-Partition -DiskNumber $_.DiskNumber -PartitionNumber $_.PartitionNumber -Confirm:$false } + + $efi = New-Partition -DiskNumber $disk.Number -Size 300MB -GptType $espGuid -AssignDriveLetter + Format-Volume -Partition $efi -FileSystem FAT32 -NewFileSystemLabel 'System' -Confirm:$false -Force | Out-Null + $win = New-Partition -DiskNumber $disk.Number -UseMaximumSize -AssignDriveLetter + Format-Volume -Partition $win -FileSystem NTFS -NewFileSystemLabel 'Windows' -Confirm:$false -Force | Out-Null + Write-LabLog '已完成 GPT 分区(ESP 类型已按 EFI System Partition 建立)与格式化' 'STEP' + } + + $efiPart = Get-Partition -DiskNumber $disk.Number | Where-Object GptType -eq $espGuid + $winPart = Get-Partition -DiskNumber $disk.Number | Where-Object { $_.GptType -eq '{ebd0a0a2-b9e5-4433-87c0-68b6b72699c7}' -and $_.Size -gt 1GB } + if (-not $efiPart -or -not $winPart) { throw '分区布局不符合预期(ESP / Windows 分区没找到)' } + $efiLetter = $efiPart.DriveLetter + $winLetter = $winPart.DriveLetter + if (-not $efiLetter) { Set-Partition -DiskNumber $disk.Number -PartitionNumber $efiPart.PartitionNumber -NewDriveLetter 'S'; $efiLetter = 'S' } + if (-not $winLetter) { Set-Partition -DiskNumber $disk.Number -PartitionNumber $winPart.PartitionNumber -NewDriveLetter 'W'; $winLetter = 'W' } + Write-LabLog "ESP = $efiLetter`:,Windows = $winLetter`:" 'STEP' + + # ---- 展开映像 ---- + if (-not (Test-Path -LiteralPath "$winLetter`:\Windows\System32\ntoskrnl.exe")) { + $di = Get-IsoVolume + $isoLetter = ($di | Get-Volume).DriveLetter + $il = Get-ImageList -IsoLetter $isoLetter + $pick = $il.Images | Where-Object Index -eq $cfg.ImageIndex + if (-not $pick) { throw "ISO 里没有索引 $($cfg.ImageIndex);可用:$($il.Images.Index -join ', ')" } + Write-LabLog "展开映像 [$($pick.Index)] $($pick.Name) -> $winLetter`:(需要十几分钟)" 'STEP' + $scratch = Get-LabPath 'scratch' + $out = Get-LabPath 'logs\dism-apply.out' + $err = Get-LabPath 'logs\dism-apply.err' + $proc = Start-Process -FilePath 'dism.exe' -NoNewWindow -Wait -PassThru -RedirectStandardOutput $out -RedirectStandardError $err ` + -ArgumentList @('/English','/Apply-Image',"/ImageFile:$($il.WimPath)", "/Index:$($pick.Index)", "/ApplyDir:$winLetter`:\", "/ScratchDir:$scratch") + if ($proc.ExitCode -ne 0) { throw "DISM 展开失败,退出码 $($proc.ExitCode),见 $out / $err" } + Write-LabLog '映像展开完成' 'STEP' + } else { + Write-LabLog '系统盘上已有 Windows,跳过展开' 'WARN' + } + + # ---- 注入负载与无人值守应答文件 ---- + Write-LabLog '注入 7-Zip / PowerShell 7 / Pester / 供给脚本' 'STEP' + $payloadSrc = Join-Path $PSScriptRoot 'payload' + $guestLab = Join-Path "$winLetter`:\" ($cfg.GuestLabPath.TrimStart('\')) + foreach ($item in '7zip','pwsh','Pester','provision.ps1') { + $src = Join-Path $payloadSrc $item + $dst = Join-Path $guestLab ('payload\' + $item) + if (Test-Path -LiteralPath $src) { + $null = robocopy $src $dst /MIR /NFL /NDL /NJH /NJS /NP /R:1 /W:1 + } else { + Write-LabLog "负载缺失(跳过):$src" 'WARN' + } + } + + # 口令:随机生成,只留在宿主机 D:\VMs\BakNRet-Lab\state\credentials.json + $password = New-LabPassword + $credPath = Save-LabCredential -Password $password + Write-LabLog "已生成 VM 凭据($credPath)" 'STEP' + + $unattendSrc = Get-Content -LiteralPath (Join-Path $payloadSrc 'unattend.xml') -Raw -Encoding UTF8 + $unattendXml = $unattendSrc.Replace('__LABPASSWORD__', $password) + $panther = Join-Path "$winLetter`:\" 'Windows\Panther' + New-Item -ItemType Directory -Force -Path $panther | Out-Null + [System.IO.File]::WriteAllText((Join-Path $panther 'unattend.xml'), $unattendXml, [System.Text.UTF8Encoding]::new($true)) + Write-LabLog "已写入 $panther\unattend.xml" 'STEP' + + # ---- 引导 ---- + Write-LabLog 'bcdboot 写 UEFI 引导' 'STEP' + & bcdboot.exe "$winLetter`:\Windows" /s "$efiLetter`:" /f UEFI | ForEach-Object { Write-LabLog " $_" } + if ($LASTEXITCODE -ne 0) { throw "bcdboot 失败,退出码 $LASTEXITCODE" } + $bootMgr = Join-Path "$efiLetter`:\" 'EFI\Microsoft\Boot\bootmgfw.efi' + if (-not (Test-Path -LiteralPath $bootMgr)) { throw "ESP 上没有 bootmgfw.efi:$bootMgr" } + Write-LabLog "引导文件就位:$bootMgr" 'STEP' + + Dismount-VHD -Path $cfg.VhdxPath + Write-LabLog '系统盘已完成并卸载' 'STEP' +} + +# --------------------------------------------------------------------------- +# 2. 虚拟机 +# --------------------------------------------------------------------------- + +function New-LabVM { + Assert-LabElevated -Why '创建/配置 Hyper-V 虚拟机' + $vm = Get-LabVm + if (-not $vm) { + Write-LabLog "创建虚拟机 $($cfg.VmName)(Gen2 / $($cfg.MemoryStartupGB) GB / $($cfg.CpuCount) vCPU)" 'STEP' + $vm = New-VM -Name $cfg.VmName -Generation 2 -MemoryStartupBytes ($cfg.MemoryStartupGB * 1GB) ` + -VHDPath $cfg.VhdxPath -SwitchName $cfg.SwitchName + Set-VMProcessor -VMName $cfg.VmName -Count $cfg.CpuCount + Set-VMFirmware -VMName $cfg.VmName -EnableSecureBoot Off + Set-VM -Name $cfg.VmName -AutomaticStopAction TurnOff -AutomaticStartAction Nothing + # 集成服务名随系统语言变化(中文是「来宾服务接口」),按状态启用而不是按名字找 + Get-VMIntegrationService -VMName $cfg.VmName | Where-Object { -not $_.Enabled } | + ForEach-Object { Enable-VMIntegrationService -VMName $cfg.VmName -Name $_.Name } + } else { + Write-LabLog "虚拟机 $($cfg.VmName) 已存在(状态 $($vm.State))" 'WARN' + if (-not (Get-VMHardDiskDrive -VMName $cfg.VmName | Where-Object Path -eq $cfg.VhdxPath)) { + Add-VMHardDiskDrive -VMName $cfg.VmName -Path $cfg.VhdxPath + } + } + $vm = Get-LabVm + if ($vm.State -ne 'Running') { + Write-LabLog '启动虚拟机' 'STEP' + Start-VM -Name $cfg.VmName + if (-not (Wait-LabVMRunning -TimeoutSeconds 180)) { throw '虚拟机没有进入 Running' } + } + Write-LabLog "虚拟机状态:$((Get-LabVm).State)" 'STEP' +} + +# --------------------------------------------------------------------------- +# 3. 供给与检查点 +# --------------------------------------------------------------------------- + +function Wait-LabProvision { + Assert-LabElevated -Why 'PowerShell Direct 需要管理员' + Write-LabLog '等待 VM 内供给脚本完成(首次启动要几分钟)' 'STEP' + $sw = [Diagnostics.Stopwatch]::StartNew() + while ($sw.Elapsed.TotalMinutes -lt 30) { + if (Test-LabGuestReady) { + Write-LabLog "供给完成,耗时 $([math]::Round($sw.Elapsed.TotalMinutes,1)) 分钟" 'STEP' + $facts = Invoke-LabCommand -ScriptBlock { Get-Content 'C:\BakNRet-Lab\state\provisioned.json' -Raw } + Write-Host $facts + return + } + Start-Sleep -Seconds 15 + } + throw '等待供给超时(30 分钟);用 Lab.ps1 -Verb console 到 VM 里看一眼 provision.log' +} + +function New-LabCheckpoint { + Assert-LabElevated -Why '创建 Hyper-V 检查点' + $existing = Get-VMSnapshot -VMName $cfg.VmName -ErrorAction SilentlyContinue | Where-Object Name -eq $cfg.CheckpointName + if ($existing) { Write-LabLog "检查点 $($cfg.CheckpointName) 已存在,跳过" 'WARN'; return } + Checkpoint-VM -Name $cfg.VmName -SnapshotName $cfg.CheckpointName + Write-LabLog "已创建检查点 $($cfg.CheckpointName)(Lab.ps1 -Verb reset 可回到此状态)" 'STEP' +} + +# --------------------------------------------------------------------------- +# 主流程 +# --------------------------------------------------------------------------- + +if ($Stage -in @('all','disk')) { New-LabSystemDisk } +if ($Stage -in @('all','vm')) { New-LabVM } +if ($Stage -in @('all','provision')) { Wait-LabProvision; New-LabCheckpoint } + +Write-LabLog '搭建流程结束' 'STEP' \ No newline at end of file diff --git a/tools/lab/README.md b/tools/lab/README.md new file mode 100644 index 0000000..a703c3e --- /dev/null +++ b/tools/lab/README.md @@ -0,0 +1,205 @@ +# tools\lab —— BakNRet 的隔离测试环境(Hyper-V 真机级 VM) + +在**宿主机之外的 Windows 虚拟机**里跑 BakNRet 的备份 / 恢复 / 测试。宿主机仓库、`Backups\`、 +`logs\` 在本环境里只被读取,从不写入;VM 内也没有挂载宿主机的任何目录(一切交互走 +PowerShell Direct,也就是 VMBus,不需要网络共享)。 + +``` +宿主机 隔离 VM(BakNRet-Lab) +────────────────────────────── ───────────────────────────────────────── +D:\Workspace\Temp\BakNRet ← 仓库(只读) ──sync──▶ C:\BakNRet 仓库副本(每次覆盖) +D:\VMs\BakNRet-Lab C:\BakNRet-Lab 工具负载 + 沙盒 + 日志 + ├─ vhdx\BakNRet-Lab.vhdx 系统盘 ├─ payload\ 7-Zip 26.03 / pwsh 7 / Pester 5.9.1 + ├─ state\credentials.json lab 口令 ├─ sources\ 带刺的假数据(见下) + ├─ logs\ 全流程日志 ├─ Backups\ 沙盒归档 + manifest.json + └─ stage\repo.zip 仓库快照 └─ logs\ 脚本日志与重定向输出 +``` + +## 为什么用它 + +真机语义是单元测试造不出来的。这套环境里能真正跑到: + +| 形态 | 说明 | +| --- | --- | +| 真 NTFS 连接点(junction) | `sources\JunctionToData` 指向 `AppMultiSlot\Data`;真实源目录里不该造这种东西,VM 内的沙盒源可以随便折腾 | +| 被占用文件 | `AppLocked\locked.bin` 由后台进程持句柄,用来压「有文件没打进归档」的告警路径 | +| 长路径 / 深目录 | 10 层嵌套、112 字符路径 | +| 中文 + 空格 + 点的路径 | `sources\软件 目录.甲`,归档名同样是中文 | +| 多 Slot / 单文件 Slot | 一个软件多个 Slot(`\<内容>`)与文件 Slot(包内是名为 Slot 的文件) | +| 排除与追加 | `:-` 的 Slot 前缀形式与 `!` 任意层级形式;`:+ Modules:<路径>` 追加映射 | +| 覆盖 Path | 清单里的 `:: <路径>` 覆盖名录里故意写错的 Path | +| 源不存在的条目 | 记 `missing-source`、退出码仍为 0 | +| 方向标记 | 行首 `+`(仅备份)与 `-`(仅恢复) | +| 增量判断 | 第二次备份对未变更的源报「源目录未更新」并跳过,`-Force` 强制重打 | +| 计划任务 / 重启持久性 | 真机环境,可注册计划任务、可重启后继续验证 | + +## 搭建 + +前提:Windows 10/11 专业版或更高(需要 Hyper-V)、管理员权限、一个 Windows 安装 ISO。 +默认读 `F:\Images\Windows\Win11_25H2_Chinese_Simplified_x64_v2.iso`(可在 `Lab-Common.ps1` +的 `$LabConfig` 里改)。 + +```powershell +# 0. 先看 ISO 里有哪些版本(记住要装的索引,默认 4 = 专业版) +gsudo pwsh -NoProfile -File .\tools\lab\New-BakNRetLab.ps1 -ListImages + +# 1. 一次搭完:建 VHDX -> 分区 -> DISM 展开 -> 注入负载与无人值守文件 -> bcdboot +# -> 建 VM -> 首启无人值守 -> 等供给完成 -> 打 clean-baseline 检查点 +gsudo pwsh -NoProfile -File .\tools\lab\New-BakNRetLab.ps1 +``` + +全程**不需要点任何安装向导**,也不需要 VM 的图形界面:Windows 是用 DISM 离线展开进 VHDX 的, +首次启动由 `payload\unattend.xml`(放进 `C:\Windows\Panther\`)无人值守走完 specialize + OOBE, +再由 `payload\provision.ps1` 把 7-Zip / PowerShell 7 / Pester 装好并写上 PATH。 + +分阶段重跑(排查用):`-Stage disk` / `-Stage vm` / `-Stage provision`。 + +VM 规格:Gen2、8 vCPU、12 GB 静态内存、Default Switch(NAT,可联网)、80 GB 动态 VHDX +(实际占用约 15 GB,另有检查点差异盘)。lab 账户口令随机生成,只写在 +`D:\VMs\BakNRet-Lab\state\credentials.json`(仓库之外),不进程版本库。 + +## 日常使用 + +```powershell +gsudo pwsh -NoProfile -File .\tools\lab\Lab.ps1 status # 一眼看状态 +gsudo pwsh -NoProfile -File .\tools\lab\Lab.ps1 sync # 把当前工作树推给 VM +gsudo pwsh -NoProfile -File .\tools\lab\Lab.ps1 seed -Force # 重建带刺假数据 +gsudo pwsh -NoProfile -File .\tools\lab\Lab.ps1 backup # VM 内真跑 Backup.ps1(沙盒清单+配置) +gsudo pwsh -NoProfile -File .\tools\lab\Lab.ps1 restore # 用真实归档做恢复演练(逐字节对拍) +gsudo pwsh -NoProfile -File .\tools\lab\Lab.ps1 acl-test # 安全描述符演练(scoop/vscode + ProgramData 属主) +gsudo pwsh -NoProfile -File .\tools\lab\Lab.ps1 acl-test -SkipScoop # 只跑 ProgramData 那段(不下载 vscode) +gsudo pwsh -NoProfile -File .\tools\lab\Lab.ps1 test -Suite all # 三套仓库自带测试 +gsudo pwsh -NoProfile -File .\tools\lab\Lab.ps1 shell # 进去自己敲(exit 出来) +gsudo pwsh -NoProfile -File .\tools\lab\Lab.ps1 reset # 秒回 clean-baseline +``` + +动词一览:`status` / `start` / `stop` / `wait` / `sync` / `seed` / `backup` / `restore` / +`acl-test` / `test` / `shell` / `console` / `checkpoint` / `reset` / `destroy`。 + +`backup` 支持 `-DryRun` / `-Force` / `-AcceptWarnings`;`restore` 支持 +`-Entries @('AppMultiSlot','C:\BakNRet-Lab\sources\AppBig')` 指定条目;`test` 支持 +`-Suite pester|zero|e2e`;`acl-test` 支持 `-SkipScoop` / `-KeepWork`。 + +## acl-test:安全描述符演练(`payload\run-acl-scenario.ps1`) + +两段,都在 VM 里真跑(不是模拟),宿主侧退出码由动词 `throw` 回传: + +- **A. 用户级真实场景**:默认方式装 scoop(提权会话按官方写法加 `-RunAsAdmin`,目录仍是 + `%USERPROFILE%\scoop`)→ `scoop install git` → `bucket add extras` → `scoop install vscode` + → 改 vscode 的 `settings.json` → 备份 → 删源 → 恢复 → 断言:CLI 仍可执行、改过的配置原样 + 读得回、数据目录可写、app/persist 的安全指纹与备份前一致。 + 两个实测坑写在脚本注释里:extras 的 vscode 清单**没有 `bin` 条目**(所以没有 `shims\code.cmd`, + CLI 在 `apps\vscode\current\bin\code.cmd`);`code --version` 拉起的 `Code.exe` 会锁住文件, + 删源前必须先清进程。 +- **B. 权限现场**:`C:\ProgramData\baknret-acl-lab\data`,属主设成 **SYSTEM**、DACL 是 + `protected` 且只有 `(A;OICIIO;GA;;;CO)` + SYSTEM/Administrators/Users —— 就是 ProgramData + 下那些目录的形态。备份 / 删源 / 恢复后断言:**属主仍是 SYSTEM**、`CREATOR OWNER` 的 + inherit-only ACE 还在、逐对象安全指纹与备份前一致;外加一条**负对照**(只搬文件、不回放 + 安全描述符)证明属主会落到"跑脚本的账户"头上。 + +## 沙盒清单 / 名录 / 配置 + +三个文件都在 `tools\lab\payload\sandbox\`,随 `sync` 进 VM: + +- `BackupList.txt` —— 沙盒清单,覆盖上面表里的各种形态; +- `SoftwareCatalog.psd1` —— 软件名 → Slot 组,全部指向 `C:\BakNRet-Lab\sources`; +- `BackupConfig.psd1` —— 归档/日志/快照都落在 VM 内(`C:\BakNRet-Lab\Backups`), + 压缩级别 1(跑得快),`ComputeHash = $true`(方便对拍)。 + +## 踩过的坑(照抄会踩) + +1. **`SoftwareCatalog` 的相对路径是按仓库根解析的**,不是按配置文件所在目录;而且路径 + **不存在时会静默回退**到仓库真实的 `SoftwareCatalog.psd1`。沙盒配置里必须写成仓库根 + 相对路径(`tools\lab\payload\sandbox\SoftwareCatalog.psd1`),否则软件名条目会悄悄用错名录。 +2. **子进程被重定向的 stdout 是控制台代码页**(中文 Windows 上是 GBK/936),按 UTF-8 读会 + 整片乱码;`Lab.ps1` 因此按「替换字符更少」的候选解码。脚本自己写的 + `logs\backup\backup-*.log` 反而是 UTF-8。 +3. **`ConvertFrom-Json` 把 JSON 数组当作一个对象写出**,`@(...)` 会套成嵌套数组;数组参数 + 经 `Invoke-Command -ArgumentList` 传到 VM 里再交给 `Start-Process -ArgumentList` 会报 + 「无法转换为 System.String」。`Lab.ps1` 用 JSON 传参 + 显式枚举摊平。 +4. **Hyper-V 对新建 VM 默认开自动检查点**,会不断堆叠差异盘。`New-BakNRetLab.ps1` 已关掉 + (`AutomaticCheckpointsEnabled = $false`)。 +5. **中文 Windows 上集成服务名是本地的**(「来宾服务接口」而不是 `Guest Service Interface`), + 按名字启用会找不到;脚本改为「把所有未启用的集成服务启用」。 +6. **全新 Gen2 VM 的 NVRAM 是空的**,固件会走 UEFI 回退路径 `\EFI\Boot\bootx64.efi`。 + `bcdboot /f UEFI` 通常会写它;没写时脚本会从 `bootmgfw.efi` 补一份。 +7. **`New-Partition -Size` 建出来的是普通数据分区**,不是 ESP;要按 UEFI 规范用 + `-GptType '{c12a7328-f81f-11d2-ba4b-00a0c93ec93b}'` 建,事后再用 `Set-Partition -GptType` + 改类型可能被拒(尤其打错分区号时)。`Initialize-Disk` 还会自带一个 MSR 分区。 +8. **exFAT 卷上写不了硬链接**:DSH 的 write 工具用「临时目录 + 硬链接」做原子落盘,在 exFAT 上会 + 直接失败(EISDIR)。仓库已于 2026-09-26 迁到 NTFS(`D:\Workspace\Temp\BakNRet`),不再受影响; + 但 U 盘上的其它数据仍受此限制 —— 改那里的文件要么用 shell 重定向,要么先写 NTFS 再拷。 +9. VM 是**未激活**的 Windows:会有水印,个性化受限,功能测试不受影响。 +10. **PowerShell Direct 的默认端点是 Windows PowerShell 5.1**(不是 7)。要在 VM 里跑 7 的代码 + 必须显式 `Start-Process pwsh.exe`(`Lab.ps1` 就是这么做的)。5.1 还读不了仓库里无 BOM 的 + UTF-8 脚本(见下「已知问题」),`Import-Module C:\BakNRet\Common.psm1` 会报一串「缺少右 }」。 + +## 已知问题与规避 + +### 在 VM 里直接跑 `tests\Run-Pester.ps1` 会红 8 项(都是中文断言) + +`tests\BakNRet*.Tests.ps1` 里的 `Invoke-BaknretScript` 这样抓子进程输出: + +``` +cmd /c pwsh -File Backup.ps1 ... > out.txt 2>&1 +Get-Content -LiteralPath out.txt -Encoding UTF8 +``` + +而 `Backup.ps1` / `Restore.ps1` 的 `Write-Log` 走 `Write-Host`,写进 `out.txt` 的**字节编码取自 +`[Console]::OutputEncoding`**: + +| 环境 | `[Console]::OutputEncoding` | 结果 | +| --- | --- | --- | +| 宿主机(日常会话) | `utf-8` | 文件是 UTF-8,按 UTF-8 读回正确 → 150/150 绿 | +| 全新 Windows VM(中文系统) | `gb2312`(936) | 文件是 GBK 字节,按 UTF-8 读回得到替换字符 → 8 项中文断言失败 | + +实测:VM 里直接跑是 `142 通过 / 8 失败`;把控制台输出编码先钉成 UTF-8 后是 `150/150`。 + +这是**测试环境的编码假设问题,不是产品缺陷**(产品行为在两边完全一致)。 +`Lab.ps1 test` 因此会经 `payload\run-suite-utf8.ps1` 运行套件,不需要改动仓库里的测试代码。 + +若要在仓库里根治(三选一): + +1. 生成的 `.cmd` 里先 `chcp 65001 >nul`; +2. 子进程改成 `pwsh -Command "[Console]::OutputEncoding=[Text.Encoding]::UTF8; & '<脚本>' <参数>"`; +3. 读回时按控制台代码页解码,而不是写死 `-Encoding UTF8`。 + +### 名录改了、源没变时:归档与 manifest 会不一致 + +实测路径(在 VM 里真实撞到过): + +1. 名录里某个条目的 Slot 定义变了(当时是把沙盒名录的路径修对之后); +2. 源目录一个字节没动; +3. 下一次 `Backup.ps1` 按「源未更新」跳过该条目 —— **归档保持旧内容**; +4. 但 manifest 的 `roots` / `layouts` 是按**当前**名录重新算的,于是它描述的内容比归档里实际有的多; +5. 恢复时才炸:`归档 AppFileSlot.7z 里既没有 'Profile',也没有旧布局的 '0'`。 + +报错是清楚的(不是静默错误),修复办法就是重打一次:`Lab.ps1 backup -Force` +(实测重打后 `Lab.ps1 restore` 立刻变成 6/6 逐字节对拍通过)。 + +如果希望产品层面自动发现,可以在「源未更新」的判断里带上「本次解析出的 roots/layouts 是否与 +manifest 记录的一致」,不一致就不要跳过。### 仓库里的 PowerShell 文件是「UTF-8 无 BOM」 + +`Backup.ps1` / `Common.psm1` 等都没有 BOM(开头字节是 `3C 23 0A` = `<#` + 换行)。 +PowerShell 7 默认按 UTF-8 读,没问题;**Windows PowerShell 5.1 会把无 BOM 文件按 ANSI(GBK) 读**, +中文注释会被拆出错字节,甚至报「语句块或类型定义中缺少右 }」这类假解析错误。 +要么给这些文件加 BOM,要么在文档里明确只支持 PowerShell 7。 + +### 仓库位置(2026-09-26 已从 U 盘迁到 NTFS) + +仓库原在 `F:\Backup\BakNRet`(exFAT 的 Ventoy U 盘),为了减少 U 盘读写、并且拿回 NTFS 的 +ACL / 硬链接支持,已整体搬到 **`D:\Workspace\Temp\BakNRet`**(NTFS,561 个文件 / 7.45 GB, +搬迁后做了逐文件 SHA256 对拍,全部一致)。 + +对这套 lab 没有影响:`Lab-Common.ps1` 用 `$PSScriptRoot` 推导 `RepoRoot`, +搬迁后实测自动指向新路径,脚本无需改动。唯一仍在 U 盘上的是默认安装 ISO +(`F:\Images\Windows\...`),只在重新 `-Stage disk` 时**只读**用一次;想彻底不读 U 盘, +把它复制一份到 D: 再改 `Lab-Common.ps1` 的 `IsoPath` 即可。 + +仓库在 NTFS 上还顺带修好了 git:原先 exFAT 不记录属主,git 报 `dubious ownership` 全部命令失败; +搬迁后 `git status` / `git log` 直接可用(不需要 `safe.directory` 白名单)。## 拆掉 + +```powershell +gsudo pwsh -NoProfile -File .\tools\lab\Lab.ps1 destroy -Confirm # 删 VM 与系统盘 +# 日志、凭据、仓库快照留在 D:\VMs\BakNRet-Lab 下,便于事后排查;确认不要了再手工删该目录 +``` \ No newline at end of file diff --git a/tools/lab/payload/lab-fixtures.ps1 b/tools/lab/payload/lab-fixtures.ps1 new file mode 100644 index 0000000..8eed45a --- /dev/null +++ b/tools/lab/payload/lab-fixtures.ps1 @@ -0,0 +1,126 @@ +<# +.SYNOPSIS + BakNRet 隔离沙盒的假数据生成器(在 VM 内运行)。 + +.DESCRIPTION + 在 C:\BakNRet-Lab\sources 下造出一批**故意带刺**的源目录,用来在真机语义下压测 + Backup.ps1 / Restore.ps1 —— 这些形态在宿主机上不敢随便试: + + * 多 Slot 软件目录(Data / Config / Cache 三个子目录,各自可带排除); + * 单文件 Slot(一个 .json 直接当一个 Slot); + * 中文 + 空格 + 点的路径名; + * **真 NTFS 连接点(junction)** —— exFAT 的仓库里造不出来; + * **被占用文件** —— 后台进程持有句柄,验证「有文件没打进归档」的告警路径; + * 长路径(接近 260 字符)与 10 层深目录; + * DefaultExcludes 命中的垃圾文件(Thumbs.db / desktop.ini)与 *.log; + * 空目录; + * 一个约 50 MB 的文件,让归档大小/空间预估有实际数字; + * 一个「源不存在」条目对应的目录(故意不建)。 + + 幂等:默认只在缺失时创建;-Force 会先删掉 sources 重建(删连接点用 rmdir,避免跟进目标)。 +#> + +[CmdletBinding()] +param( + [string]$Root = 'C:\BakNRet-Lab\sources', + [switch]$Force +) + +$ErrorActionPreference = 'Stop' + +function New-TextFile { + param([string]$Path, [string]$Content, [int]$Count = 1) + $dir = Split-Path -Parent $Path + if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null } + if ($Count -le 1) { + Set-Content -LiteralPath $Path -Value $Content -Encoding UTF8 + } else { + Set-Content -LiteralPath $Path -Value (1..$Count | ForEach-Object { "$Content #$_" }) -Encoding UTF8 + } +} + +if ($Force -and (Test-Path -LiteralPath $Root)) { + Write-Host "清除已有沙盒源:$Root" + Get-ChildItem -LiteralPath $Root -Recurse -Force -Directory -ErrorAction SilentlyContinue | + Where-Object { $_.Attributes -band [IO.FileAttributes]::ReparsePoint } | + ForEach-Object { cmd /c rmdir "$($_.FullName)" 2>$null } + Remove-Item -LiteralPath $Root -Recurse -Force +} +New-Item -ItemType Directory -Force -Path $Root | Out-Null + +# --- 1. 多 Slot 软件目录 ----------------------------------------------------- +$appA = Join-Path $Root 'AppMultiSlot' +New-TextFile (Join-Path $appA 'Data\settings.json') '{ "theme": "dark", "slots": 3 }' +New-TextFile (Join-Path $appA 'Data\nested\deep\payload.bin') 'binary-ish-payload' -Count 40 +New-TextFile (Join-Path $appA 'Config\app.ini') '[main]' +New-TextFile (Join-Path $appA 'Config\app.ini.bak') '[main] backup copy' +New-TextFile (Join-Path $appA 'Cache\cache-01.tmp') 'cache entry' -Count 20 +New-TextFile (Join-Path $appA 'Cache\Thumbs.db') 'junk that DefaultExcludes should drop' +New-TextFile (Join-Path $appA 'Cache\desktop.ini') 'junk that DefaultExcludes should drop' +New-TextFile (Join-Path $appA 'Data\session.log') 'log line that an exclusion should drop' -Count 10 +New-TextFile (Join-Path $appA 'Data\node_modules\pkg\index.js') 'module.exports = {}' +New-Item -ItemType Directory -Force -Path (Join-Path $appA 'Data\emptydir') | Out-Null + +# --- 2. 单文件 Slot ---------------------------------------------------------- +$appB = Join-Path $Root 'AppFileSlot' +New-TextFile (Join-Path $appB 'profile.json') '{ "name": "file-slot", "single": true }' +New-TextFile (Join-Path $appB 'readme.txt') 'file slot 的侧车说明' + +# --- 3. 中文 + 空格 + 点的路径 ---------------------------------------------- +$appC = Join-Path $Root '软件 目录.甲' +New-TextFile (Join-Path $appC '设置\配置 文件.ini') '中文路径内容' +New-TextFile (Join-Path $appC '数据 备份\记录.txt') '记录内容' -Count 5 + +# --- 4. 真 NTFS 连接点 ------------------------------------------------------- +$realTarget = Join-Path $Root 'AppMultiSlot\Data' +$junction = Join-Path $Root 'JunctionToData' +if (-not (Test-Path -LiteralPath $junction)) { + $null = New-Item -ItemType Junction -Path $junction -Target $realTarget -ErrorAction SilentlyContinue +} +if (Test-Path -LiteralPath $junction) { Write-Host "连接点已建:$junction -> $realTarget" } + +# --- 5. 长路径与深目录 ------------------------------------------------------- +$cursor = Join-Path $Root 'AppDeep' +1..10 | ForEach-Object { $cursor = Join-Path $cursor "level$_" } +New-TextFile (Join-Path $cursor 'bottom.txt') 'deep content' +Write-Host ("最长路径长度:{0} 字符" -f (Join-Path $cursor 'bottom.txt').Length) + +# --- 6. 50 MB 大文件 --------------------------------------------------------- +$bigDir = Join-Path $Root 'AppBig' +$bigFile = Join-Path $bigDir 'blob-50mb.bin' +if (-not (Test-Path -LiteralPath $bigFile)) { + New-Item -ItemType Directory -Force -Path $bigDir | Out-Null + $fs = [IO.File]::Create($bigFile) + try { + $rng = [Random]::new(20260926) + $chunk = [byte[]]::new(1MB) + for ($i = 0; $i -lt 50; $i++) { $rng.NextBytes($chunk); $fs.Write($chunk, 0, $chunk.Length) } + } finally { $fs.Dispose() } +} +Write-Host ("大文件:{0} MB" -f [math]::Round((Get-Item $bigFile).Length/1MB,1)) + +# --- 7. 被占用文件(后台进程持句柄 90 秒后释放)----------------------------- +$lockDir = Join-Path $Root 'AppLocked' +$lockFile = Join-Path $lockDir 'locked.bin' +New-Item -ItemType Directory -Force -Path $lockDir | Out-Null +New-TextFile $lockFile 'this file is held open by another process' +$holderLines = @( + '$path = $args[0]' + '$fs = [IO.File]::Open($path, ''Open'', ''ReadWrite'', ''None'')' + 'try { Start-Sleep -Seconds 90 } finally { $fs.Dispose() }' +) +$holderPath = 'C:\BakNRet-Lab\state\hold-lock.ps1' +Set-Content -LiteralPath $holderPath -Value $holderLines -Encoding UTF8 +Start-Process -FilePath 'powershell.exe' -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File',$holderPath,$lockFile) -WindowStyle Hidden +Write-Host "已启动占用者进程(持句柄 90 秒):$lockFile" + +# --- 8. 「源不存在」条目对应的目录:故意不建 --------------------------------- +Write-Host '故意不创建 MissingApp(用于验证源缺失只跳过、不失败)' + +Write-Host '' +Write-Host '--- 沙盒源清单 ---' +Get-ChildItem -LiteralPath $Root -Force | ForEach-Object { + $files = @(Get-ChildItem -LiteralPath $_.FullName -Recurse -File -Force -ErrorAction SilentlyContinue) + $mb = [math]::Round((($files | Measure-Object Length -Sum).Sum) / 1MB, 2) + " {0,-24} {1,4} 个文件 {2,8} MB 连接点={3}" -f $_.Name, $files.Count, $mb, [bool]($_.Attributes -band [IO.FileAttributes]::ReparsePoint) +} diff --git a/tools/lab/payload/provision.ps1 b/tools/lab/payload/provision.ps1 new file mode 100644 index 0000000..7a772ab --- /dev/null +++ b/tools/lab/payload/provision.ps1 @@ -0,0 +1,118 @@ +<# +.SYNOPSIS + BakNRet 隔离测试 VM 的首次登录供给脚本(由 unattend.xml 的 FirstLogonCommands 调用)。 + +.DESCRIPTION + 运行环境是 VM 内全新安装的 Windows 11(Windows PowerShell 5.1、管理员 lab 账户)。 + 目标:把 VM 变成「可以直接跑 BakNRet 全链路测试」的真机状态: + + 1. 电源 / 休眠 / 锁屏:测试期间不要因为空闲睡下去; + 2. 执行策略 Bypass(仅此实验 VM); + 3. 把注入的 7-Zip 与 PowerShell 7 放到机器 PATH 上,与宿主机的工具版本对齐; + 4. 安装 Pester 5.9.1(Windows PowerShell 与 pwsh 两侧都能导入); + 5. 不让 Windows Update 自动重启,并给实验目录加 Defender 排除 + (避免杀软把「文件被占用」类用例变成随机失败,同时显著拖慢压缩); + 6. 关掉首次登录后的 SCOOBE「完成设备设置」向导; + 7. 写出 state\provisioned.json 与 state\provision.ok 作为「供给完成」的硬凭据。 + + 幂等:可重复执行,第二次跑不会失败。 +#> + +$ErrorActionPreference = 'Continue' +$ProgressPreference = 'SilentlyContinue' + +$lab = 'C:\BakNRet-Lab' +$logDir = Join-Path $lab 'logs' +$stateDir = Join-Path $lab 'state' +New-Item -ItemType Directory -Force -Path $logDir, $stateDir | Out-Null + +Start-Transcript -Path (Join-Path $logDir 'provision.log') -Force | Out-Null +function Step($m) { Write-Host "==> $m" } + +try { + Step '1/7 电源与显示:不休眠、不锁屏、关休眠' + powercfg /change standby-timeout-ac 0 | Out-Null + powercfg /change monitor-timeout-ac 0 | Out-Null + powercfg /change hibernate-timeout-ac 0 | Out-Null + powercfg /hibernate off | Out-Null + + Step '2/7 执行策略:LocalMachine = Bypass(仅此实验 VM)' + Set-ExecutionPolicy -Scope LocalMachine -ExecutionPolicy Bypass -Force + + Step '3/7 工具上机器 PATH:7-Zip 与 PowerShell 7' + $zipSrc = Join-Path $lab 'payload\7zip' + $zipDst = 'C:\Program Files\7-Zip' + $pwshSrc = Join-Path $lab 'payload\pwsh' + $pwshDst = 'C:\Program Files\PowerShell\7' + if (-not (Test-Path $zipDst)) { robocopy $zipSrc $zipDst /MIR /NFL /NDL /NJH /NJS /NP | Out-Null } + if (-not (Test-Path $pwshDst)) { robocopy $pwshSrc $pwshDst /MIR /NFL /NDL /NJH /NJS /NP | Out-Null } + + $machinePath = [Environment]::GetEnvironmentVariable('Path', 'Machine') + foreach ($p in @($zipDst, $pwshDst)) { + if ($machinePath -notlike "*$p*") { $machinePath = $machinePath.TrimEnd(';') + ';' + $p } + if ($env:Path -notlike "*$p*") { $env:Path = $env:Path.TrimEnd(';') + ';' + $p } + } + [Environment]::SetEnvironmentVariable('Path', $machinePath, 'Machine') + + Step '4/7 安装 Pester 5.9.1(Windows PowerShell 与 pwsh 各一份)' + $pesterSrc = Join-Path $lab 'payload\Pester\5.9.1' + foreach ($dst in @("$env:ProgramFiles\WindowsPowerShell\Modules\Pester\5.9.1", + "$env:ProgramFiles\PowerShell\Modules\Pester\5.9.1")) { + if (-not (Test-Path $dst)) { robocopy $pesterSrc $dst /MIR /NFL /NDL /NJH /NJS /NP | Out-Null } + } + + Step '5/7 Windows Update 不自动重启 + Defender 排除实验目录' + $wu = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU' + New-Item -Path $wu -Force | Out-Null + New-ItemProperty -Path $wu -Name 'NoAutoRebootWithLoggedOnUsers' -Value 1 -PropertyType DWord -Force | Out-Null + New-ItemProperty -Path $wu -Name 'AUOptions' -Value 2 -PropertyType DWord -Force | Out-Null + New-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Power' -Name 'HiberbootEnabled' -Value 0 -PropertyType DWord -Force | Out-Null + Add-MpPreference -ExclusionPath 'C:\BakNRet', 'C:\BakNRet-Lab' -ErrorAction SilentlyContinue + + Step '6/7 关掉 SCOOBE「完成设备设置」' + $scoobe = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\UserProfileEngagement' + New-Item -Path $scoobe -Force | Out-Null + New-ItemProperty -Path $scoobe -Name 'ScoobeSystemSettingEnabled' -Value 0 -PropertyType DWord -Force | Out-Null + + Step '7/7 采集真机事实并落盘' + $zipExe = Join-Path $zipDst '7z.exe' + $pwshExe = Join-Path $pwshDst 'pwsh.exe' + $pwshVer = '缺失' + if (Test-Path $pwshExe) { $pwshVer = (& $pwshExe -NoProfile -Command '$PSVersionTable.PSVersion.ToString()' 2>&1) -join ' ' } + $zipVer = '缺失' + if (Test-Path $zipExe) { $zipVer = (& $zipExe 2>&1 | Select-Object -First 2) -join ' / ' } + + $facts = [ordered]@{ + ProvisionedAt = (Get-Date).ToString('s') + ComputerName = $env:COMPUTERNAME + User = (whoami) + IsAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + OsCaption = (Get-CimInstance Win32_OperatingSystem).Caption + OsVersion = (Get-CimInstance Win32_OperatingSystem).Version + OsBuild = (Get-CimInstance Win32_OperatingSystem).BuildNumber + OsArch = (Get-CimInstance Win32_OperatingSystem).OSArchitecture + WindowsPS = $PSVersionTable.PSVersion.ToString() + SevenZipVersion = $zipVer + PwshVersion = $pwshVer + PesterVersion = (Get-Module -ListAvailable Pester -ErrorAction SilentlyContinue | Select-Object -First 1).Version.ToString() + PathHasSevenZip = ([Environment]::GetEnvironmentVariable('Path', 'Machine')) -like '*7-Zip*' + PathHasPwsh = ([Environment]::GetEnvironmentVariable('Path', 'Machine')) -like '*PowerShell\7*' + CpuCount = (Get-CimInstance Win32_ComputerSystem).NumberOfLogicalProcessors + RamGB = [math]::Round((Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory / 1GB, 1) + Drives = @(Get-Volume | Where-Object DriveLetter | ForEach-Object { + [ordered]@{ Letter = "$($_.DriveLetter):"; Fs = $_.FileSystemType; SizeGB = [math]::Round($_.Size / 1GB, 1) } + }) + } + $facts | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath (Join-Path $stateDir 'provisioned.json') -Encoding UTF8 + $facts.GetEnumerator() | ForEach-Object { Write-Host (" {0,-16} {1}" -f $_.Key, $_.Value) } + + 'provision-ok' | Set-Content -LiteralPath (Join-Path $stateDir 'provision.ok') -Encoding ASCII + Write-Host '==> 供给完成' +} +catch { + Write-Host ("供给失败:" + $_.Exception.Message) -ForegroundColor Red + ("provision-FAILED: " + $_.Exception.Message) | Set-Content -LiteralPath (Join-Path $stateDir 'provision.FAILED') -Encoding UTF8 +} +finally { + Stop-Transcript | Out-Null +} \ No newline at end of file diff --git a/tools/lab/payload/run-acl-scenario.ps1 b/tools/lab/payload/run-acl-scenario.ps1 new file mode 100644 index 0000000..808b3cd --- /dev/null +++ b/tools/lab/payload/run-acl-scenario.ps1 @@ -0,0 +1,491 @@ +<# +.SYNOPSIS + BakNRet 的安全描述符(属主 / ACL)场景演练 —— 在 lab 虚拟机内运行。 + +.DESCRIPTION + 两段,都是"真跑",不是模拟: + + A. 用户级真实场景(上报的那条链路): + 默认方式装 scoop → `scoop install vscode` → 打开 vscode 改配置 + → 备份 → 删源 → 恢复 → 断言 vscode 还能读能写、安全描述符与备份前一致。 + + B. 权限现场(C:\ProgramData 那种形态): + 一个"属主**不是**当前账户 + CREATOR OWNER(inherit-only) + DACL protected"的 + 目录,备份 / 删源 / 恢复之后: + * 属主必须仍是原账户 —— CREATOR OWNER(S-1-3-0)不是账户,是访问检查时 + 才替换的占位符,替换成"被检查对象的属主"。属主一旦变成跑恢复脚本的账户, + 那条 (A;OICIIO;GA;;;CO) 就把全权判给了脚本,原程序(服务账户)反而没了权限; + * 负对照:只搬文件、不回放安全描述符时,属主确实会落到当前账户头上 —— + 也就是"不修就是什么样"。 + +.NOTES + 由 Lab.ps1 的 acl-test 动词经 Invoke-GuestScriptFile 用 pwsh.exe 调起(PowerShell + Direct 的默认端点还是 5.1,读不了仓库里无 BOM 的 UTF-8 脚本,必须显式起 7)。 + 参数只传字符串,数组用 ';' 拼 —— 与 run-drill.ps1 同一套约定。 +#> +[CmdletBinding()] +param( + [string]$RepoPath = 'C:\BakNRet', + [string]$WorkRoot = 'C:\BakNRet-Lab\acl', + [switch]$SkipScoop, + [switch]$KeepWorkRoot +) + +$ErrorActionPreference = 'Stop' + +# 与 run-suite-utf8.ps1 同理:把控制台编码钉成 UTF-8,中文断言输出才不乱 +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +[Console]::InputEncoding = [System.Text.Encoding]::UTF8 +$OutputEncoding = [System.Text.Encoding]::UTF8 + +Import-Module (Join-Path $RepoPath 'Common.psm1') -Force + +$script:Passed = 0 +$script:Failures = @() + +function Test-Scenario { + param([Parameter(Mandatory = $true)][string]$Name, [bool]$Ok, [string]$Detail = '') + if ($Ok) { + $script:Passed++ + Write-Host (" [PASS] {0}" -f $Name) -ForegroundColor Green + } else { + $script:Failures += $Name + Write-Host (" [FAIL] {0}{1}" -f $Name, $(if ($Detail) { ' —— ' + $Detail } else { '' })) -ForegroundColor Red + } +} + +function Get-SecurityFingerprint { + <# + .SYNOPSIS + 属主 | 属组 | protected | 全部 ACE 的 类型|SID|掩码(排序)。 + .NOTES + 刻意不含继承标志位与 ID 标志:继承到文件子对象时容器继承位会被系统去掉, + 而 ID 标志写不回去(不是可写的输入),两者都不影响有效权限。 + #> + param([Parameter(Mandatory = $true)][string]$Path) + + $acl = Get-Acl -LiteralPath $Path + $sid = [System.Security.Principal.SecurityIdentifier] + $aces = @($acl.GetAccessRules($true, $true, $sid) | + ForEach-Object { '{0}|{1}|{2}' -f $_.AccessControlType, $_.IdentityReference.Value, [int]$_.FileSystemRights } | + Sort-Object) + return ('O={0} G={1} P={2} [{3}]' -f $acl.GetOwner($sid).Value, $acl.GetGroup($sid).Value, $acl.AreAccessRulesProtected, ($aces -join ' ')) +} + +function Invoke-BaknretChild { + <# + .SYNOPSIS + 用独立进程跑 Backup.ps1 / Restore.ps1(两个脚本结尾都会 exit)。 + .NOTES + 输出重定向到文件再读回:不经过 PowerShell 的管道。 + #> + param( + [Parameter(Mandatory = $true)][string]$Script, + [Parameter(Mandatory = $true)][hashtable]$Parameters + ) + + $arguments = @('-NoProfile', '-NonInteractive', '-File', $Script) + foreach ($name in ($Parameters.Keys | Sort-Object)) { + $value = $Parameters[$name] + if ($value -is [bool]) { + if ($value) { $arguments += "-$name" } + continue + } + $arguments += "-$name" + if ($value -is [array]) { $arguments += $value } else { $arguments += [string]$value } + } + + $outFile = Join-Path $WorkRoot ('out-' + [guid]::NewGuid().ToString('N').Substring(0, 6) + '.txt') + $process = Start-Process -FilePath 'pwsh.exe' -ArgumentList $arguments -NoNewWindow -Wait -PassThru ` + -RedirectStandardOutput $outFile -RedirectStandardError "$outFile.err" + $lines = @(Get-Content -LiteralPath $outFile -Encoding UTF8 -ErrorAction SilentlyContinue) + + return [pscustomobject]@{ + ExitCode = $process.ExitCode + Lines = @($lines | ForEach-Object { [string]$_ }) + Output = (($lines | Out-String)) + LastLog = @($lines | Where-Object { $_ -match '\[(INFO|WARN|ERROR)\]' } | Select-Object -Last 6) + } +} + +function Stop-VscodeProcesses { + <# + .SYNOPSIS + 把 vscode 相关进程清掉。 + .NOTES + 不清理的后果是实测撞到的:`code --version` 会拉起 Code.exe,进程活着会把 + apps\vscode 下的文件锁住 —— 于是"删源"删不干净、恢复也写不进去, + 而且报错看起来像是权限问题(正是这个演练要避免的误判)。 + #> + param([string]$AppRoot) + + foreach ($name in 'Code', 'code', 'Code - Insiders') { + Get-Process -Name $name -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue + } + if ($AppRoot) { + foreach ($process in @(Get-Process -ErrorAction SilentlyContinue)) { + try { + $path = $process.Path + if ($path -and $path.StartsWith($AppRoot, [System.StringComparison]::OrdinalIgnoreCase)) { + Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue + } + } catch { } + } + } + Start-Sleep -Milliseconds 700 +} + +function Remove-TreeHard { + <# + .SYNOPSIS + 删掉一棵树,包括带刺的 DACL、只读属性和连接点。 + + .DESCRIPTION + 必须比 `Remove-Item -Recurse -Force` 更小心,实测撞到过两件事: + + 1) scoop 在版本目录里也建了 persist 连接点(`apps\vscode\1.139.1\data` + → `persist\vscode\data`)。把 `persist\vscode` 当独立条目删掉之后, + 那个连接点就成了**悬空连接点**:`Remove-Item -Recurse` 会跟进去 + (目标没了 → "对路径 data 的访问被拒绝"),7z 解压也会试图穿过它写 + (→ "Could not find a part of the path")。看起来像权限问题,其实是删除方式问题。 + 2) 带刺的 DACL(protected + 不给当前账户写权限)会让普通删除直接失败。 + + 所以:先把所有连接点摘掉(rmdir 只删链接本身),再用 `rmdir /s /q` 删树; + 还删不掉才 takeown / icacls /reset 之后再删。 + #> + param([Parameter(Mandatory = $true)][string]$Path) + if (-not (Test-Path -LiteralPath $Path)) { return } + + $links = @(Get-ChildItem -LiteralPath $Path -Recurse -Force -Directory -ErrorAction SilentlyContinue | + Where-Object { $_.Attributes -band [IO.FileAttributes]::ReparsePoint }) + foreach ($link in $links) { + & cmd.exe /c ('rmdir "{0}"' -f $link.FullName) 2>&1 | Out-Null + Remove-BaknretJunction -Path $link.FullName + } + + & cmd.exe /c ('rmdir /s /q "{0}"' -f $Path) 2>&1 | Out-Null + + if (Test-Path -LiteralPath $Path) { + # 只处理这一条路径:拿回属主 → 换成继承来的默认 ACL → 再删 + & takeown.exe /F $Path /R /D Y 2>&1 | Out-Null + & icacls.exe $Path /reset /T /C /Q 2>&1 | Out-Null + & cmd.exe /c ('rmdir /s /q "{0}"' -f $Path) 2>&1 | Out-Null + Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction SilentlyContinue + } +} + +# ============================================================================ +# 准备 +# ============================================================================ + +if (Test-Path -LiteralPath $WorkRoot) { + Get-ChildItem -LiteralPath $WorkRoot -Directory -ErrorAction SilentlyContinue | ForEach-Object { Remove-TreeHard -Path $_.FullName } +} else { + New-Item -ItemType Directory -Path $WorkRoot -Force | Out-Null +} +$BackupDir = Join-Path $WorkRoot 'backups' +New-Item -ItemType Directory -Path $BackupDir -Force | Out-Null + +$privileges = Enable-BaknretPrivilege -Name @('SeRestorePrivilege', 'SeBackupPrivilege') +if ($privileges.Missing.Count -gt 0) { + Write-Host ('[acl] 警告:{0} 不在令牌里 —— 属主无法恢复,B 段会失败(应以管理员/SYSTEM 运行)' -f ($privileges.Missing -join '、')) -ForegroundColor Yellow +} + +Write-Host '' +Write-Host '===== A. scoop 装的 vscode:备份 / 恢复后还能不能正常读写 =====' -ForegroundColor Cyan + +$scoopRoot = Join-Path $env:USERPROFILE 'scoop' +$scoopCmd = Join-Path $scoopRoot 'shims\scoop.cmd' +$vscodeApp = Join-Path $scoopRoot 'apps\vscode' +$vscodePersist = Join-Path $scoopRoot 'persist\vscode' + +# extras 里的 vscode 清单**没有 bin 条目**(实测确认),所以 scoop 根本不会生成 +# shims\code.cmd —— CLI 在应用目录里,`current` 是指向版本目录的 junction。 +# 两个位置都探,谁在就用谁。 +$vscodeCli = Join-Path $vscodeApp 'current\bin\code.cmd' +$vscodeCliShim = Join-Path $scoopRoot 'shims\code.cmd' +$codeCmd = $null + +if (-not $SkipScoop) { + if (-not (Test-Path -LiteralPath $scoopCmd)) { + # 官方安装器默认拒绝在管理员会话里安装(安全考虑)。PowerShell Direct 的会话是提权的, + # 所以按官方文档给管理员的写法加 -RunAsAdmin —— 目录仍然是默认的 %USERPROFILE%\scoop, + # 布局与普通用户装出来的完全一致(https://github.com/ScoopInstaller/Install#for-admin)。 + Write-Host '[A] 用官方默认方式安装 scoop(-RunAsAdmin;目录仍是 %USERPROFILE%\scoop)' -ForegroundColor Yellow + try { + Invoke-Expression "& {$(Invoke-RestMethod -Uri 'https://get.scoop.sh')} -RunAsAdmin" + Write-Host ('[A] 安装器退出码:{0}' -f $LASTEXITCODE) + } catch { + Test-Scenario 'A: 安装 scoop' $false $_.Exception.Message + } + } else { + Write-Host '[A] scoop 已存在,跳过安装' + } + + if (Test-Path -LiteralPath $scoopCmd) { + # VM 里没有 git,而 scoop 的 bucket add 是 git clone —— 直接把 main bucket 以 zip + # 形式放到位(scoop 只要求 buckets\main 下是清单文件,不关心它怎么来的)。 + $mainBucket = Join-Path $scoopRoot 'buckets\main' + # 判据用 buckets\main\bucket(真正放清单的地方):第一次失败的 bucket add 会留下 + # 一个**空**的 buckets\main,只看目录存在会把这种半成品当成"已就绪"。 + if (-not (Test-Path -LiteralPath (Join-Path $mainBucket 'bucket'))) { + Write-Host '[A] main bucket 不可用(VM 里没有 git):用 zip 放进去' -ForegroundColor Yellow + $bucketZip = Join-Path $env:TEMP 'bnr-main-bucket.zip' + $bucketDir = Join-Path $env:TEMP 'bnr-main-bucket' + Invoke-WebRequest -Uri 'https://github.com/ScoopInstaller/Main/archive/refs/heads/master.zip' -OutFile $bucketZip + Remove-Item -LiteralPath $bucketDir -Recurse -Force -ErrorAction SilentlyContinue + Expand-Archive -LiteralPath $bucketZip -DestinationPath $bucketDir -Force + New-Item -ItemType Directory -Path (Join-Path $scoopRoot 'buckets') -Force | Out-Null + Remove-Item -LiteralPath $mainBucket -Recurse -Force -ErrorAction SilentlyContinue + Move-Item -LiteralPath (Join-Path $bucketDir 'Main-master') -Destination $mainBucket + Write-Host (' 清单数:{0}' -f @(Get-ChildItem -LiteralPath (Join-Path $mainBucket 'bucket') -Filter '*.json' -ErrorAction SilentlyContinue).Count) + } + } + + # 装 git:之后 bucket 操作就是正常路径(scoop 的 bucket add 本质是 git clone)。 + # 这台 VM 出厂不带 git,所以 main bucket 只能先用 zip 兜底进来,git 再由此装上。 + if ((Test-Path -LiteralPath $scoopCmd) -and -not (Test-Path -LiteralPath (Join-Path $scoopRoot 'shims\git.exe'))) { + Write-Host '[A] scoop install git(后面 bucket add 要靠它)' -ForegroundColor Yellow + & $scoopCmd install git 2>&1 | ForEach-Object { ' ' + $_ } + } + + # vscode 在 extras bucket,不在 main 里 + if ((Test-Path -LiteralPath $scoopCmd) -and -not (Test-Path -LiteralPath (Join-Path $scoopRoot 'buckets\extras'))) { + Write-Host '[A] scoop bucket add extras' -ForegroundColor Yellow + & $scoopCmd bucket add extras 2>&1 | ForEach-Object { ' ' + $_ } + } + + if ((Test-Path -LiteralPath $scoopCmd) -and -not (Test-Path -LiteralPath $vscodeCli)) { + Write-Host '[A] scoop install vscode(从 extras 下载几百 MB,慢是正常的)' -ForegroundColor Yellow + & $scoopCmd install vscode 2>&1 | ForEach-Object { ' ' + $_ } + if (-not (Test-Path -LiteralPath $vscodeCli)) { + Write-Host '[A] 第一次没装上,重试一次(下载超时是常见原因)' -ForegroundColor Yellow + & $scoopCmd install vscode 2>&1 | ForEach-Object { ' ' + $_ } + } + } +} + +foreach ($candidate in @($vscodeCli, $vscodeCliShim)) { + if (Test-Path -LiteralPath $candidate) { $codeCmd = $candidate; break } +} +$vscodeReady = [bool]$codeCmd + +if ($vscodeReady) { + Test-Scenario 'A: vscode 已安装(找到 CLI)' $true $codeCmd +} elseif ($SkipScoop) { + Write-Host (' [SKIP] 按 -SkipScoop 跳过 vscode({0} 不存在)' -f $vscodeCli) -ForegroundColor Yellow +} else { + Test-Scenario 'A: vscode 已安装(找到 CLI)' $false $vscodeCli +} + +# 打开 vscode 改配置:先跑一次 CLI 让它初始化用户数据目录,再写一个可核对的设置 +$probe = 'baknret-' + [guid]::NewGuid().ToString('N').Substring(0, 8) +$settingsPath = $null +if ($vscodeReady) { + $versionText = (& $codeCmd --version 2>&1 | Out-String).Trim() + Test-Scenario 'A: vscode CLI 可执行(--version)' ($LASTEXITCODE -eq 0) ($versionText -split "`n" | Select-Object -First 1) + + # scoop 的 vscode 清单带 persist:data,用户数据落在 persist 里(portable 模式); + # 万一没有走 portable,退回 %APPDATA%\Code\User。 + $userDataDir = Join-Path $vscodePersist 'data\user-data\User' + if (-not (Test-Path -LiteralPath (Join-Path $vscodePersist 'data'))) { + $userDataDir = Join-Path $env:APPDATA 'Code\User' + } + New-Item -ItemType Directory -Path $userDataDir -Force | Out-Null + $settingsPath = Join-Path $userDataDir 'settings.json' + [System.IO.File]::WriteAllText($settingsPath, ('{{"baknret.probe":"{0}","editor.fontSize":14}}' -f $probe)) + Write-Host ('[A] 改过的配置:{0}' -f $settingsPath) +} + +Write-Host '' +Write-Host '===== B. ProgramData 现场:属主 + CREATOR OWNER =====' -ForegroundColor Cyan + +$bRoot = Join-Path $env:ProgramData 'baknret-acl-lab' +Remove-TreeHard -Path $bRoot +$bData = Join-Path $bRoot 'data' +New-Item -ItemType Directory -Path (Join-Path $bData 'sub') -Force | Out-Null +[System.IO.File]::WriteAllText((Join-Path $bData 'sub\a.txt'), 'acl payload') + +# 属主设成 **SYSTEM**(不是当前账户、也不是提权进程默认拿到的 Administrators): +# 这正是"CREATOR OWNER 会把全权判给谁"的关键,也是不做安全描述符恢复时必然丢掉的东西。 +# 注意 sections 必须带上 Owner —— 只传 Access 的话 SDDL 里的 O: 会被直接忽略, +# 于是属主还是"谁创建谁拥有",测试就退化成没有意义的形式。 +$specialSddl = 'O:S-1-5-18D:PAI(A;OICIIO;GA;;;CO)(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;0x1200a9;;;BU)' +$specialSecurity = New-Object System.Security.AccessControl.DirectorySecurity +$specialSecurity.SetSecurityDescriptorSddlForm($specialSddl, ( + [System.Security.AccessControl.AccessControlSections]::Owner -bor + [System.Security.AccessControl.AccessControlSections]::Access)) +[System.IO.FileSystemAclExtensions]::SetAccessControl((Get-Item -LiteralPath $bData), $specialSecurity) + +# "跑脚本的账户"新建对象时实际会拿到什么属主 —— 用它做基准,负对照才有判据 +$probeDir = Join-Path $WorkRoot 'owner-probe' +New-Item -ItemType Directory -Path $probeDir -Force | Out-Null +$creatorOwner = (Get-Acl -LiteralPath $probeDir).GetOwner([System.Security.Principal.SecurityIdentifier]).Value + +$expected = @{} +foreach ($pair in @(@($vscodeApp, 'app'), @($vscodePersist, 'persist'), @($bData, 'programdata'), @((Join-Path $bData 'sub'), 'programdata-sub'))) { + if (Test-Path -LiteralPath $pair[0]) { $expected[$pair[1]] = Get-SecurityFingerprint -Path $pair[0] } +} +$sourceOwner = (Get-Acl -LiteralPath $bData).GetOwner([System.Security.Principal.SecurityIdentifier]).Value +Write-Host ('[B] 备份前 data 的属主:{0}' -f (Get-Acl -LiteralPath $bData).Owner) +$currentSid = ([System.Security.Principal.WindowsIdentity]::GetCurrent()).User.Value +Test-Scenario 'B: 现场造对了 —— 属主既不是当前账户、也不是新建对象的默认属主' ` + (($sourceOwner -ne $currentSid) -and ($sourceOwner -ne $creatorOwner)) ` + "owner=$sourceOwner current=$currentSid creatorDefault=$creatorOwner" + +# --------------------------------------------------------------------------- +# 备份(三个条目) +# --------------------------------------------------------------------------- +$listPath = Join-Path $WorkRoot 'BackupList.txt' +$entries = @() +if ($vscodeReady) { $entries += $vscodeApp; $entries += $vscodePersist } +$entries += $bData +[System.IO.File]::WriteAllText($listPath, (($entries -join [Environment]::NewLine) + [Environment]::NewLine), [System.Text.UTF8Encoding]::new($false)) + +$configPath = Join-Path $WorkRoot 'BackupConfig.psd1' +$configText = @" +@{ + BackupDir = '$BackupDir' + LogDir = '$(Join-Path $WorkRoot 'logs')' + SnapshotDir = '$(Join-Path $BackupDir 'snapshots')' + SoftwareCatalog = 'NoSuchCatalog.psd1' + MinFreeSpaceGB = 0 + VerifyArchive = `$true + CompressionLevel = 1 + ToolOutput = 'quiet' + Snapshot = @{ Enabled = `$false } + Encryption = @{ Enabled = `$false; PasswordFile = '' } + Security = @{ Mode = 'Full'; IncludeSacl = `$false; SidMap = @{}; FailOnError = `$true } + DefaultExcludes = @('!Thumbs.db', '!desktop.ini') +} +"@ +[System.IO.File]::WriteAllText($configPath, $configText, [System.Text.UTF8Encoding]::new($false)) + +Write-Host '' +Write-Host '[备份] Backup.ps1' -ForegroundColor Yellow +$backup = Invoke-BaknretChild -Script (Join-Path $RepoPath 'Backup.ps1') -Parameters @{ + BackupListPath = $listPath + ConfigPath = $configPath + BackupDir = $BackupDir +} +$backup.LastLog | ForEach-Object { ' ' + $_ } +Test-Scenario '备份退出码 0' ($backup.ExitCode -eq 0) ('exit=' + $backup.ExitCode) +Test-Scenario '每个条目都写了 .acl.json' (@(Get-ChildItem -LiteralPath $BackupDir -Filter '*.acl.json').Count -ge $entries.Count) ` + ('acl.json=' + @(Get-ChildItem -LiteralPath $BackupDir -Filter '*.acl.json').Count + ' 条目=' + $entries.Count) + +# --------------------------------------------------------------------------- +# 删源 → 恢复 +# --------------------------------------------------------------------------- +foreach ($path in $entries) { + if ($path -ieq $vscodeApp) { Stop-VscodeProcesses -AppRoot $vscodeApp } + Remove-TreeHard -Path $path +} +$leftovers = @($entries | Where-Object { Test-Path -LiteralPath $_ }) +Test-Scenario '源已删除(模拟真的丢了)' ($leftovers.Count -eq 0) ($leftovers -join '、') + +Write-Host '' +Write-Host '[恢复] Restore.ps1' -ForegroundColor Yellow +$restore = Invoke-BaknretChild -Script (Join-Path $RepoPath 'Restore.ps1') -Parameters @{ + BackupListPath = $listPath + ConfigPath = $configPath + BackupDir = $BackupDir + Force = $true +} +$restore.LastLog | ForEach-Object { ' ' + $_ } +Test-Scenario '恢复退出码 0' ($restore.ExitCode -eq 0) ('exit=' + $restore.ExitCode) +Test-Scenario '恢复日志里出现安全描述符回放' ($restore.Output -match '安全描述符:回放') '' + +# --------------------------------------------------------------------------- +# A 段断言:vscode 还能不能正常读写 +# --------------------------------------------------------------------------- +Write-Host '' +Write-Host '--- A 断言 ---' -ForegroundColor Cyan +if ($vscodeReady) { + $versionText = (& $codeCmd --version 2>&1 | Out-String).Trim() + Test-Scenario 'A: 恢复后 vscode CLI 仍可执行' ($LASTEXITCODE -eq 0) ($versionText -split "`n" | Select-Object -First 1) + + $settingsOk = $false + if ($settingsPath -and (Test-Path -LiteralPath $settingsPath)) { + $settingsOk = (Get-Content -LiteralPath $settingsPath -Raw) -match [regex]::Escape($probe) + } + Test-Scenario 'A: 改过的 settings.json 被原样恢复(读得到、内容对)' $settingsOk $settingsPath + + # 写测试:vscode 的数据目录必须能新建文件 —— 这正是"无读写权限"症状的反面 + $writeOk = $false + $detail = '' + try { + $probeFile = Join-Path (Split-Path -Parent $settingsPath) ('baknret-write-' + [guid]::NewGuid().ToString('N').Substring(0, 6) + '.tmp') + [System.IO.File]::WriteAllText($probeFile, 'write probe') + $writeOk = (Test-Path -LiteralPath $probeFile) + Remove-Item -LiteralPath $probeFile -Force -ErrorAction SilentlyContinue + } catch { + $detail = $_.Exception.Message + } + Test-Scenario 'A: vscode 的数据目录可写(新建文件成功)' $writeOk $detail + + foreach ($pair in @(@($vscodeApp, 'app'), @($vscodePersist, 'persist'))) { + if (-not $expected.ContainsKey($pair[1])) { continue } + $expectedNormalized = $expected[$pair[1]] -replace ' P=(True|False) ', ' P=' + $actualNormalized = (Get-SecurityFingerprint -Path $pair[0]) -replace ' P=(True|False) ', ' P=' + Test-Scenario ("A: {0} 的安全指纹与备份前一致" -f $pair[1]) ($actualNormalized -eq $expectedNormalized) ` + ("want: " + $expectedNormalized + " / got: " + $actualNormalized) + } +} else { + Write-Host ' [SKIP] vscode 没装上,A 段的功能断言跳过(-SkipScoop 或下载失败)' -ForegroundColor Yellow +} + +# --------------------------------------------------------------------------- +# B 段断言:属主与 CREATOR OWNER +# --------------------------------------------------------------------------- +Write-Host '' +Write-Host '--- B 断言 ---' -ForegroundColor Cyan + +$restoredOwner = (Get-Acl -LiteralPath $bData).GetOwner([System.Security.Principal.SecurityIdentifier]).Value +Test-Scenario 'B: 现场属主确实是 SYSTEM(不是脚本账户的默认属主)' ($sourceOwner -eq 'S-1-5-18') "source=$sourceOwner" +Test-Scenario 'B: 恢复后属主 == 备份前的属主 —— CREATOR OWNER 才会判给原程序' ($restoredOwner -eq $sourceOwner) "want=$sourceOwner got=$restoredOwner" +Test-Scenario 'B: CREATOR OWNER 的 inherit-only ACE 还在' ((Get-Acl -LiteralPath $bData).Sddl -match '\(A;OICIIO;GA;;;CO\)') (Get-Acl -LiteralPath $bData).Sddl + +$bExpected = $expected['programdata'] -replace ' P=(True|False) ', ' P=' +$bActual = (Get-SecurityFingerprint -Path $bData) -replace ' P=(True|False) ', ' P=' +Test-Scenario 'B: data 的安全指纹与备份前一致' ($bActual -eq $bExpected) ("want: $bExpected / got: $bActual") + +if ($expected.ContainsKey('programdata-sub')) { + $subExpected = $expected['programdata-sub'] -replace ' P=(True|False) ', ' P=' + $subActual = (Get-SecurityFingerprint -Path (Join-Path $bData 'sub')) -replace ' P=(True|False) ', ' P=' + Test-Scenario 'B: 子目录的安全指纹与备份前一致' ($subActual -eq $subExpected) ("want: $subExpected / got: $subActual") +} + +# 负对照:只搬文件、不回放安全描述符 —— 属主会落到"跑脚本的账户"头上, +# 也就是 (A;OICIIO;GA;;;CO) 把全权判给脚本、原程序没权限的那种状态。 +$negative = Join-Path $WorkRoot 'negative-data' +& robocopy.exe $bData $negative /E /COPY:DAT /NFL /NDL /NJH /NJS /NP | Out-Null +$negativeOwner = (Get-Acl -LiteralPath $negative).GetOwner([System.Security.Principal.SecurityIdentifier]).Value +Test-Scenario 'B 负对照: 只搬文件时,属主变成"跑脚本的账户"而不再是原账户' ` + (($negativeOwner -ne $sourceOwner) -and ($negativeOwner -eq $creatorOwner)) ` + "negative=$negativeOwner creatorDefault=$creatorOwner" +Write-Host (' 原属主 = {0}' -f $sourceOwner) +Write-Host (' 恢复后属主 = {0}(应与原属主相同)' -f $restoredOwner) +Write-Host (' 负对照属主 = {0}(跑脚本的账户新建对象的默认属主)' -f $negativeOwner) + +# ============================================================================ +# 收尾 +# ============================================================================ +Write-Host '' +$total = $script:Passed + $script:Failures.Count +if ($script:Failures.Count -eq 0) { + Write-Host ('ACL 演练:全部通过 {0} 项' -f $total) -ForegroundColor Green +} else { + Write-Host ('ACL 演练:通过 {0} 项,失败 {1} 项' -f $script:Passed, $script:Failures.Count) -ForegroundColor Red + foreach ($failure in $script:Failures) { Write-Host (' - ' + $failure) -ForegroundColor Red } +} + +if ($KeepWorkRoot) { + Write-Host ('临时目录保留:{0}' -f $WorkRoot) -ForegroundColor Yellow +} else { + Remove-TreeHard -Path $bRoot + Remove-TreeHard -Path (Join-Path $WorkRoot 'negative-data') + # 备份与日志留着,便于事后核对(归档可能有几百 MB,要腾空间就手工删这个目录) +} + +if ($script:Failures.Count -gt 0) { exit 1 } +exit 0 diff --git a/tools/lab/payload/run-drill.ps1 b/tools/lab/payload/run-drill.ps1 new file mode 100644 index 0000000..234fcc8 --- /dev/null +++ b/tools/lab/payload/run-drill.ps1 @@ -0,0 +1,45 @@ +<# +.SYNOPSIS + 在 VM 内跑 tests\Restore-Drill.ps1,并把条目数组安全地传进去。 + +.DESCRIPTION + 为什么需要这一层:跨进程传数组参数是坏的。 + 经 `pwsh -File Restore-Drill.ps1 -Entries A B C` 传进去时,只有第一个值能绑到 + `[string[]]$Entries`,后面的会被当成多余的位置参数: + + A positional parameter cannot be found that accepts argument '...' + + 而 JSON / 带引号的字符串又会在 Start-Process 拼命令行时被引号转义搞坏, + 所以这里用 `;` 分隔的纯文本传条目,再在 PowerShell 内部用真正的数组绑定调用钻取脚本。 + + 用法:pwsh -File run-drill.ps1 -BackupDir <归档目录> -ConfigPath <配置> -EntriesCsv 'A;B;C' +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$BackupDir, + [Parameter(Mandatory)][string]$ConfigPath, + [string]$EntriesCsv = '', + [switch]$KeepWorkRoot, + [switch]$AllowChanged +) + +$ErrorActionPreference = 'Continue' + +$entries = @() +if ($EntriesCsv) { + $entries = @($EntriesCsv.Split(';') | Where-Object { $_ -and $_.Trim() } | ForEach-Object { $_.Trim() }) +} + +# 必须用**哈希表** splat:数组 splat 会把 -Entries A B C 拆成三个独立参数, +# 只有 A 绑得上,B 会被当成多余的位置参数(A positional parameter cannot be found ...)。 +$drillParams = [ordered]@{ + BackupDir = $BackupDir + ConfigPath = $ConfigPath +} +if ($entries.Count -gt 0) { $drillParams['Entries'] = $entries } +if ($KeepWorkRoot) { $drillParams['KeepWorkRoot'] = $true } +if ($AllowChanged) { $drillParams['AllowChanged'] = $true } + +Write-Host ("[lab] 恢复演练:{0} 个条目 -> {1}" -f $entries.Count, ($entries -join ' | ')) +& 'C:\BakNRet\tests\Restore-Drill.ps1' @drillParams \ No newline at end of file diff --git a/tools/lab/payload/run-suite-utf8.ps1 b/tools/lab/payload/run-suite-utf8.ps1 new file mode 100644 index 0000000..d7f97a7 --- /dev/null +++ b/tools/lab/payload/run-suite-utf8.ps1 @@ -0,0 +1,40 @@ +<# +.SYNOPSIS + 在 VM 内以 UTF-8 控制台编码运行一个测试套件(不改仓库里的任何测试代码)。 + +.DESCRIPTION + 为什么需要它 —— tests\BakNRet*.Tests.ps1 的 Invoke-BaknretScript 是这么抓子进程输出的: + + cmd /c pwsh -File Backup.ps1 ... > out.txt 2>&1 + Get-Content -LiteralPath out.txt -Encoding UTF8 + + 而 Backup.ps1 / Restore.ps1 的 Write-Log 走 Write-Host,写进 out.txt 的字节用的是 + `[Console]::OutputEncoding`: + + * 宿主机上它是 utf-8 -> 文件是 UTF-8 -> 按 UTF-8 读回,中文正确,套件全绿; + * 一台全新 Windows VM 上它是 ANSI 代码页(中文系统 936) + -> 文件是 GBK 字节 -> 按 UTF-8 读回得到替换字符 -> 断言中文的那几项失败。 + + 这是测试环境假设问题,不是产品缺陷。本包装器把控制台输出编码先钉成 UTF-8, + 于是 VM 里也能得到和宿主机一致的 150/150。 + + 用法:pwsh -File run-suite-utf8.ps1 C:\BakNRet\tests\Run-Pester.ps1 [-KeepWorkRoot ...] +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory, Position = 0)][string]$Suite, + [Parameter(Position = 1, ValueFromRemainingArguments = $true)][string[]]$SuiteArgs = @() +) + +$ErrorActionPreference = 'Continue' +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +[Console]::InputEncoding = [System.Text.Encoding]::UTF8 +$OutputEncoding = [System.Text.Encoding]::UTF8 + +Write-Host ("[lab] 包装器:PS {0},[Console]::OutputEncoding = {1}" -f $PSVersionTable.PSVersion, [Console]::OutputEncoding.WebName) +Write-Host ("[lab] 套件:{0} {1}" -f $Suite, ($SuiteArgs -join ' ')) + +if (-not (Test-Path -LiteralPath $Suite)) { Write-Error "找不到套件:$Suite"; exit 2 } +& $Suite @SuiteArgs +exit $LASTEXITCODE \ No newline at end of file diff --git a/tools/lab/payload/sandbox/BackupConfig.psd1 b/tools/lab/payload/sandbox/BackupConfig.psd1 new file mode 100644 index 0000000..9dc9acb --- /dev/null +++ b/tools/lab/payload/sandbox/BackupConfig.psd1 @@ -0,0 +1,25 @@ +<# + 隔离沙盒配置:归档、日志、快照全部落在 C:\BakNRet-Lab 与 C:\BakNRet\ 下(都在 VM 内), + 绝不碰宿主机仓库的 Backups\ 与 logs\。 + + 取值偏「跑得快」而非「压得小」:CompressionLevel = 1,让一次全链路几秒钟跑完; + 要压真实比例时用 -CompressionLevel 9 单独跑。 +#> +@{ + BackupDir = 'C:\BakNRet-Lab\Backups' + LogDir = 'C:\BakNRet-Lab\logs\backup' + SnapshotDir = 'C:\BakNRet-Lab\Backups\snapshots' + # 注意:SoftwareCatalog 的相对路径是按**仓库根**(Backup.ps1 所在目录)解析的, + # 不是按本配置文件所在目录;而且路径不存在时会**静默回退**到仓库真实的 + # SoftwareCatalog.psd1。沙盒必须写成仓库根相对路径,否则软件名条目会悄悄用错名录。 + SoftwareCatalog = 'tools\lab\payload\sandbox\SoftwareCatalog.psd1' + CatalogMaxDepth = 5 + MinFreeSpaceGB = 0 + VerifyArchive = $true + ComputeHash = $true + CompressionLevel = 1 + ToolOutput = 'quiet' + Snapshot = @{ Enabled = $false; KeepCount = 3; KeepDays = 30 } + Encryption = @{ Enabled = $false; PasswordFile = 'C:\BakNRet-Lab\state\baknret.key'; EncryptHeaders = $true } + DefaultExcludes = @('!Thumbs.db','!desktop.ini') +} \ No newline at end of file diff --git a/tools/lab/payload/sandbox/BackupList.txt b/tools/lab/payload/sandbox/BackupList.txt new file mode 100644 index 0000000..2ebbad5 --- /dev/null +++ b/tools/lab/payload/sandbox/BackupList.txt @@ -0,0 +1,26 @@ +########### +# BakNRet 隔离沙盒清单(只在 VM 内使用;所有路径都指向 C:\BakNRet-Lab\sources) +########### +# +# 形态覆盖:多 Slot 软件名、单文件 Slot、中文+空格路径、真 NTFS 连接点、手写路径、 +# :- 排除、:+ 追加、:: 覆盖 Path、行首方向标记 + / -、源缺失条目。 +# 约束提醒:归档名 = 软件名(或路径推导名),每行必须产生唯一归档名。 + +# ---- 软件名条目(查同目录的 SoftwareCatalog.psd1)---- + +AppMultiSlot :- CacheSlot\cache-01.tmp,!*.log # Slot 前缀排除 + 任意层级通配 +AppFileSlot :+ Modules:C:\BakNRet-Lab\sources\AppMultiSlot\Config # 追加映射:把 Config 放到包内 Modules\ +软件目录甲 # 中文 + 空格 + 点的路径 +JunctionToData # 真 NTFS 连接点 +MissingApp # 源不存在:记 missing-source,退出码仍 0 +OverrideTarget :: C:\BakNRet-Lab\sources\AppMultiSlot\Config # :: 覆盖名录里故意写错的 Path + +# ---- 手写路径条目 ---- + +C:\BakNRet-Lab\sources\AppBig +C:\BakNRet-Lab\sources\AppLocked # 被占用文件:验证「有文件没打进归档」的告警 + +# ---- 行首方向标记 ---- + ++ C:\BakNRet-Lab\sources\AppDeep # 仅备份,不恢复 +- C:\BakNRet-Lab\sources\AppRestoreOnly # 仅恢复,不备份(备份端跳过) \ No newline at end of file diff --git a/tools/lab/payload/sandbox/SoftwareCatalog.psd1 b/tools/lab/payload/sandbox/SoftwareCatalog.psd1 new file mode 100644 index 0000000..2d61dac --- /dev/null +++ b/tools/lab/payload/sandbox/SoftwareCatalog.psd1 @@ -0,0 +1,34 @@ +<# + BakNRet 隔离沙盒名录:软件名 -> Slot 组,全部指向 C:\BakNRet-Lab\sources 下的假数据。 + + 只在 VM 内使用,宿主机仓库里的 SoftwareCatalog.psd1 不受影响。 + 带连字符的键必须加引号(PSD1 会把它当减法);这里用中文键名,也统一加引号。 +#> +@{ + AppMultiSlot = @{ + DefaultData = @{ Path = 'C:\BakNRet-Lab\sources\AppMultiSlot\Data'; Description = '主数据(含 node_modules、session.log、空目录)' } + DefaultConfig = @{ Path = 'C:\BakNRet-Lab\sources\AppMultiSlot\Config'; Description = '配置(含 .bak)' } + CacheSlot = @{ Path = 'C:\BakNRet-Lab\sources\AppMultiSlot\Cache'; Description = '缓存(清单里再排除一条 cache-01.tmp)' } + } + + AppFileSlot = @{ + Profile = @{ Path = 'C:\BakNRet-Lab\sources\AppFileSlot\profile.json'; Description = '单文件 Slot:归档内是名为 Profile 的文件' } + Readme = @{ Path = 'C:\BakNRet-Lab\sources\AppFileSlot\readme.txt'; Description = '另一个单文件 Slot' } + } + + '软件目录甲' = @{ + DefaultData = @{ Path = 'C:\BakNRet-Lab\sources\软件 目录.甲'; Description = '中文 + 空格 + 点的路径' } + } + + JunctionToData = @{ + DefaultData = @{ Path = 'C:\BakNRet-Lab\sources\JunctionToData'; Description = '真 NTFS 连接点(指向 AppMultiSlot\Data)' } + } + + MissingApp = @{ + DefaultData = @{ Path = 'C:\BakNRet-Lab\sources\MissingApp'; Description = '源不存在:备份跳过并记 missing-source,恢复仍知道目标位置' } + } + + OverrideTarget = @{ + DefaultData = @{ Path = 'C:\BakNRet-Lab\sources\OverrideTarget-故意不存在'; Description = '故意写错,由清单里的 :: 覆盖成存在的目录' } + } +} \ No newline at end of file diff --git a/tools/lab/payload/unattend.xml b/tools/lab/payload/unattend.xml new file mode 100644 index 0000000..38c6df9 --- /dev/null +++ b/tools/lab/payload/unattend.xml @@ -0,0 +1,126 @@ + + + + + + + + BAKNRET-LAB + China Standard Time + BakNRet Lab + BakNRet Lab + + + + + + 1 + 跳过 OOBE 的联网 / 微软账户强制 + reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\OOBE" /v BypassNRO /t REG_DWORD /d 1 /f + + + 2 + 关掉“让我们完成设备设置”一类打扰 + reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\UserProfileEngagement" /v ScoobeSystemSettingEnabled /t REG_DWORD /d 0 /f + + + + + + + + + + zh-CN + zh-CN + zh-CN + zh-CN + + + + + + true + true + true + true + Work + 3 + + + + + + lab + Lab + BakNRet 隔离测试账户 + Administrators + + __LABPASSWORD__ + true</PlainText> + </Password> + </LocalAccount> + </LocalAccounts> + </UserAccounts> + + <AutoLogon> + <Username>lab</Username> + <Enabled>true</Enabled> + <LogonCount>3</LogonCount> + <Password> + <Value>__LABPASSWORD__</Value> + <PlainText>true</PlainText> + </Password> + </AutoLogon> + + <FirstLogonCommands> + <SynchronousCommand wcm:action="add"> + <Order>1</Order> + <Description>BakNRet lab 供给脚本(把 VM 变成可跑全链路测试的真机状态)</Description> + <CommandLine>powershell.exe -NoProfile -ExecutionPolicy Bypass -File C:\BakNRet-Lab\payload\provision.ps1</CommandLine> + </SynchronousCommand> + </FirstLogonCommands> + + <TimeZone>China Standard Time</TimeZone> + </component> + + </settings> + +</unattend> \ No newline at end of file