chore: 记录改造前基线

改造开始前的完整状态,作为可回退的基点。此提交之后:Pester 175 项、零依赖套件 101 项全绿;PowerShell 5.1 尚不可用(源文件无 BOM)。

包含此前未提交的在制品:安全描述符套件、Hyper-V 实验环境(tools/lab)、agent 约定(AGENTS.md 与 docs/agents)。

.gitignore 增加 *.key / *.pfx:BackupConfig.psd1 的 PasswordFile 此前默认指向仓库内的 baknret.key,一次 git add -A 就会把口令提交进版本库。默认值在后续提交中改为空。
This commit is contained in:
Shuery committed 2026-09-26 21:46:55 +08:00
1 parent 7173e8ae10
commit 2937eb6652
32 files changed
+8722 -1638

No files matched your search

+6
View File
@@ -12,6 +12,12 @@ logs/
# 测试用的本地依赖(Pester 等,见 tools/Install-TestDependencies.ps1) # 测试用的本地依赖(Pester 等,见 tools/Install-TestDependencies.ps1)
.tools/ .tools/
# 口令与私钥文件绝不进版本库。
# BackupConfig.psd1 的 PasswordFile 默认值为空:口令应放在仓库之外
# (用 $env:BAKNRET_PASSWORD,或用 -KeyFile 指向仓库外的文件)。
*.key
*.pfx
# 编辑器 / 系统杂项 # 编辑器 / 系统杂项
.vscode/ .vscode/
*.swp *.swp
+15
View File
@@ -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`。
+214 -135
View File
@@ -16,6 +16,12 @@
跳过和失败从此有据可查,而不是只剩一行滚过去的控制台告警。 跳过和失败从此有据可查,而不是只剩一行滚过去的控制台告警。
5. 结尾按失败数 exit,并写日志文件,计划任务能正确判断成败。 5. 结尾按失败数 exit,并写日志文件,计划任务能正确判断成败。
6. 磁盘空间守卫:放不下就拒绝该条目,低于阈值则告警。 6. 磁盘空间守卫:放不下就拒绝该条目,低于阈值则告警。
与 SoftwareCatalog.psd1 的 Slot 结构配套:
* 一个软件 = 一个归档,归档内是 `<Slot>\<该 Path 的内容>`;
* 打包前用暂存目录 + junction / 硬链接把 Slot 名变成包里真实的目录名
(7z 没有"入库时改名"的能力),打包后立刻拆掉暂存目录;
* 清单行首 `+` = 仅备份、`-` = 仅恢复。
#> #>
[CmdletBinding()] [CmdletBinding()]
@@ -116,7 +122,11 @@ if (-not (Test-Path -LiteralPath $BackupDir)) {
} }
if (-not (Test-Path -LiteralPath $BackupListPath)) { if (-not (Test-Path -LiteralPath $BackupListPath)) {
$template = "# BackupList.txt`n# 语法: <路径> [ :: <排除模式>[,<排除模式>...] ] [ @<标记> ]`n# 示例: %UserProfile%\.ssh`n" $template = "# BackupList.txt`n" +
"# 语法: [+|-] <软件名 或 绝对路径> [:: <路径>] [:- <排除模式>[,<排除模式>...]] [:+ <追加项>[,<追加项>...]] [:encrypt | :!encrypt] [@ <Key>='<值>'] [# 说明]`n" +
"# 示例: Edge`n" +
"# %UserProfile%\.ssh :encrypt`n" +
"# 完整语法见 README 与 BackupList.txt 自身的注释。`n"
[System.IO.File]::WriteAllText($BackupListPath, $template, [System.Text.UTF8Encoding]::new($false)) [System.IO.File]::WriteAllText($BackupListPath, $template, [System.Text.UTF8Encoding]::new($false))
Write-Log '模板 BackupList.txt 已创建,请编辑后重试。' -Level INFO Write-Log '模板 BackupList.txt 已创建,请编辑后重试。' -Level INFO
Stop-BaknretLog Stop-BaknretLog
@@ -148,6 +158,8 @@ $toolQuietArgument = if ($showToolOutput) { @() } else { @('-bso0', '-bsp0') }
$lines = Get-Content -LiteralPath $BackupListPath $lines = Get-Content -LiteralPath $BackupListPath
$seenBaseNames = @{} $seenBaseNames = @{}
$processed = 0; $skipped = 0; $failed = 0; $planned = 0 $processed = 0; $skipped = 0; $failed = 0; $planned = 0
$securityErrorCount = 0 # 有条目"安全描述符里有读不到的对象"
$securityFailed = 0 # 有条目"安全描述符完全没存下来"
$failures = @() $failures = @()
$freeSpaceGB = Get-BaknretFreeSpaceGB -Path $BackupDir $freeSpaceGB = Get-BaknretFreeSpaceGB -Path $BackupDir
if ($freeSpaceGB -ge 0) { if ($freeSpaceGB -ge 0) {
@@ -179,6 +191,7 @@ function New-ItemRecord {
source = $Source source = $Source
resolvedSource = $ResolvedSource resolvedSource = $ResolvedSource
roots = @() roots = @()
layouts = @()
catalog = $null catalog = $null
archive = $null archive = $null
action = $null action = $null
@@ -192,6 +205,7 @@ function New-ItemRecord {
warnings = $false warnings = $false
attemptWarnings = $false attemptWarnings = $false
encrypted = $false encrypted = $false
security = $null
sourceFiles = $null sourceFiles = $null
sourceBytes = $null sourceBytes = $null
archiveBytes = $null archiveBytes = $null
@@ -221,6 +235,12 @@ function Save-ItemRecord {
$Record.warnings = [bool]$previous.warnings $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) {
if ($previous.PSObject.Properties.Name -contains 'lastSuccessAt') { $Record.lastSuccessAt = $previous.lastSuccessAt } if ($previous.PSObject.Properties.Name -contains 'lastSuccessAt') { $Record.lastSuccessAt = $previous.lastSuccessAt }
if ($previous.PSObject.Properties.Name -contains 'successCount') { $Record.successCount = [int]$previous.successCount } if ($previous.PSObject.Properties.Name -contains 'successCount') { $Record.successCount = [int]$previous.successCount }
@@ -240,14 +260,17 @@ function Save-ItemRecord {
# 压缩 + 校验 + 原子替换;返回 @{ Ok; ExitCode; Warnings; Reason } # 压缩 + 校验 + 原子替换;返回 @{ Ok; ExitCode; Warnings; Reason }
# #
# $SourceGroups 支持"一个软件包含多个目录":每个元素是 # 归档内容由调用方决定:它已经用 New-BaknretArchiveStaging 把每个归档项按"归档内的名字"
# @{ ParentDir; RelativePaths; Label }。7z/RAR 对同一归档多次 `a` 会把内容并入, # 挂进了 $StagingRoot(目录走 junction、文件走硬链接/复制),所以这里只做三件事:
# 所以按父目录分组、逐组追加,归档里每个目录仍保留自己的名字与层级。 # 1. 以暂存目录为工作目录调用压缩工具,把项名加进去;
# 2. 用 7z t 校验,并核对多 Slot 条目的每个顶层名字都真的在包里;
# 3. 有警告时按保护策略决定是否原子替换。
function Invoke-BackupItem { function Invoke-BackupItem {
param( param(
[Parameter(Mandatory = $true)][array]$SourceGroups, [Parameter(Mandatory = $true)][array]$SourceItems,
[Parameter(Mandatory = $true)][string]$StagingRoot,
[Parameter(Mandatory = $true)][string]$FinalPath, [Parameter(Mandatory = $true)][string]$FinalPath,
[string[]]$ExcludePatterns, [string[]]$ExcludePatterns = @(),
[switch]$UseEncryption, [switch]$UseEncryption,
[switch]$ProtectPrevious, [switch]$ProtectPrevious,
[switch]$AcceptWarnings [switch]$AcceptWarnings
@@ -258,29 +281,17 @@ function Invoke-BackupItem {
$warnings = $false $warnings = $false
$lastExitCode = 0 $lastExitCode = 0
$lastParentDir = $null $itemNames = @($SourceItems | ForEach-Object { [string]$_.ArchivePath })
$realPaths = @($SourceItems | ForEach-Object { [string]$_.RealPath })
try { try {
if ($SourceGroups.Count -eq 0) { if ($SourceItems.Count -eq 0) {
return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '没有可打包的源目录' } return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '没有可打包的源目录' }
} }
$groupIndex = 0
foreach ($group in $SourceGroups) {
$groupIndex++
$parentDir = $group.ParentDir
$relativePaths = @($group.RelativePaths)
if ($relativePaths.Count -eq 0) { continue }
$lastParentDir = $parentDir
# 排除模式的**前缀用这一组的源目录名**(归档里就是这个层级)。
$prefixName = if ($group.Label) { $group.Label } else { Split-Path -Path $relativePaths[0] -Leaf }
$excludeArgument = Get-ArchiveExcludeArgument -ItemName $prefixName -Patterns $ExcludePatterns
if ($tool.Name -eq '7z') { if ($tool.Name -eq '7z') {
$probePath = "$($parentDir.TrimEnd('\'))\$($relativePaths[0])" $optimized = Get-Optimized7zArgument -SourcePath $realPaths -Level $script:Config.CompressionLevel
$optimized = Get-Optimized7zArgument -SourcePath $probePath -Level $script:Config.CompressionLevel $argument = @($optimized.Argument) + $toolQuietArgument + @($ExcludePatterns)
$argument = @($optimized.Argument) + $toolQuietArgument + $excludeArgument
if ($UseEncryption) { if ($UseEncryption) {
if (-not $password) { if (-not $password) {
@@ -291,18 +302,18 @@ function Invoke-BackupItem {
} }
$argument += $tempPath $argument += $tempPath
foreach ($relative in $relativePaths) { $argument += $relative } $argument += $itemNames
$exitCode = Invoke-ExternalCommand -FilePath $tool.Command -ArgumentList $argument -WorkingDirectory $parentDir $exitCode = Invoke-ExternalCommand -FilePath $tool.Command -ArgumentList $argument -WorkingDirectory $StagingRoot
$lastExitCode = $exitCode $lastExitCode = $exitCode
# 7z: 0 成功;1 警告(有文件读不到或跳过);2 及以上为失败 # 7z: 0 成功;1 警告(有文件读不到或跳过);2 及以上为失败
if ($exitCode -ne 0 -and $exitCode -ne 1) { if ($exitCode -ne 0 -and $exitCode -ne 1) {
return [pscustomobject]@{ Ok = $false; ExitCode = $exitCode; Warnings = $warnings; Reason = "第 $groupIndex 组($prefixName)压缩工具退出码 $exitCode" } return [pscustomobject]@{ Ok = $false; ExitCode = $exitCode; Warnings = $warnings; Reason = "压缩工具退出码 $exitCode" }
} }
if ($exitCode -eq 1) { $warnings = $true } if ($exitCode -eq 1) { $warnings = $true }
} }
elseif ($tool.Name -eq 'RAR') { elseif ($tool.Name -eq 'RAR') {
$argument = @('a', '-m5', '-idp', '-idn') + $toolQuietArgument + $excludeArgument $argument = @('a', '-m5', '-idp', '-idn') + $toolQuietArgument + @($ExcludePatterns)
if ($UseEncryption) { if ($UseEncryption) {
if (-not $password) { if (-not $password) {
return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '需要加密但取不到口令' } return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '需要加密但取不到口令' }
@@ -310,35 +321,22 @@ function Invoke-BackupItem {
$argument += "-p$password" $argument += "-p$password"
} }
$argument += $tempPath $argument += $tempPath
foreach ($relative in $relativePaths) { $argument += $relative } $argument += $itemNames
$exitCode = Invoke-ExternalCommand -FilePath $tool.Command -ArgumentList $argument -WorkingDirectory $parentDir $exitCode = Invoke-ExternalCommand -FilePath $tool.Command -ArgumentList $argument -WorkingDirectory $StagingRoot
$lastExitCode = $exitCode $lastExitCode = $exitCode
if ($exitCode -ne 0) { if ($exitCode -ne 0) {
return [pscustomobject]@{ Ok = $false; ExitCode = $exitCode; Warnings = $warnings; Reason = "第 $groupIndex 组($prefixName)压缩工具退出码 $exitCode" } return [pscustomobject]@{ Ok = $false; ExitCode = $exitCode; Warnings = $warnings; Reason = "压缩工具退出码 $exitCode" }
} }
} }
else { else {
if ($UseEncryption) { if ($UseEncryption) {
return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '内置 ZIP 不支持加密,请改用 7z 或去掉 encrypt 标记' } return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '内置 ZIP 不支持加密,请改用 7z 或去掉加密' }
} }
# Compress-Archive 不能追加;多组时逐组重打(先把已有临时归档解开再合并会让代码复杂得多, # Compress-Archive 没有排除开关,也没有加密;这是降级路径,只保证内容完整。
# 而 ZIP 本来就是降级路径,这里只保证内容完整) # 暂存目录里的名字就是归档内的名字,所以布局与 7z 分支一致。
$fullPaths = @($relativePaths | ForEach-Object { Join-Path $parentDir $_ }) $fullPaths = @($SourceItems | ForEach-Object { Join-Path $StagingRoot $_.ArchivePath })
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 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 (-not (Test-Path -LiteralPath $tempPath)) { if (-not (Test-Path -LiteralPath $tempPath)) {
@@ -351,22 +349,22 @@ function Invoke-BackupItem {
if ($UseEncryption -and $password) { $verifyArgument += "-p$password" } if ($UseEncryption -and $password) { $verifyArgument += "-p$password" }
$verifyArgument += $tempPath $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) { if ($verifyCode -ne 0) {
Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue
return [pscustomobject]@{ Ok = $false; ExitCode = $verifyCode; Warnings = $false; Reason = "归档校验失败(7z t 退出码 $verifyCode),已丢弃临时文件" } return [pscustomobject]@{ Ok = $false; ExitCode = $verifyCode; Warnings = $false; Reason = "归档校验失败(7z t 退出码 $verifyCode),已丢弃临时文件" }
} }
Write-Log '归档校验通过(7z t)' -Level DEBUG Write-Log '归档校验通过(7z t)' -Level DEBUG
# 多目录时确认每个目录都真的进了归档:7z 的"警告"可能只体现在某一组里 # 多个 Slot / 追加项时确认每一个顶层名字都真的进了归档:7z 的"警告"可能只体现在某一部分上
if ($SourceGroups.Count -gt 1) { if ($SourceItems.Count -gt 1) {
$listed = @(Get-ArchiveTopLevelNames -ArchivePath $tempPath -SevenZip $tool.Command -Password $(if ($UseEncryption) { $password } else { $null })) $listed = @(Get-ArchiveTopLevelNames -ArchivePath $tempPath -SevenZip $tool.Command -Password $(if ($UseEncryption) { $password } else { $null }))
if ($listed.Count -gt 0) { 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 }) $absent = @($expected | Where-Object { $_ -notin $listed })
if ($absent.Count -gt 0) { if ($absent.Count -gt 0) {
Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue 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 $planResolved = Resolve-BackupEntry -Entry $planItem -CatalogPath $catalogPath -MaxDepth $script:Config.CatalogMaxDepth
if (-not $planResolved.BaseName) { continue } if (-not $planResolved.BaseName) { continue }
if (-not (Test-ItemSelected -DisplayPath $planDisplayPath -BaseName $planResolved.BaseName)) { continue } if (-not (Test-ItemSelected -DisplayPath $planDisplayPath -BaseName $planResolved.BaseName)) { continue }
if ($planResolved.Direction -eq 'restore') { continue }
if ($planResolved.Blocking) { continue } if ($planResolved.Blocking) { continue }
$planSources = @($planResolved.Sources | Where-Object { Test-Path -LiteralPath $_.SourcePath }) $planItems = @($planResolved.Items | Where-Object { Test-Path -LiteralPath $_.RealPath })
if ($planSources.Count -eq 0) { $spaceNoSource++; continue } if ($planItems.Count -eq 0) { $spaceNoSource++; continue }
$planSourceBytes = [int64]0 $planSourceBytes = [int64]0
$planSourceFiles = 0 $planSourceFiles = 0
$planLatest = $null $planLatest = $null
foreach ($planSource in $planSources) { foreach ($planSource in $planItems) {
$planSummary = Get-FolderSummary -FolderPath $planSource.SourcePath $planSummary = Get-FolderSummary -FolderPath $planSource.RealPath
$planSourceBytes += [int64]$planSummary.TotalSize $planSourceBytes += [int64]$planSummary.TotalSize
$planSourceFiles += [int]$planSummary.FileCount $planSourceFiles += [int]$planSummary.FileCount
if ($planSummary.LatestModifiedTime -and (-not $planLatest -or $planSummary.LatestModifiedTime -gt $planLatest)) { if ($planSummary.LatestModifiedTime -and (-not $planLatest -or $planSummary.LatestModifiedTime -gt $planLatest)) {
@@ -533,29 +532,35 @@ foreach ($line in $lines) {
continue 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)) { if ($seenBaseNames.ContainsKey($baseName)) {
$reason = "归档名 '$baseName' 与清单中的 '$($seenBaseNames[$baseName])' 重复(由 '$displayPath' 生成),两者会互相覆盖" $reason = "归档名 '$baseName' 与清单中的 '$($seenBaseNames[$baseName])' 重复(由 '$displayPath' 生成),两者会互相覆盖"
Write-Log "失败: $displayPath,$reason" -Level ERROR 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 Save-ItemRecord -Record $record -Action 'failed' -Reason $reason | Out-Null
$failed++; $failures += $displayPath $failed++; $failures += $displayPath
continue continue
} }
$seenBaseNames[$baseName] = $displayPath $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) { if ($resolved.Blocking) {
Write-Log "失败: $displayPath,$($resolved.Blocking)" -Level ERROR Write-Log "失败: $displayPath,$($resolved.Blocking)" -Level ERROR
Save-ItemRecord -Record $record -Action 'failed' -Reason $resolved.Blocking | Out-Null Save-ItemRecord -Record $record -Action 'failed' -Reason $resolved.Blocking | Out-Null
@@ -563,16 +568,22 @@ foreach ($line in $lines) {
continue 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 ` Write-BackupEntryPlan -Resolved $resolved -DisplayPath $displayPath `
-ListExcludes @($item.ExcludePatterns) -ConfigExcludes @($script:Config.DefaultExcludes) ` -ListExcludes $planListExcludes -CatalogExcludes $planCatalogExcludes `
-Comment $item.Comment -ConfigExcludes @($script:Config.DefaultExcludes) -Comment $item.Comment
# Sources 为空 = 解析不出任何源(名录里没这个软件名、或路径拆不出父/子级)。 # Items 为空 = 解析不出任何归档项(名录里没这个软件名、或路径拆不出末级名)。
# 注意不能用 $resolved.Error 判断:名录里的路径不存在时 Error 有值, # 注意不能用 $resolved.Error 判断:名录里的路径不存在时 Error 有值,
# 但 Sources 是给出的(恢复端要靠它把内容还原回原位),备份端由下面的 # 但 Items 是给出的(恢复端要靠它把内容还原回原位),备份端由下面的存在性检查统一处理。
# 存在性检查统一处理。 if ($resolved.Items.Count -eq 0) {
if ($resolved.Sources.Count -eq 0) {
$reason = if ($resolved.Error) { $resolved.Error } else { '解析不出任何源路径' } $reason = if ($resolved.Error) { $resolved.Error } else { '解析不出任何源路径' }
Write-Log "跳过: $displayPath,$reason" -Level WARN Write-Log "跳过: $displayPath,$reason" -Level WARN
Save-ItemRecord -Record $record -Action 'missing-source' -Reason $reason | Out-Null Save-ItemRecord -Record $record -Action 'missing-source' -Reason $reason | Out-Null
@@ -582,65 +593,50 @@ foreach ($line in $lines) {
# 源存在性检查必须在 Get-FolderSummary / Get-Item 之前: # 源存在性检查必须在 Get-FolderSummary / Get-Item 之前:
# 两者对不存在的路径要么抛异常、要么返回会误导判断的空摘要。 # 两者对不存在的路径要么抛异常、要么返回会误导判断的空摘要。
# 注意不能用 Join-Path 探测:目标盘符不存在时它会直接抛异常。 $missingItems = @($resolved.Items | Where-Object { -not (Test-Path -LiteralPath $_.RealPath) })
# 源路径存在性以 SourcePath 为准:RelativePaths 是"归档里的名字",
# 目前两者一致,但 SourcePath 才是磁盘上的真实位置。
$expectedRoots = 0
$missingRoots = @()
foreach ($source in $resolved.Sources) {
$expectedRoots++
if (-not (Test-Path -LiteralPath $source.SourcePath)) { $missingRoots += $source.SourcePath }
}
if ($missingRoots.Count -ge $expectedRoots) { if ($missingItems.Count -ge $resolved.Items.Count) {
$missingText = @($missingItems | ForEach-Object { $_.RealPath }) -join ';'
Write-Log "跳过: $displayPath,源路径不存在" -Level WARN 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++ $skipped++
continue continue
} }
if ($missingRoots.Count -gt 0) { if ($missingItems.Count -gt 0) {
Write-Log ("警告: {0} 有 {1} 个源路径不存在,本次只备份存在的部分:{2}" -f $displayPath, $missingRoots.Count, ($missingRoots -join ';')) -Level WARN Write-Log ("警告: {0} 有 {1} 个归档项的源路径不存在,本次只打包存在的部分:{2}" -f `
$displayPath, $missingItems.Count, (@($missingItems | ForEach-Object { $_.RealPath }) -join ';')) -Level WARN
} }
# 归档里只放真实存在的源 # 归档里只放真实存在的源
$liveSources = @() $liveItems = @($resolved.Items | Where-Object { Test-Path -LiteralPath $_.RealPath })
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
}
}
}
# 归档内的顶层条目名 = 每个**真实存在**的源在归档里的第一层名字,也就是源目录 # 归档内的顶层条目名 = 真实存在的归档项在包内的第一层名字(Slot 名 / 源目录名)。
# (或源文件)自己的名字。刻意不用 $resolved.Sources[].RootName:那套"归档内套一层 # 这里记录可核对的事实,备份成功后还会用 Get-ArchiveTopLevelNames 与归档内容对账。
# 软件名"的设想已按设计取舍放弃,实际布局始终是 <源目录名>\...。 $record.roots = @($liveItems | ForEach-Object { $_.TopName } | Select-Object -Unique)
# 这里记录可核对的事实,之前写成软件名会让 Edge(实际是 "User Data")之类的条目对不上。
$record.roots = @($liveSources | ForEach-Object {
$_.RelativePaths | ForEach-Object { ($_ -split '[\\/]')[0] }
} | Select-Object -Unique)
$primarySource = $liveSources[0].SourcePath # 恢复端要知道每个项在归档里是目录还是文件:记在 manifest 里,
$parentDir = $liveSources[0].ParentDir # 这样目标机器上目标还不存在(全新恢复)时也判断得出来。
# 排除模式的前缀始终用**源目录名**(归档里就是这个层级) $record.layouts = @($liveItems | ForEach-Object {
$itemName = Split-Path -Path $primarySource -Leaf [ordered]@{
name = $_.ArchivePath
kind = $(if ($_.IsFile) { 'file' } else { 'dir' })
}
})
if (-not $parentDir -or -not $itemName) { $primarySource = $liveItems[0].RealPath
Write-Log "跳过: $displayPath,无法处理根目录" -Level WARN if ([string]::IsNullOrWhiteSpace($primarySource)) {
Save-ItemRecord -Record $record -Action 'invalid-path' -Reason '无法拆出父目录或末级名' | Out-Null Write-Log "跳过: $displayPath,无法确定主源路径" -Level WARN
Save-ItemRecord -Record $record -Action 'invalid-path' -Reason '归档项没有可用路径' | Out-Null
$skipped++ $skipped++
continue continue
} }
$summary = Get-FolderSummary -FolderPath $primarySource $summary = Get-FolderSummary -FolderPath $primarySource
foreach ($source in $liveSources[1..($liveSources.Count - 1)]) { # 从第二个归档项开始累加。刻意不用 `$liveItems[1..($liveItems.Count-1)]`:
$extra = Get-FolderSummary -FolderPath $source.SourcePath # 只有一项时 `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.FileCount += $extra.FileCount
$summary.TotalSize += $extra.TotalSize $summary.TotalSize += $extra.TotalSize
if ($extra.LatestModifiedTime -and $extra.LatestModifiedTime -gt $summary.LatestModifiedTime) { if ($extra.LatestModifiedTime -and $extra.LatestModifiedTime -gt $summary.LatestModifiedTime) {
@@ -693,13 +689,38 @@ foreach ($line in $lines) {
continue continue
} }
$useEncryption = $encryptAll -or ($item.Flags -contains 'encrypt') $useEncryption = $encryptAll -or [bool]$resolved.Encrypt
$record.encrypted = [bool]$useEncryption $record.encrypted = [bool]$useEncryption
$startedAt = Get-Date $startedAt = Get-Date
$record.attemptedAt = $startedAt.ToString('o') $record.attemptedAt = $startedAt.ToString('o')
# 配置里的全局排除 + 本条目的排除 # 排除参数:条目级 `:-` / `@ Exclude` 覆盖优先,否则用名录里各 Slot 自己的 Exclude;
$effectiveExcludes = @($script:Config.DefaultExcludes) + @($item.ExcludePatterns) # 再叠上 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 记录 # 只有在"现有归档是完整的"时才值得保护它。没有 manifest 记录
# (本次重构之前留下的归档)时按完整处理——宁可保守。 # (本次重构之前留下的归档)时按完整处理——宁可保守。
@@ -711,21 +732,19 @@ foreach ($line in $lines) {
} }
} }
# 多目录:每个源组各带自己的父目录与相对名。7z 会对同一归档逐组追加。 # 归档内的一层目录名由 Slot / 追加项的归档内路径决定,所以先把它们以正确的名字
# Label 刻意留空:排除模式的前缀必须是**归档里的那一层名字**,也就是源目录名 # 挂进暂存目录(junction / 硬链接),再让压缩工具以暂存目录为工作目录打包。
# (归档内布局是 `<源目录名>\...`)。若把软件名当 Label 传下去, $stagingRoot = $null
# 排除模式就会变成 `软件名\skip.bin`,与实际路径对不上而静默失效。 try {
$sourceGroups = @($liveSources | ForEach-Object { $stagingRoot = New-BaknretArchiveStaging -Items $liveItems
[pscustomobject]@{ $result = Invoke-BackupItem -SourceItems $liveItems -StagingRoot $stagingRoot `
ParentDir = $_.ParentDir
RelativePaths = @($_.RelativePaths)
Label = $null
}
})
$result = Invoke-BackupItem -SourceGroups $sourceGroups `
-FinalPath $finalPath -ExcludePatterns $effectiveExcludes -UseEncryption:$useEncryption ` -FinalPath $finalPath -ExcludePatterns $effectiveExcludes -UseEncryption:$useEncryption `
-ProtectPrevious:$protectPrevious -AcceptWarnings:$AcceptWarnings -ProtectPrevious:$protectPrevious -AcceptWarnings:$AcceptWarnings
} catch {
$result = [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = "准备归档内容失败:$_" }
} finally {
Remove-BaknretArchiveStaging -Root $stagingRoot
}
$record.exitCode = $result.ExitCode $record.exitCode = $result.ExitCode
$record.attemptWarnings = [bool]$result.Warnings $record.attemptWarnings = [bool]$result.Warnings
@@ -748,6 +767,58 @@ foreach ($line in $lines) {
Write-Log "备份成功: $baseName" -Level INFO 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) { if ($Hash -or $script:Config.ComputeHash) {
$record.sha256 = (Get-FileHash -LiteralPath $finalPath -Algorithm SHA256).Hash $record.sha256 = (Get-FileHash -LiteralPath $finalPath -Algorithm SHA256).Hash
Write-Log "SHA256: $($record.sha256)" -Level DEBUG Write-Log "SHA256: $($record.sha256)" -Level DEBUG
@@ -790,6 +861,7 @@ if ($DryRun) {
# 判据只用清单,**不能用 manifest**:manifest 会一直留着历史条目, # 判据只用清单,**不能用 manifest**:manifest 会一直留着历史条目,
# 于是"从清单里删掉某个条目(或把它合并进另一个条目)"留下的归档会被历史记录遮住, # 于是"从清单里删掉某个条目(或把它合并进另一个条目)"留下的归档会被历史记录遮住,
# 审计就永远不会报——那正是最需要报出来的情况。 # 审计就永远不会报——那正是最需要报出来的情况。
# $seenBaseNames 在方向过滤之前就登记,所以"行首 + / -"的条目也算有主。
# 只在整表运行时做:带 -Only/-Skip 时未选中的条目本来就不在 $seenBaseNames 里, # 只在整表运行时做:带 -Only/-Skip 时未选中的条目本来就不在 $seenBaseNames 里,
# 那种情况下报出来的全是假孤儿。 # 那种情况下报出来的全是假孤儿。
if (-not $DryRun -and $Only.Count -eq 0 -and $Skip.Count -eq 0) { 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 } 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" $summaryText = "备份完成。成功: $processed, 跳过: $skipped, 失败: $failed"
if ($DryRun) { $summaryText += ", 试运行计划: $planned" } if ($DryRun) { $summaryText += ", 试运行计划: $planned" }
Write-Log $summaryText -Level INFO Write-Log $summaryText -Level INFO
+29 -2
View File
@@ -48,7 +48,8 @@
# #
# **本仓库不存放任何口令**,这里只记"去哪儿找": # **本仓库不存放任何口令**,这里只记"去哪儿找":
# Encryption.Enabled = $true -> 所有条目都加密 # Encryption.Enabled = $true -> 所有条目都加密
# 或 BackupList.txt 里给单个条目加 @encrypt(如 .ssh @encrypt) # SoftwareCatalog.psd1 的 Slot 写 Encrypt = $true(如 OpenSSH)
# 或 BackupList.txt 里给单个条目加 :encrypt(如 Edge :encrypt)
# #
# 口令本身按以下优先级获取(见 README「加密」): # 口令本身按以下优先级获取(见 README「加密」):
# 1. -Password 命令行参数 # 1. -Password 命令行参数
@@ -60,10 +61,36 @@
# 注意 7z 只接受命令行口令,口令在本机进程列表里短暂可见,这是 7z 本身的限制。 # 注意 7z 只接受命令行口令,口令在本机进程列表里短暂可见,这是 7z 本身的限制。
Encryption = @{ Encryption = @{
Enabled = $false Enabled = $false
PasswordFile = '' PasswordFile = 'baknret.key'
EncryptHeaders = $true 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(! 开头 = 任意层级匹配组件名) # 所有条目都生效的排除模式,语法同 BackupList.txt(! 开头 = 任意层级匹配组件名)
DefaultExcludes = @( DefaultExcludes = @(
'!Thumbs.db' '!Thumbs.db'
+110 -87
View File
@@ -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 和本文件里, # <SoftwareName> 软件名。查 SoftwareCatalog.psd1,归档名 = 软件名。
# 两边都进 git,改动可追溯。 # <Absolute\Path> 绝对路径。含 `\`、`/` 或 `%` 时按路径处理。
# ---- 用户配置 / 开发环境 ----
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 等一批。
# #
# 注意:不带 ! 的普通模式是**相对归档根目录锚定**的(会展开成 `-x!User?Data\<模式>`), # 修饰符(可多个,前后必须有空格):
# 所以它只排除根目录下那一份。Edge 的 OneAuth\WebView2\EBWebView\ 里还有一整套 # :: <Absolute\Path> 覆盖 Path。同 `@ Path='<Absolute\Path>'`。
# 自己的 Crashpad / BrowserMetrics / ProvenanceData / optimization_guide, # 同一行中,:- / :+ 的模式相对覆盖后的 Path。
# 根锚定模式碰不到它们 —— 这些可再生的东西一律用 ! 形式按组件名排除(-xr!),任意层级都命中。 # :- <模式>[,...] 排除。同 `@ Exclude='<模式>'`。
# 实测:根锚定的 Edge 归档 1781 MB / 27961 项 -> 改成 ! 形式后 72 MB / 2303 项, # :+ <模式>[,...] 追加。同 `@ Include='<模式>'`。
# 书签、密码(Login Data)、Cookies、偏好、历史、IndexedDB / Local Storage 全部保留。 # 模式为两段式:<归档内相对路径>:<宿主机绝对路径>。
# :encrypt 加密。同 `@ Encrypt='$true'`。
# :!encrypt 不加密。同 `@ Encrypt='$false'`。
# @ <Key>='<Value>' 覆盖 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!<Path 的目录名>\<模式>`。
# 只匹配根下这一份。
#
# !<模式> 任意层级。展开为 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 ---- + AutoDarkMode # 备份文件还在,但目前不再使用
BaiduNetdisk +MicrosoftEdge
FooClolor +FastNodeManager
March7thAssistant :- 3rdparty\WebBrowser\UserProfile\Integrated,March7thAssistant\logs\ # 内置浏览器的缓存与日志,可再生 +INZONEHub
MiFlash +Kazumi
MiFlash_Unlock +Legendary @ Exclude='DefaultConfig\tmp' # 忽略临时文件夹
QuarkCloudDrive +Mnemon
translucenttb +OpenCode
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 的缓存/会话数据,可再生 +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 # 仅在必要时备份
+2021 -403
View File
File diff suppressed because it is too large. Load diff
+326 -132
View File
@@ -2,11 +2,17 @@
把 `BackupList.txt` 里列出的软件 / 目录用 **7-Zip** 打包进 `Backups/`,并且能用 `Restore.ps1` 原样恢复的 Windows 备份工具。 把 `BackupList.txt` 里列出的软件 / 目录用 **7-Zip** 打包进 `Backups/`,并且能用 `Restore.ps1` 原样恢复的 Windows 备份工具。
- 清单里**直接写软件名**即可(如 `FooClolor`),目录映射维护在 `SoftwareCatalog.psd1` 里。 - 清单里**直接写软件名**即可(如 `Edge`),目录映射维护在 `SoftwareCatalog.psd1` 里。
- 归档名就是软件名(`FooClolor.7z`),不再是 `FooClolor_from_C_+Programs.7z`。 - 一个软件一个归档:**归档名 = 软件名**(`Edge.7z`),归档内按名录里的 **Slot 分层**
(`<Slot>\<该路径的内容>`),所以同一个软件里两个都叫 `persist` 的目录不会再撞在一起。
- 清单行首 `+` = 仅备份、`-` = 仅恢复;两条路径共用同一份清单。
- 排除 / 追加 / 加密都能写在 `SoftwareCatalog.psd1` 的 Slot 上,清单行里可以按条目覆盖。
- 只依赖 PowerShell(5.1 或 7.x)与 7-Zip,**运行备份/恢复不需要任何模块**(只有跑 Pester 测试才需要 Pester 5)。 - 只依赖 PowerShell(5.1 或 7.x)与 7-Zip,**运行备份/恢复不需要任何模块**(只有跑 Pester 测试才需要 Pester 5)。
- 每个归档写完后做 `7z t` 内容校验,**先写临时文件、校验通过再原子替换**。 - 每个归档写完后做 `7z t` 内容校验,**先写临时文件、校验通过再原子替换**。
- 每次运行产出可核对的 `Backups/manifest.json` 与 `logs/*.log`。 - 每次运行产出可核对的 `Backups/manifest.json` 与 `logs/*.log`。
- 归档之外还保存 **NTFS 安全描述符**(属主 / 属组 / DACL):每个归档旁边一份
`<归档名>.acl.json`,恢复时按它回放。这是"恢复之后原程序还能不能读写"的关键
(`C:\ProgramData` 下那些靠 `CREATOR OWNER` 授权的目录,见「安全描述符」一节)。
- 退出码可靠:有失败就返回 `1`,计划任务能正确判断成败。 - 退出码可靠:有失败就返回 `1`,计划任务能正确判断成败。
- 备份结束做**孤儿归档审计**:磁盘上有、但没有任何清单条目指向的归档会被点名(它们恢复不到,别误删)。 - 备份结束做**孤儿归档审计**:磁盘上有、但没有任何清单条目指向的归档会被点名(它们恢复不到,别误删)。
- 恢复支持 `-WhatIf` / `-DryRun` / `-VerifyOnly` / `-Only` / `-Skip`;其中三种"只看不写"的模式(`-WhatIf` / `-DryRun` / `-VerifyOnly`)**一个字节都不写**。 - 恢复支持 `-WhatIf` / `-DryRun` / `-VerifyOnly` / `-Only` / `-Skip`;其中三种"只看不写"的模式(`-WhatIf` / `-DryRun` / `-VerifyOnly`)**一个字节都不写**。
@@ -30,7 +36,7 @@
.\Backup.ps1 -Force -AcceptWarnings .\Backup.ps1 -Force -AcceptWarnings
# 4. 只备份 / 只恢复某几项(通配符匹配清单条目或归档名) # 4. 只备份 / 只恢复某几项(通配符匹配清单条目或归档名)
.\Backup.ps1 -Only 'FooClolor','.ssh' .\Backup.ps1 -Only 'Edge','OpenSSH'
.\Restore.ps1 -Only 'Edge' -Force .\Restore.ps1 -Only 'Edge' -Force
# 5. 恢复前先看计划(恢复会覆盖真实目录,务必先看一眼) # 5. 恢复前先看计划(恢复会覆盖真实目录,务必先看一眼)
@@ -44,60 +50,78 @@
| 路径 | 作用 | | 路径 | 作用 |
| --- | --- | | --- | --- |
| `SoftwareCatalog.psd1` | **软件名 → 目录**的映射,清单里写软件名的依据 | | `SoftwareCatalog.psd1` | **软件名 → Slot 组**的映射:每个 Slot 是一个目录/文件,以及它的排除、追加、加密、说明 |
| `BackupList.txt` | 备份 / 恢复共用的清单,唯一的"要备份什么"来源 | | `BackupList.txt` | 备份 / 恢复共用的清单,唯一的"要处理什么"来源 |
| `BackupConfig.psd1` | 目录、空间阈值、校验、加密等配置 | | `BackupConfig.psd1` | 目录、空间阈值、校验、加密等配置 |
| `Backup.ps1` / `Restore.ps1` | 备份 / 恢复入口 | | `Backup.ps1` / `Restore.ps1` | 备份 / 恢复入口 |
| `Common.psm1` | 公共模块(日志、外部命令、解析、名录、manifest) | | `Common.psm1` | 公共模块(日志、外部命令、清单与名录解析、归档布局、暂存、manifest) |
| `Backups/` | 归档与 `manifest.json`(已 gitignore) | | `Backups/` | 归档与 `manifest.json`(已 gitignore) |
| `logs/` | 每次运行的日志(已 gitignore) | | `logs/` | 每次运行的日志(已 gitignore) |
| `tests/` | 测试:Pester 套件(`*.Tests.ps1`)、零依赖套件、端到端验收、真实归档恢复演练 | | `tests/` | 测试:Pester 套件(`*.Tests.ps1`)、零依赖套件、端到端验收、真实归档恢复演练 |
| `tools/Register-BackupTask.ps1` | 注册 / 移除计划任务 | | `tools/Register-BackupTask.ps1` | 注册 / 移除计划任务 |
| `tools/Rename-Archives.ps1` | 把按路径命名的旧归档重命名成软件名(默认试运行) | | `tools/Rename-Archives.ps1` | 把归档名对齐到当前清单规则(默认试运行) |
| `tools/Install-TestDependencies.ps1` | 把 Pester 5 装到仓库内的 `.tools/`(不动机器上的全局模块) | | `tools/Install-TestDependencies.ps1` | 把 Pester 5 装到仓库内的 `.tools/`(不动机器上的全局模块) |
## SoftwareCatalog.psd1 —— 软件名 → 目录 ## SoftwareCatalog.psd1 —— 软件名 → Slot 组
```powershell ```powershell
@{ @{
# 1) 一个目录,直接写字符串 Edge = @{
FooClolor = 'C:\Programs\FooClolor' # Slot = 归档内的一层目录:内容进 DefaultData\,恢复时整棵回到这个 Path
DefaultData = @{
# 2) 一个目录 + 介绍(运行时会打印出来,推荐) Path = '%LocalAppData%\Microsoft\Edge\User Data'
Kazumi = @{ Exclude = '!*Cache,!Crashpad,Default\Extensions,Default\Service Worker'
Path = '%AppData%\com.example\Kazumi' Description = 'Edge 用户数据:书签/密码/偏好/历史,以及站点数据'
Description = 'Kazumi 的观看记录与设置' }
} }
# 3) 一个软件 = 多个目录:写成对象数组,每个目录各自带说明 Scoop = @{
scoop = @( # 一个软件可以有多个 Slot;两个都叫 persist 的目录因此不再冲突
@{ DefaultConfig = @{
Path = '%UserProfile%\scoop\persist'
Description = 'scoop 各应用的持久化数据(重装应用就会丢)'
}
@{
Path = '%UserProfile%\.config\scoop' Path = '%UserProfile%\.config\scoop'
Encrypt = $true
Description = 'scoop 自身的配置' Description = 'scoop 自身的配置'
} }
) UserPersist = @{
Path = '$(if ($env:SCOOP) { $env:SCOOP } else { Join-Path $env:USERPROFILE "scoop" })\persist'
Encrypt = $true
Description = 'scoop 各应用的持久化数据'
}
}
# 含 - 或 . 的键必须加引号 WindowsTerminal = @{
'.ssh' = @{ Path = '%UserProfile%\.ssh'; Description = 'SSH 私钥(不可再生)' } # Path 指向文件时,归档里就是一个名为 DefaultData 的文件(没有扩展名)
DefaultData = @{
Path = '%LocalAppData%\Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json'
Encrypt = $true
Description = 'Windows Terminal 的设置文件'
}
}
} }
``` ```
字段:
| 字段 | 说明 |
| --- | --- |
| Slot 名 | **归档内的一层目录**。内容进 `<Slot>\`;Path 是文件时就是名为 `<Slot>` 的文件。同一软件里不能重名 |
| `Path` | 宿主机上的绝对路径。支持 `%变量%` 与 `$( ... )` 子表达式 |
| `Exclude` | 排除模式,相对本 Slot 的根,逗号分隔。`!` 打头 = 任意层级(7z 通配符),`!re:<正则>` = 正则 |
| `Include` | 追加项,`<归档内相对路径>:<宿主机绝对路径>`,逗号分隔 |
| `Encrypt` | 该归档是否加密,默认 `$false`。同一软件里若各 Slot 不一致,整个归档按**加密**处理 |
| `Description` | 这个 Slot 是干什么的;运行时逐条打印 |
要点: 要点:
- **含 `-` 或 `.` 的键一定要加引号**,否则 PowerShell 会把 `a-b` 解析成减法表达式并报 - **`Path` 支持 `$( ... )`**:`$(if ($env:SCOOP) { $env:SCOOP } else { Join-Path $env:USERPROFILE "scoop" })`
`Missing '=' operator after key in hash literal`。这是最容易踩的一个坑。 会按 PowerShell 求值(求值结果会缓存,不会每个条目重复起进程)。
- 数组元素也接受**纯字符串**(`scoop = @('D:\a', 'D:\b')`),以及旧的 这类写法用了 `+` 拼接字符串时,`Import-PowerShellDataFile` 会拒绝,脚本会自动改用
`@{ Dirs = @(...) }` / `@{ Variants = @(...) }` 写法 —— 三种都能用。 PowerShell 求值——名录与配置是仓库里的本地文件,和脚本同级,信任级别相同。
- **目录当前不存在也不会被丢掉**:备份时跳过并记 `missing-source`,但恢复时仍然知道 - **目录当前不存在也不会被丢掉**:备份时跳过并记 `missing-source`,但恢复时仍然知道
"这块内容原本该回到哪个位置",这正是恢复要用的。 "这块内容原本该回到哪个位置",这正是恢复要用的。
- **前缀补全**:写 `D:\Programs\legendary`,实际目录是 `legendary_2.0.4` 时会自动匹配。 - **前缀补全**:写 `D:\Programs\legendary`,实际目录是 `legendary_2.0.4` 时会自动匹配
只认 `<名>_*` 与 `<名>-*`,不会把 `Legendary` 误配成 `LegendarySomething`。 (只认 `<名>_*` 与 `<名>-*`)。一个 Slot 只能对应一个目录,补全出多个会明确报错并让你拆 Slot。
- **一个软件里不能有两个同名目录**(例如两个 `persist`):归档内的顶层名就是目录名, - **一个软件里不能有两个同名 Slot**,否则归档内会混成一棵树;脚本会明确报错。
那样会在包里混成一棵树。脚本会明确报错(退出码 1)让你拆成两个条目。
**分文件维护**:用 `Includes` 引入其它名录文件(路径相对本文件): **分文件维护**:用 `Includes` 引入其它名录文件(路径相对本文件):
@@ -111,88 +135,74 @@
## BackupList.txt 语法 ## BackupList.txt 语法
```text ```text
<软件名 或 手写目录> [ :+ <再追加一个目录/软件名> ... ] [ :- <排除模式>[,<排除模式>...] ] [ @<标记> ] [+|-] <软件名 或 绝对路径> [ :: <绝对路径> ] [ :- <模式>[,<模式>...] ] [ :+ <追加项>[,<追加项>...] ]
[ :encrypt | :!encrypt ] [ @ <Key>='<值>' ] [ # 说明 ]
``` ```
### 两种写法(混用没问题) 修饰符必须是**独立的、前后带空白的记号**,所以路径里出现的 `:-`、`C:\a#b` 之类不会被误切。
### 目标(二选一)
| 写法 | 说明 | | 写法 | 说明 |
| --- | --- | | --- | --- |
| `FooClolor` | **软件名**:去 `SoftwareCatalog.psd1` 查目录,**归档名 = 软件名**。一个软件可以挂多个目录 | | `Edge` | **软件名**:去 `SoftwareCatalog.psd1` 查 Slot 组,**归档名 = 软件名** |
| `%UserProfile%\Documents\PowerShell` | **手写目录**:含 `\` `/` 或 `%` 就按路径处理,归档名沿用 `<末级名>_from_<上级路径>` | | `C:\Programs\MiFlash` | **手写路径**:含 `\` `/` 或 `%` 就按路径处理,归档名 = `<末级名>_from_<上级路径用 + 连接>` |
| `FooClolor @pathname` | 软件名 + 强制用路径命名。适合想换到名录体系但暂时不想改归档名的条目 | | `Edge @pathname` | 软件名 + 强制用路径命名(想换到名录体系但暂时不想改归档名时用) |
### 行首方向标记
| 标记 | 作用 | | 标记 | 作用 |
| --- | --- | | --- | --- |
| `encrypt` | 用 7z 加密该归档,见下文「加密」 | | `+` | **仅备份,不恢复**(`Restore.ps1` 会跳过它;归档名照旧算"有主"的,不会被报成孤儿) |
| `pathname` | 用路径命名算法而不是软件名 | | `-` | **仅恢复,不备份**(`Backup.ps1` 会跳过它;适合放在别处、必要时才还原的目录) |
| `root=<名>` | **尚未实现**:归档内的根目录始终是源目录名。用了会打印告警,不会静默失效 | | 无 | 既能备份也能恢复(默认) |
### 两种写法都支持追加(`:+`)与排除(`:-`) ### 修饰符
| 记号 | 作用 | | 修饰符 | 等价写法 | 作用 |
| --- | --- | | --- | --- | --- |
| `:+` | **追加**一个目录;写成软件名时会按名录展开成它的全部目录。可以写多个、位置随意,追加进来的目录与主目录一起打进同一个归档 | | `:: <绝对路径>` | `@ Path='<绝对路径>'` | 覆盖 Path(软件名条目只有一个 Slot 时可用) |
| `:-` | **排除**模式(`::` 是历史别名,等价)。`,` 与 `;` 都当分隔符 | | `:- <模式>[,...]` | `@ Exclude='<模式>'` | 排除模式(`,` `;` 都当分隔符),**覆盖**名录里各 Slot 的 Exclude |
| `:+ <追加项>[,...]` | `@ Include='<追加项>'` | 追加项,语法 `<归档内相对路径>:<宿主机绝对路径>`,**覆盖**名录里的 Include |
| `:encrypt` | `@ Encrypt='$true'` | 该条目加密 |
| `:!encrypt` | `@ Encrypt='$false'` | 该条目不加密 |
| `@ <Key>='<值>'` | — | 覆盖名录里的默认字段(目前支持 Path / Exclude / Include / Encrypt) |
兼容的历史写法仍然认:`@encrypt` / `@!encrypt` / `@pathname` / `@root=<名>`(`root=` 已废弃,只会打印告警)。
```text ```text
# 软件名 + 追加 + 排除 # 软件名:用名录里的 Slot 与排除;再把额外目录放进包内 Modules\ 位置
scoop :- !*Cache :+ D:\scoop-extra 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 的根**(也就是 `<Slot>\` 里面那一层):
行尾的 ` # 说明` 会被解析出来,运行时和目录介绍一起打印: | 形态 | 展开成 | 说明 |
| --- | --- | --- |
| `<相对路径>` | `-x!<Slot>\<相对路径>` | 锚定在归档根下这一份 |
| `!<通配>` | `-xr!<通配>` | **任意层级**按组件名匹配,`*` `?` 是 7z 通配符(不是正则) |
| `!re:<正则>` | 若干 `-x!<完整路径>` | **正则**:脚本自己遍历源目录把命中的路径展开成精确排除项 |
| `GlobalPersist\steam` | `-x!GlobalPersist\steam` | 第一段是 Slot 名时,只作用在那一个 Slot 上 |
```text - `!*Cache` 一次覆盖 `Cache` / `Code Cache` / `GPUCache` / `DaemonCache` 等一批以 Cache 结尾的组件名。
Edge :- !*Cache,!Crashpad # 缓存与崩溃转储都可再生,不进归档 - 模式里**不要自己写引号**;模式里的空格会被自动转成 `?`(7z 的 `-x!` 不接受带空格的模式)。
``` - 想把 `.log` 之类按正则排除就写 `!re:.*\.log$`;命中的目录会整棵剪掉,命中数超过 300 条会明确报错
(命令行长度有限),这时应该改用更粗的通配模式。
`#` 必须前面有空白才算注释,所以路径里的 `C:\a#b` 不受影响。 - 不带 `!` 的模式是**锚定**的:Edge 的 `OneAuth\WebView2\EBWebView\` 里还有一整套自己的
`Crashpad` / `BrowserMetrics` / `ProvenanceData` / `optimization_guide`,锚定模式碰不到它们,
### 运行时会把每个条目的目录逐条介绍出来 这些可再生的东西一律用 `!<组件名>` 才会在任意层级命中。
目录介绍来自 `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\`。
实测效果(本机真实 Edge 配置,源 4619.9 MB): 实测效果(本机真实 Edge 配置,源 4619.9 MB):
@@ -204,36 +214,178 @@ Edge :- !*Cache,!Crashpad # 缓存与崩溃转储都可再生,不进归档
书签、密码(`Login Data`)、`Cookies`、偏好、历史、`IndexedDB`、`Local Storage` 全部保留; 书签、密码(`Login Data`)、`Cookies`、偏好、历史、`IndexedDB`、`Local Storage` 全部保留;
缓存、组件缓存、Service Worker、扩展本体、遥测与优化数据全部排除。 缓存、组件缓存、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 目录 | `<Slot>\<该 Path 的内容>` |
| 软件名 + Slot 文件 | 一个名为 `<Slot>` 的文件(没有扩展名,恢复时还原成 Path 里的原名) |
| 手写路径(目录) | `<路径末级名>\...`(与历史归档一致) |
| 手写路径(文件) | 一个名为 `<路径末级名>` 的文件 |
| `:+` / `Include` 追加项 | 你写的那个 `<归档内相对路径>`(目录就是目录,文件就是那个文件) |
7z 没有"入库时改名"的能力,所以打包前会建一个**暂存目录**:目录项用 junction、
文件项用硬链接(不可用时退回复制)按归档内的名字挂进去,打完立刻拆掉。
建不出连接点时会**明确报错**,不会悄悄换成另一种布局——布局一变恢复就对不上了。
### 归档名
| 条目类型 | 归档名 | | 条目类型 | 归档名 |
| --- | --- | | --- | --- |
| 软件名 | `<软件名>.7z` | | 软件名 | `<软件名>.7z` |
| 字面路径 | `<末级名>_from_<上级路径用 + 连接>.7z` | | 字面路径 | `<末级名>_from_<上级路径用 + 连接>.7z`(`:` 归一化成 `_`) |
| 软件名 + `@pathname` | 同字面路径 | | 软件名 + `@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` `CREATOR OWNER`(`S-1-3-0`)不是账户,是**访问检查时才替换的占位符** —— 替换成
> 数组条目后,归档名从两个变成 `scoop.7z`;旧的 `scoop-config.7z` / `scoop-persist.7z` "被检查对象的属主"。所以这句话的真实含义是「谁创建的东西谁有全权」。只回放 ACE 文本、
> 就**没有清单条目指向了**(会出现在孤儿归档审计里)。确认新的 `scoop.7z` 校验通过之后 不恢复属主,等于把里面的"谁"换成了跑恢复脚本的账户,**原程序(服务账户 / 专用用户)
> 再删旧的 —— 重命名工具只改名,不会合并归档内容。 反而没了读写权限**。真机实测(`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` 把归档里**该目标对应的那棵子树**解到目标的父目录,覆盖同名文件。 - 每一项只解出**它自己那棵子树**(`<Slot>` / `<末级名>`),不会把兄弟项也复制到别的父目录下。
- **归档内部布局与历史完全一致**:顶层仍是源目录名(软件名只用于归档文件名)。 - **目录项**:在目标的父目录下建一个指向目标目录的 junction,让 7z 直接写穿它落地(零拷贝),
所以恢复逻辑不需要"剥掉一层",现有归档也不会因为重命名而解不开。 解完立刻拆掉连接点。建不出连接点(父目录里已有同名实体、目标卷不支持等)时,
- **一个条目挂多个目录时,每个目录只还原自己那棵子树**,不会把兄弟目录也复制到别的父目录下。 退回"先解到临时目录再逐项合并"——只慢不错。
- **文件项**:解到临时目录后把文件搬到 `Path` 指定的位置(恢复原名)。
- **旧布局兜底**:归档里没有该 Slot 时(重构前的归档)会打印告警,退回到旧布局
(把目标的末级名直接解到目标的父目录),与重构前的恢复语义一致。
- **不做镜像同步**:目标目录里多出来的文件不会被删除。想得到"完全等于归档"的目录,请先清空目标。 - **不做镜像同步**:目标目录里多出来的文件不会被删除。想得到"完全等于归档"的目录,请先清空目标。
- 行首 `+`(仅备份)的条目不恢复;行首 `-`(仅恢复)的条目照常恢复。
- 目标目录比归档新时**默认跳过**,需要覆盖就加 `-Force`。 - 目标目录比归档新时**默认跳过**,需要覆盖就加 `-Force`。
- `-WhatIf` / `-DryRun` 只打印计划;`-VerifyOnly` 只跑 `7z t`。 - `-WhatIf` / `-DryRun` 只打印计划;`-VerifyOnly` 只跑 `7z t`。
这三种模式**一个字节都不写**(`manifest.json` 也不会被碰)。 这三种模式**一个字节都不写**(`manifest.json` 也不会被碰)。
- 加密归档取不到口令时**直接失败**,不会让 7z 停在控制台等输入(在计划任务里那会静默挂起)。
- **排除规则只在下一份归档里生效**:已经生成的归档不会因为改了排除表而"变干净"。 - **排除规则只在下一份归档里生效**:已经生成的归档不会因为改了排除表而"变干净"。
## manifest.json ## manifest.json
@@ -244,7 +396,8 @@ Edge :- !*Cache,!Crashpad # 缓存与崩溃转储都可再生,不进归档
| --- | --- | | --- | --- |
| `source` | 清单里的原始写法(软件名或路径) | | `source` | 清单里的原始写法(软件名或路径) |
| `resolvedSource` | 展开后的路径 | | `resolvedSource` | 展开后的路径 |
| `roots` | 归档内**真实**的顶层条目名(就是源目录 / 源文件名;只统计真实存在的源)。每次重新处理该条目时刷新 | | `roots` | 归档内**真实**的顶层条目名(就是 Slot 名 / 源目录名 / 追加项的归档内路径;只统计真实存在的项)。每次重新处理该条目时刷新 |
| `layouts` | 每个归档项的 `{ name, kind }`(`dir` / `file`),恢复端在目标还不存在时靠它判断"该还原成目录还是文件" |
| `catalog` | 名录里记录的路径(便于追溯软件名到底指向哪) | | `catalog` | 名录里记录的路径(便于追溯软件名到底指向哪) |
| `archive` | 归档文件名 | | `archive` | 归档文件名 |
| `action` | `backed-up` / `skip-unchanged` / `missing-source` / `invalid-path` / `failed` / `planned` | | `action` | `backed-up` / `skip-unchanged` / `missing-source` / `invalid-path` / `failed` / `planned` |
@@ -314,9 +467,12 @@ Edge :- !*Cache,!Crashpad # 缓存与崩溃转储都可再生,不进归档
默认关闭 —— 一旦开启而口令丢失,备份就再也解不开。 默认关闭 —— 一旦开启而口令丢失,备份就再也解不开。
```powershell ```powershell
# 方式一:只为个别条目加密(.ssh 里是私钥,最典型) # 方式一:给某个 Slot 加密(私钥、浏览器数据这类最典型)
# 在 BackupList.txt 里写成: # SoftwareCatalog.psd1:
# .ssh @encrypt # OpenSSH = @{ DefaultData = @{ Path = '%UserProfile%\.ssh'; Encrypt = $true } }
# 或在 BackupList.txt 的条目上写:
# Edge :encrypt
# PowerShell @ Encrypt='$false' # 反过来,关掉名录里的默认加密
# 方式二:全部加密,改配置 # 方式二:全部加密,改配置
# Encryption = @{ Enabled = $true; PasswordFile = 'D:\secret\baknret.key' } # Encryption = @{ Enabled = $true; PasswordFile = 'D:\secret\baknret.key' }
@@ -326,7 +482,8 @@ $env:BAKNRET_PASSWORD = '...' # 或
.\Backup.ps1 -KeyFile 'D:\secret\baknret.key' # 文件首行即口令 .\Backup.ps1 -KeyFile 'D:\secret\baknret.key' # 文件首行即口令
``` ```
要求加密但取不到口令时,该条目会**明确失败**,绝不会退化成明文归档。 一个软件一个归档:名录里各 Slot 的 `Encrypt` 不一致时,**整个归档按加密处理**(宁可多加密,不可漏加密),
并打印告警。要求加密但取不到口令时,该条目会**明确失败**,绝不会退化成明文归档。
恢复加密归档时同理:取不到口令就直接失败,不会让 7z 停在控制台等待输入(在计划任务里那会静默挂起)。 恢复加密归档时同理:取不到口令就直接失败,不会让 7z 停在控制台等待输入(在计划任务里那会静默挂起)。
> ⚠️ 7-Zip 只接受命令行口令,口令在本机进程列表里会短暂可见。这是 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`** 的端到端与回归 | | **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 | 49 项:同样的单元面,适合没装 Pester 的机器 | | 零依赖套件 | `.\tests\Run-Tests.ps1` | 只要 PowerShell + 7z | 101 项:同样的单元面,适合没装 Pester 的机器 |
| 端到端验收 | `.\tests\Run-E2E.ps1` | 只要 PowerShell + 7z | 23 项:备份 → 确认排除生效 → 删源 → 恢复 → 逐字节对拍 | | 端到端验收 | `.\tests\Run-E2E.ps1` | 只要 PowerShell + 7z | 36 项:备份 → 确认排除生效 → 删源 → 恢复 → 逐字节对拍,含 `<Slot>\` 布局、文件 Slot、方向标记与旧布局回退 |
| **真实归档恢复演练** | `.\tests\Restore-Drill.ps1` | 只要 PowerShell + 7z | 把 `Backups/` 里**真实的那批归档**解到临时目录,再和活源逐字节对拍(全程不碰真实目录) | | **真实归档恢复演练** | `.\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` 继承控制台启动,退出码可靠 | | `Start-Process -PassThru` 的 `ExitCode` 在 PowerShell 7.7.0-preview.4 上恒为 `$null` | 压缩明明成功却报"压缩失败",`exit 2 → 删档重试` 的自愈分支永远不可达 | 用 `.NET Process` 继承控制台启动,退出码可靠 |
| 排除模式写成 `-x!"路径"` | 引号成为模式的一部分,**排除对所有条目都失效** | 不再嵌引号;含空格自动转 `?`,`!` 前缀走 `-xr!` | | 排除模式写成 `-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` 校验 → 原子替换 | | 直接更新已有归档(7z `u`) | 固实归档下收益极小,且排除规则与"源里已删的文件"永远反映不到归档里 | 临时文件 → `7z t` 校验 → 原子替换 |
| 没有校验、没有记录 | 中断留下的半个归档会被下次 `u` 续写;跳过/失败只有一行滚过去的 WARN | 校验 + 原子替换 + `manifest.json` + 日志文件 | | 没有校验、没有记录 | 中断留下的半个归档会被下次 `u` 续写;跳过/失败只有一行滚过去的 WARN | 校验 + 原子替换 + `manifest.json` + 日志文件 |
| 结尾不 `exit` | 全部失败也返回 0,计划任务永远显示成功 | 有失败返回 1 | | 结尾不 `exit` | 全部失败也返回 0,计划任务永远显示成功 | 有失败返回 1 |
@@ -389,12 +555,11 @@ Pester 套件里的端到端用例是**用子进程**跑 `Backup.ps1` / `Restore
| 恢复没有干跑 | 直接覆盖 `E:\CodeSpace`、Edge User Data 这类真实目录 | `-WhatIf` / `-DryRun` / `-VerifyOnly` / `-Only` | | 恢复没有干跑 | 直接覆盖 `E:\CodeSpace`、Edge User Data 这类真实目录 | `-WhatIf` / `-DryRun` / `-VerifyOnly` / `-Only` |
| `manifest.json` 的 `roots` | 记的是软件名,与归档里真实的顶层目录对不上(`Edge` vs `User Data`) | 记归档内真实的顶层条目名,并且和归档内容对账过 | | `manifest.json` 的 `roots` | 记的是软件名,与归档里真实的顶层目录对不上(`Edge` vs `User Data`) | 记归档内真实的顶层条目名,并且和归档内容对账过 |
| `-DryRun` / `-WhatIf` / `-VerifyOnly` | 仍然写回 `manifest.json`,违背"不会写入任何文件" | 只有真的恢复成功了才写回(用 manifest 的 SHA256 前后对比验证) | | `-DryRun` / `-WhatIf` / `-VerifyOnly` | 仍然写回 `manifest.json`,违背"不会写入任何文件" | 只有真的恢复成功了才写回(用 manifest 的 SHA256 前后对比验证) |
| 孤儿归档 | 只在恢复时列一下;带 `-Only` 时还会把未选中的归档误报成孤儿,吓得人不敢删 | 备份端也做孤儿审计;`-Only` / `-Skip` 时不再误报 | | 孤儿归档 | 只在恢复时列一下;带 `-Only` 时还会把未选中的归档误报成孤儿,吓得人不敢删 | 备份端也做孤儿审计;`-Only` / `-Skip` 时不再误报;`+` / `-` 的条目也算"有主" |
| `Resolve-BackupEntry` 里的 `$rootName` | 在赋值之前就被引用,会读到外层作用域残留的值 | 提前赋值,回归测试钉死 | | 手写目录的 `:+` 追加 | 被整段丢掉(只有软件名写法才生效),既没人报错也没人知道 | 两种写法都生效,追加项还会标出来源(名录 / 追加项) |
| 手写目录的 `:+` 追加 | 被整段丢掉(只有软件名写法才生效),既没人报错也没人知道 | 两种写法都生效,追加项还会标出来源(名录展开 / 字面路径) | | 软件名录的多目录写法 | 一个软件可以挂多个目录,但目录名不能重复,否则包内混成一棵树 | 改成 **Slot 结构**:每个 Slot 是包内一层目录,同名目录(两个 `persist`)不再冲突 |
| 软件名录的多目录写法 | 只有 `@{ Dirs = @(...) }`,没有"这个目录是干什么的" | 支持**对象数组**(`Path` + `Description`),运行时逐条介绍 | | 一个条目挂多个目录的恢复 | 把整包解压到每个位置的父目录,会在别的父目录下凭空冒出兄弟目录 | 每个归档项只解出**它自己那棵子树** |
| 多目录条目的恢复 | 把整包解压到每个位置的父目录,会在别的父目录下凭空冒出兄弟目录 | 每个源只解出**它自己那棵子树** | | 归档内路径冲突 | 静默混成一棵树,两边的数据都错 | 打包前明确报错(退出码 1)并提示改 Slot 名 / 归档内相对路径 |
| 同一条目里两个同名目录 | 静默混成一棵树,两边的数据都错 | 打包前明确报错(退出码 1)并提示拆成两个条目 |
| 运行时的可解释性 | 只有一行"开始备份: X" | 逐条打印目录、来源、介绍、排除/追加的出处与理由;备份前还会预估所需空间并判断够不够 | | 运行时的可解释性 | 只有一行"开始备份: X" | 逐条打印目录、来源、介绍、排除/追加的出处与理由;备份前还会预估所需空间并判断够不够 |
| manifest 的 `archive` 字段 | 源不存在的条目也留着归档名,指向一个根本不存在的文件;恢复时白报"归档不存在" | 只在文件真的存在时才写;删掉归档后同步一次就自我纠正 | | manifest 的 `archive` 字段 | 源不存在的条目也留着归档名,指向一个根本不存在的文件;恢复时白报"归档不存在" | 只在文件真的存在时才写;删掉归档后同步一次就自我纠正 |
| 没有名录、manifest、测试、README,不是 git 仓库 | — | 都有 | | 没有名录、manifest、测试、README,不是 git 仓库 | — | 都有 |
@@ -402,20 +567,49 @@ Pester 套件里的端到端用例是**用子进程**跑 `Backup.ps1` / `Restore
## 设计取舍(有意为之,不是遗漏) ## 设计取舍(有意为之,不是遗漏)
- **放弃 7z 的更新模式(`u`)。** 7z 默认固实压缩,`u` 本来就要重压大部分数据,收益很小,却让"排除规则改动"和"源里删掉的文件"永远进不了归档。 - **放弃 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 需要额外管道,在受限环境里会直接失败。 - **不捕获压缩工具的输出。** 结构化记录交给日志与 `manifest.json`;捕获子进程 stdio 需要额外管道,在受限环境里会直接失败。
- **有警告(退出码 1)时不覆盖完整的归档。** 被占用的文件会让 7z 返回 1,此时新归档是**不完整**的。实测 Edge 运行时打包,118 个文件读不到,其中包含 `Login Data`(密码)、`Cookies`、`History`、`Web Data`。所以在位归档完整时脚本**保留它、报失败、退出码 1**,确认可以接受再显式加 `-AcceptWarnings`。 - **有警告(退出码 1)时不覆盖完整的归档。** 被占用的文件会让 7z 返回 1,此时新归档是**不完整**的。实测 Edge 运行时打包,118 个文件读不到,其中包含 `Login Data`(密码)、`Cookies`、`History`、`Web Data`。所以在位归档完整时脚本**保留它、报失败、退出码 1**,确认可以接受再显式加 `-AcceptWarnings`。
- **名录里的路径不存在时,恢复仍然可用。** 源被删掉正是要恢复的场景,所以解析器照旧给出 `Sources`,备份端则据此跳过。 - **名录里的路径不存在时,恢复仍然可用。** 源被删掉正是要恢复的场景,所以解析器照旧给出 `Items`,备份端则据此跳过。
- **源路径不存在只算"跳过",不算失败。** 会以 `missing-source` 记进 manifest。失败只统计真正打不开的条目。 - **源路径不存在只算"跳过",不算失败。** 会以 `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`。 - 路径里本来就含 `+` 或 `_from_` 时,仅靠文件名无法可靠反推路径,此时依赖 `manifest.json`。
- `-Snapshot` 目前是"复制一份带时间戳的副本",不做自动轮转清理(`KeepCount` / `KeepDays` 尚未实现)。 - `-Snapshot` 目前是"复制一份带时间戳的副本",不做自动轮转清理(`KeepCount` / `KeepDays` 尚未实现)。
- 加密归档的常规备份/恢复不依赖 `RAR`;`RAR` 与内置 `ZIP` 分支仅作降级,未做加密支持(ZIP 明确拒绝加密请求)。 - 加密归档的常规备份/恢复不依赖 `RAR`;`RAR` 与内置 `ZIP` 分支仅作降级,未做加密支持(ZIP 明确拒绝加密请求)。
- `Variants`(同名目录分散在多处)当前打包第一个位置;恢复时每个源只解出**它自己那棵子树**,不会把兄弟目录复制到别的父目录下。 内置 ZIP 分支也不支持排除规则(`Compress-Archive` 没有对应开关),只保证内容完整。
- **`root=<名>` 标记尚未实现。** 归档内的根目录始终是源目录名(见「设计取舍」)。7z 命令行没有"入库时改名"的能力;用了该标记会打印告警,不会静默失效。 - **暂存改名需要能建目录连接点(junction)。** 暂存目录在 `%TEMP%`(NTFS 即可),目标源目录跨盘也没问题;
建不出连接点时该条目会明确失败,而不会静默换成别的布局。恢复时的 junction 建不出来会自动退回"临时目录 + 合并"。
- **一个 Slot 只能对应一个目录。** 前缀补全命中多个候选(同名目录分散在多处)时会报错并让你拆成多个 Slot,
而不是任选一个。
- **`!re:` 有量级上限。** 正则命中的路径超过 300 条、或排除参数超过命令行安全长度时会明确失败;
这种场景应改用更粗的通配模式。
- **`root=<名>` 标记已废弃。** 包内的一层目录现在由 Slot 决定;写了该标记只会打印告警。
- **空间只做"预估 + 提示",不做全局拦截。** 备份前会打印预计峰值新增和"够不够"的结论; - **空间只做"预估 + 提示",不做全局拦截。** 备份前会打印预计峰值新增和"够不够"的结论;
不够时**只告警不中断**,真正放不下的条目交给逐条目守卫跳过。`MinFreeSpaceGB` 是告警阈值。 不够时**只告警不中断**,真正放不下的条目交给逐条目守卫跳过。`MinFreeSpaceGB` 是告警阈值。
想稳妥跑完就先腾空间,或用 `-Only` / `-Skip` 分批。 想稳妥跑完就先腾空间,或用 `-Only` / `-Skip` 分批。
- **恢复安全描述符需要管理员(或 SYSTEM)。** 非提权时属主写不进去(`SeRestorePrivilege`
不在令牌里),脚本会退化到"只恢复 DACL"并明确告警 —— 那不是失败,但 `CREATOR OWNER`
会判给"当前属主",所以依赖它的程序可能仍然没权限。
- **`acl.json` 要跟归档一起搬。** 它不在归档里(7z 装不下),改名 / 迁移归档时要用
`tools\Rename-Archives.ps1` 或手工把同名旁挂文件一起改。
- **7z 会跟随 junction**(不是存成链接,因为 `-snl` 只对 WIM/TAR 生效):所以 scoop 那种
`apps\<app>\current` 的连接点,备份时会把目标内容一并收进归档(体积翻倍),恢复后
`current` 变成**真实目录**。功能上仍然可用(`current\bin\...` 路径还在),但要心里有数。
- **跨机恢复要配 `Security.SidMap`**:本机不存在的 SID 写进 DACL 是安全的(那条 ACE 只是
永不匹配),但写进**属主**会让谁都没有合理所有权 —— 换域 / 换机时请给映射,或接受
"属主未恢复"的告警。服务账户(`NT SERVICE\X`)的 SID 是按名字算出来的,跨机一致。
+354 -29
View File
@@ -16,6 +16,10 @@
真实目录的破坏性操作,必须能先看清单再决定。 真实目录的破坏性操作,必须能先看清单再决定。
4. 支持 -Only / -Skip 只恢复指定条目,-VerifyOnly 只校验不写盘。 4. 支持 -Only / -Skip 只恢复指定条目,-VerifyOnly 只校验不写盘。
5. 结尾按失败数 exit。 5. 结尾按失败数 exit。
6. 清单行首 `+`(仅备份)的条目会跳过;`-`(仅恢复)的条目照常恢复。
7. 归档内的一层目录由 SoftwareCatalog 的 Slot 决定(`<Slot>\<内容>`),
恢复时只解出该 Slot 那棵子树,并通过"目标父目录下的 junction"直接落地
(零拷贝;建不出连接点时退回先解到临时目录再合并)。
#> #>
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
@@ -48,7 +52,11 @@ param(
# 只对归档做 7z t 校验,不解压 # 只对归档做 7z t 校验,不解压
[Parameter()] [Parameter()]
[switch]$VerifyOnly [switch]$VerifyOnly,
# 不恢复安全描述符(属主 / ACL):默认会按 <归档名>.acl.json 回放
[Parameter()]
[switch]$SkipSecurity
) )
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
@@ -157,25 +165,32 @@ function Get-7zExecutable {
return $sevenZip return $sevenZip
} }
function Invoke-Extraction { function Invoke-ExtractionRaw {
param([object]$ArchiveFile, [string]$DestinationPath, [string]$RelativePath) <#
.SYNOPSIS
把归档里某个子树解到指定目录,不关心"落地"问题。
.DESCRIPTION
归档布局:软件名条目是 `<Slot>\...`(Slot 就是归档内的一层目录),
手写路径条目是 `<源目录名>\...`。这里只负责把指定的那条路径解出来。
#>
param(
[Parameter(Mandatory = $true)][object]$ArchiveFile,
[Parameter(Mandatory = $true)][string]$Destination,
[string]$RelativePath,
[string]$Password
)
$extension = $ArchiveFile.Extension.ToLower() $extension = $ArchiveFile.Extension.ToLower()
$destParent = Split-Path -Path $DestinationPath -Parent if (-not (Test-Path -LiteralPath $Destination)) {
New-Item -ItemType Directory -Path $Destination -Force | Out-Null
if (-not (Test-Path -LiteralPath $destParent)) {
New-Item -ItemType Directory -Path $destParent -Force | Out-Null
} }
# 归档布局与历史保持一致:顶层就是**源目录名**(软件名只用于归档文件名)。
# 一个条目可能打包了好几个目录(软件名录里的数组写法 / `:+` 追加),
# 所以**不能整包往每个目标里倒** —— 那会把兄弟目录也复制到不相干的父目录下。
# 这里只解出该目标自己那棵子树($RelativePath),其余不动。
$sevenZip = Get-7zExecutable $sevenZip = Get-7zExecutable
if ($sevenZip) { if ($sevenZip) {
Write-Log '使用 7z 解压' -Level DEBUG Write-Log '使用 7z 解压' -Level DEBUG
$argument = @('x', '-bsp2', '-y', "-o$destParent") $argument = @('x', '-bsp2', '-y', "-o$Destination")
if ($password) { $argument += "-p$password" } if ($Password) { $argument += "-p$Password" }
$argument += $ArchiveFile.FullName $argument += $ArchiveFile.FullName
if ($RelativePath) { $argument += $RelativePath } if ($RelativePath) { $argument += $RelativePath }
@@ -189,7 +204,7 @@ function Invoke-Extraction {
$rarExe = Get-Command rar, unrar -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source $rarExe = Get-Command rar, unrar -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source
if (-not $rarExe) { throw '未找到 RAR 工具' } if (-not $rarExe) { throw '未找到 RAR 工具' }
Write-Log '使用 RAR 解压' -Level DEBUG Write-Log '使用 RAR 解压' -Level DEBUG
$argument = @('x', '-idp', '-idn', '-y', $ArchiveFile.FullName, "$destParent\") $argument = @('x', '-idp', '-idn', '-y', $ArchiveFile.FullName, "$Destination\")
if ($RelativePath) { $argument += $RelativePath } if ($RelativePath) { $argument += $RelativePath }
$exitCode = Invoke-ExternalCommand -FilePath $rarExe -ArgumentList $argument $exitCode = Invoke-ExternalCommand -FilePath $rarExe -ArgumentList $argument
if ($exitCode -ne 0) { throw "RAR 解压失败(退出码:$exitCode)" } if ($exitCode -ne 0) { throw "RAR 解压失败(退出码:$exitCode)" }
@@ -199,13 +214,13 @@ function Invoke-Extraction {
if ($RelativePath) { if ($RelativePath) {
Write-Log "内置 ZIP 不支持只解子树,将整包解压($RelativePath)" -Level WARN Write-Log "内置 ZIP 不支持只解子树,将整包解压($RelativePath)" -Level WARN
} }
Expand-Archive -LiteralPath $ArchiveFile.FullName -DestinationPath $destParent -Force Expand-Archive -LiteralPath $ArchiveFile.FullName -DestinationPath $Destination -Force
} }
'.tar' { '.tar' {
$tarExe = Get-Command tar -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source $tarExe = Get-Command tar -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source
if (-not $tarExe) { throw '未找到 TAR 工具' } if (-not $tarExe) { throw '未找到 TAR 工具' }
Write-Log '使用 TAR 解压' -Level DEBUG Write-Log '使用 TAR 解压' -Level DEBUG
$argument = @('-xf', $ArchiveFile.FullName, '-C', $destParent) $argument = @('-xf', $ArchiveFile.FullName, '-C', $Destination)
if ($RelativePath) { $argument += $RelativePath } if ($RelativePath) { $argument += $RelativePath }
$exitCode = Invoke-ExternalCommand -FilePath $tarExe -ArgumentList $argument $exitCode = Invoke-ExternalCommand -FilePath $tarExe -ArgumentList $argument
if ($exitCode -ne 0) { throw "TAR 解压失败(退出码:$exitCode)" } if ($exitCode -ne 0) { throw "TAR 解压失败(退出码:$exitCode)" }
@@ -215,6 +230,210 @@ function Invoke-Extraction {
return $true return $true
} }
function Invoke-ExtractionByLayout {
<#
.SYNOPSIS
按**当前归档布局**(软件名条目 = `<Slot>\<内容>`)解出一个归档项并落到目标位置。
.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 布局(`<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 $lines = Get-Content -LiteralPath $BackupListPath -ErrorAction Stop
$stats = @{ restored = 0; skipped = 0; failed = 0; verified = 0; planned = 0 } $stats = @{ restored = 0; skipped = 0; failed = 0; verified = 0; planned = 0 }
$securityApplied = 0 # 本次回放成功的安全描述符对象数
$failures = @() $failures = @()
$referencedArchives = @() $referencedArchives = @()
@@ -307,6 +527,23 @@ foreach ($line in $lines) {
if (-not $baseName) { $stats.skipped++; continue } if (-not $baseName) { $stats.skipped++; continue }
if (-not (Test-EntrySelected -DisplayPath $displayPath -BaseName $baseName)) { 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 $found = Get-ArchiveForEntry -Entry ([pscustomobject]@{ baseName = $baseName }) -Manifest $manifest
if (-not $found) { if (-not $found) {
Write-Log "跳过: $displayPath,未找到归档 $baseName" -Level WARN Write-Log "跳过: $displayPath,未找到归档 $baseName" -Level WARN
@@ -314,33 +551,67 @@ foreach ($line in $lines) {
continue continue
} }
# 恢复目的地。一个条目可能带多个源(软件名录里的数组写法、`:+` 追加、 # "是目录还是文件"的判据,按可靠性排序:
# 或同名目录分散在多处),每个源只还原**它自己那棵子树**。 # 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
}
}
# 恢复目的地:一个条目可以挂多个归档项(名录里多个 Slot、`:+` 追加),
# 每一项只还原**它自己那棵子树**,不会把兄弟项也复制过去。
$targets = @() $targets = @()
if ($resolved.Sources.Count -gt 0) { foreach ($entryItem in @($resolved.Items)) {
foreach ($source in $resolved.Sources) { $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]@{ $targets += [pscustomobject]@{
DestPath = $source.SourcePath ArchivePath = [string]$entryItem.ArchivePath
RelativePath = @($source.RelativePaths)[0] RealPath = $dest
Description = $source.Description DestPath = $dest
Origin = $source.Origin IsFile = $isFile
Description = $entryItem.Description
Origin = $entryItem.Origin
} }
} }
} else {
# 兜底:解析不出归档项时按字面路径处理(历史清单里的裸路径)
if ($targets.Count -eq 0 -and -not $resolved.IsName) {
$expanded = [Environment]::ExpandEnvironmentVariables($displayPath) $expanded = [Environment]::ExpandEnvironmentVariables($displayPath)
if (-not [string]::IsNullOrWhiteSpace($expanded)) {
$targets += [pscustomobject]@{ $targets += [pscustomobject]@{
ArchivePath = (Split-Path -Path $expanded -Leaf)
RealPath = $expanded
DestPath = $expanded DestPath = $expanded
RelativePath = (Split-Path -Path $expanded -Leaf) IsFile = (Test-Path -LiteralPath $expanded -PathType Leaf)
Description = $null Description = $null
Origin = 'path' Origin = 'path'
} }
} }
}
# 防御:解析不出目的地时明确失败,别把空字符串喂给 Split-Path/Test-Path # 防御:解析不出目的地时明确失败,别把空字符串喂给 Split-Path/Test-Path
# (那种报错是 "Cannot bind argument to parameter 'Path' because it is an empty string") # (那种报错是 "Cannot bind argument to parameter 'Path' because it is an empty string")
$targets = @($targets | Where-Object { $_.DestPath -and -not [string]::IsNullOrWhiteSpace($_.DestPath) }) $targets = @($targets | Where-Object { $_.DestPath -and -not [string]::IsNullOrWhiteSpace($_.DestPath) })
if ($targets.Count -eq 0) { if ($targets.Count -eq 0) {
$reason = "无法确定恢复目的地(清单条目 '$displayPath' 解析不出任何源路径)" $reason = "无法确定恢复目的地(清单条目 '$displayPath' 解析不出任何归档项)"
Write-Log "失败: $displayPath,$reason" -Level ERROR Write-Log "失败: $displayPath,$reason" -Level ERROR
$stats.failed++ $stats.failed++
$failures += $displayPath $failures += $displayPath
@@ -415,7 +686,9 @@ foreach ($line in $lines) {
foreach ($target in $plannedTargets) { foreach ($target in $plannedTargets) {
$targetExists = Test-Path -LiteralPath $target.DestPath $targetExists = Test-Path -LiteralPath $target.DestPath
Write-Log (" 目标:{0}" -f $target.DestPath) Write-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) } if ($target.Description) { Write-Log (" 介绍:{0}" -f $target.Description) }
} }
@@ -446,13 +719,64 @@ foreach ($line in $lines) {
$restoreFailed = $false $restoreFailed = $false
try { try {
foreach ($target in $plannedTargets) { 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 $restoreFailed = $true
break break
} }
} }
if (-not $restoreFailed) { 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++ $stats.restored++
Write-Log "恢复成功: $baseName" -Level INFO Write-Log "恢复成功: $baseName" -Level INFO
@@ -515,6 +839,7 @@ if ($failures.Count -gt 0) {
} }
$summaryText = "恢复完成 - 成功:$($stats.restored),跳过:$($stats.skipped),失败:$($stats.failed)" $summaryText = "恢复完成 - 成功:$($stats.restored),跳过:$($stats.skipped),失败:$($stats.failed)"
if ($securityApplied -gt 0) { $summaryText += ",安全描述符:$securityApplied 个对象" }
if ($VerifyOnly) { $summaryText = "校验完成 - 通过:$($stats.verified),失败:$($stats.failed)" } if ($VerifyOnly) { $summaryText = "校验完成 - 通过:$($stats.verified),失败:$($stats.failed)" }
if ($stats.planned -gt 0) { $summaryText += ",试运行计划:$($stats.planned)" } if ($stats.planned -gt 0) { $summaryText += ",试运行计划:$($stats.planned)" }
Write-Log $summaryText -Level INFO Write-Log $summaryText -Level INFO
+207 -132
View File
@@ -1,157 +1,232 @@
<# <#
软件名录:维护"软件名 -> 目录"的映射。 .SYNOPSIS
软件目录清单(SoftwareCatalog)。
有这个文件之后,BackupList.txt 里可以直接写软件名: .DESCRIPTION
定义每个软件在归档内的槽位(Slot)结构,供备份与恢复共用。
一个软件 = 一个归档(归档名就是软件名),包内的顶层目录就是这里定义的 Slot。
FooClolor 结构:
Kazumi :: !*Cache SoftWareName = @{
Edge :: !*Cache,component_crx_cache Slot = @{
.ssh @encrypt Path = 'Absolute\Path'
Exclude = 'Relative\Path'
归档包的名字也就是软件名(`FooClolor.7z`),不再是 Include = 'Relative\Path:Absolute\Path'
`FooClolor_from_C_+Programs.7z` 这种由路径拼出来的名字。 Encrypt = $false
Description = 'Some information about this slot.'
写法:
<软件名> = '<目录>'
软件名的限制:
* 必须是合法的文件名(不能含 \ / : * ? " < > |),因为它就是归档名;
* 不能含 `\` 或 `/` 或 `%`,否则会被当作字面路径而不是软件名;
* **含 `-` 或 `.` 的名字必须写成带引号的键**,否则 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')
...
} }
}
.NOTES
字段说明:
SoftWareName 软件名称。不含空格,遵循驼峰大小写。
Slot 插槽。归档内的一层目录:内容进 `<Slot>\`;
Path 是文件时,存成名为 `<Slot>` 的文件本身。
同一软件里不能有两个同名 Slot。规则同 SoftWareName。
Path 路径。需备份或恢复的来源路径,为宿主机上的绝对路径。
Exclude 排除。不需备份的目录,为压缩包内的相对路径。
多个以逗号分隔。相对于本 Slot 的根(即归档内的 `<Slot>\`)。
以“!”打头即“任意层级匹配”(7z 的 -xr!,通配符 `*` / `?`);
要按正则排除写成 `!re:<正则>`(脚本自己展开成精确路径)。
Include 包含。需要追加的目录。
语法:<压缩包内相对路径>:<宿主机绝对路径>。
多个以逗号分隔。
Encrypt 是否加密此归档。默认:$false。
同一个条目里各 Slot 不一致时,整个归档按加密处理。
Description 描述。
#> #>
@{ @{
# 每个条目有两种写法: AutoDarkMode = @{
# 1. 只写一个目录字符串: legendary = '%UserProfile%\.config\legendary' DefaultData = @{
# 2. 带目录介绍(推荐): Path = '%AppData%\AutoDarkMode'
# legendary = @{ Encrypt = $true
# Path = '%UserProfile%\.config\legendary' Description = 'AutoDarkMode 数据。'
# Description = 'Legendary(Epic 的开源客户端)的配置与已安装记录'
# }
# 一个软件包含**多个目录**时,写成对象数组(见下面的 scoop)。
# 运行时会把"这个条目打包哪些目录、每个目录是干什么的、排除了什么、为什么"
# 逐条打印出来,说明就来自这里。
# ---- 用户配置 / 开发环境 ----
# 含 `-` 或 `.` 的键必须加引号,否则会被当成减法表达式(见文件开头说明)
legendary = @{
Path = '%UserProfile%\.config\legendary'
Description = 'Legendary(Epic 的开源客户端)的配置与已安装记录'
} }
opencode = @{
Path = '%UserProfile%\.config\opencode'
Description = 'opencode 的配置'
} }
# 一个软件 = 一个归档;多个目录写成**对象数组**,每个目录各自带说明。 DeepSeekHarness = @{
# 注意:归档内的顶层名字就是**目录自己的名字**,所以同一个软件里不能有两个同名目录 DefaultData = @{
# (例如两个 persist)—— 那会在包里混成一棵树,脚本会明确报错让你拆成两个条目。 Path = '%UserProfile%\.dsh'
scoop = @( Encrypt = $true
@{ Description = 'DSH(深度求索)数据。'
Path = '%UserProfile%\scoop\persist'
Description = 'scoop 里各应用的持久化数据(重装应用就会丢,必须备份)'
} }
@{
Path = '%UserProfile%\.config\scoop'
Description = 'scoop 自身的配置(源、代理、已安装清单)'
}
)
'.ssh' = @{
Path = '%UserProfile%\.ssh'
Description = 'SSH 私钥 / 公钥 / known_hosts(不可再生;要加密就给清单里那行加 @encrypt)'
}
CodeSpace = @{
Path = 'D:\UserData\Documents\CodeSpace'
Description = '开发代码目录'
}
PowerShell = @{
Path = '%UserProfile%\Documents\PowerShell'
Description = 'PowerShell 7 的用户配置与模块'
}
WindowsPowerShell = @{
Path = '%UserProfile%\Documents\WindowsPowerShell'
Description = 'Windows PowerShell 5.1 的用户配置与模块'
} }
# ---- 应用数据 ---- DSHDesktop = @{
AutoDarkMode = @{ Path = '%AppData%\AutoDarkMode'; Description = 'AutoDarkMode 的主题/时间设置' } DefaultData = @{
Kazumi = @{ Path = '%AppData%\com.example\Kazumi'; Description = 'Kazumi 的观看记录与设置' } Path = '%AppData%\dsh-desktop'
piliplus = @{ Path = '%AppData%\com.example\piliplus'; Description = 'piliplus 的设置与账号数据' } Encrypt = $true
fnm = @{ Path = '%AppData%\fnm'; Description = 'fnm(Node 版本管理器)的版本记录' } Description = 'DSH 桌面版数据。'
'twinkle-tray' = @{ Path = '%AppData%\twinkle-tray'; Description = 'Twinkle Tray 的显示器亮度设置' } }
}
# ---- 浏览器与终端 ---- MicrosoftEdge = @{
# Edge 的缓存/扩展本体等可再生内容由 BackupList.txt 的 :- 排除规则挡掉 DefaultData = @{
Edge = @{
Path = '%LocalAppData%\Microsoft\Edge\User Data' Path = '%LocalAppData%\Microsoft\Edge\User Data'
Description = 'Edge 用户数据:书签、密码、Cookies、历史、站点数据' 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'
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 里各用户应用的持久化数据。'
} }
WindowsTerminal = @{
Path = '%LocalAppData%\Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json'
Description = 'Windows Terminal 的设置文件'
} }
# ---- 系统 ----
Startup = @{ Startup = @{
GlobalLink = @{
Path = '%ProgramData%\Microsoft\Windows\Start Menu\Programs\Startup' Path = '%ProgramData%\Microsoft\Windows\Start Menu\Programs\Startup'
Description = '全局开机启动项(快捷方式)' Description = '全局开机启动项(快捷方式)。'
}
UserLink = @{
Path = '%AppData%\Microsoft\Windows\Start Menu\Programs\Startup'
Description = '用户开机启动项(快捷方式)。'
}
} }
# ---- C:\Programs ---- SteamRomManager = @{
BaiduNetdisk = @{ Path = 'C:\Programs\BaiduNetdisk'; Description = '百度网盘客户端' } DefaultData = @{
# FooClolor 的具体用途不明确,先不加介绍(没有 Description 也不会影响打包) Path = '%AppData%\steam-rom-manager'
FooClolor = 'C:\Programs\FooClolor' Description = 'Steam Rom Manager 数据。'
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,所以不能并进同一个归档'
} }
# ---- 其它盘 ---- TranslucentTB = @{
Aria = @{ Path = 'D:\UserData\Documents\Aria'; Description = 'Aria 下载器的配置与任务' } ScoopData = @{
Path = '$(scoop prefix translucenttb)\settings.json'
Description = 'TranslucentTB 的设置文件(Scoop 安装)。'
}
}
TwinkleTray = @{
DefaultData = @{
Path = '%AppData%\twinkle-tray'
Description = 'Twinkle Tray 数据。'
}
}
WindowsPowerShell = @{
DefaultData = @{
Path = '%UserProfile%\Documents\WindowsPowerShell'
Encrypt = $true
Description = 'Windows PowerShell 5.1 的用户配置与模块。'
}
}
WindowsTerminal = @{
DefaultData = @{
Path = '%LocalAppData%\Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json'
Encrypt = $true
Description = 'Windows Terminal 的设置文件。'
}
}
} }
+46
View File
@@ -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(事件溯源订单)冲突,但值得重开,因为……
+33
View File
@@ -0,0 +1,33 @@
# 议题追踪:本地 Markdown
本仓库的 issue 与 spec 都是 `.scratch/` 下的 markdown 文件。没有远程追踪器,也没有 CLI 依赖。
## 约定
- 一个特性一个目录:`.scratch/<feature-slug>/`
- spec 是 `.scratch/<feature-slug>/spec.md`
- 实现类 issue **一个 ticket 一个文件**:`.scratch/<feature-slug>/issues/<NN>-<slug>.md`,
从 `01` 编号,不要写成一个合并的 tickets 文件
- 分诊状态记在每个 issue 文件靠近顶部的 `Status:` 行
- 评论与对话历史追加到文件底部的 `## Comments` 标题下
## 当某个技能说「publish to the issue tracker」
在 `.scratch/<feature-slug>/` 下新建文件(需要就一并建目录)。
## 当某个技能说「fetch the relevant ticket」
读那个路径的文件。用户通常会直接给出路径或 issue 编号。
## Wayfinding(`/wayfinder` 用)
**Map** 是一份文件,每个 ticket 对应一个 **child** 文件。
- **Map**:`.scratch/<effort>/map.md`(Notes / Decisions-so-far / Fog 正文)。
- **Child ticket**:`.scratch/<effort>/issues/NN-<slug>.md`,从 `01` 开始,正文写问题本身;
`Type:` 行记类型(`research`/`prototype`/`grilling`/`task`),`Status:` 行记 `claimed`/`resolved`。
- **Blocking**:靠近顶部写 `Blocked by: NN, NN`;列出的文件全部 `resolved` 才算解锁。
- **Frontier**:扫 `.scratch/<effort>/issues/`,取未关闭、未阻塞、未认领的,编号最小者优先。
- **Claim**:动手前先写 `Status: claimed` 并保存。
- **Resolve**:在 `## Answer` 标题下追加答案,写 `Status: resolved`,
再把一段上下文指针(要点 + 链接)追加到 `map.md` 的 Decisions-so-far。
+353 -175
View File
@@ -1,15 +1,18 @@
<# <#
.SYNOPSIS .SYNOPSIS
清单"两种写法 + 追加/排除"的 Pester 测试:软件名、手写路径,:+/:- 两者都要生效。 清单与名录的"格式契约"Pester 测试:软件名 / 手写路径两种写法,
Slot 形状的 SoftwareCatalog.psd1,以及 `::` / `:-` / `:+` / `:encrypt` / `@ Key='Value'`。
.DESCRIPTION .DESCRIPTION
这里覆盖的是清单/名录的**输入格式**契约: 这里覆盖的是清单/名录的**输入格式与归档布局**契约(重构后的新契约):
* 写法一:直接写 SoftwareCatalog.psd1 里的软件名; * 写法一:直接写 SoftwareCatalog.psd1 里的软件名;
* 写法二:用户手写目录(含 \ / 或 % 就按路径处理); * 写法二:用户手写目录(含 \ / 或 % 就按路径处理);
* 两种写法都要支持 `:+` 追加与 `:-` 排除; * 软件名条目 -> 一个归档,归档内是 `<Slot>\<内容>`;Path 是文件时归档内是名为
* 名录里一个软件可以挂**对象数组**(每个目录带 Description),运行时会逐条介绍; `<Slot>` 的文件(没有扩展名);
* 同一条目里出现两个同名目录时,必须在归档前就明确报错(Blocking), * 手写路径条目 -> 历史布局 `<末级名>\...`,现有清单不需要改写;
而不是把两棵树悄悄混在一起。 * `::` 覆盖 Path(不再是 `:-` 的别名),排除一律写 `:-`;
* `:+` / `@ Include=` 是 `<归档内相对路径>:<宿主机绝对路径>`;
* 同一条目里两个归档项抢同一个包内位置时,必须在归档前就明确报错(Blocking)。
跟 BakNRet.Tests.ps1 一样,脚本调用统一走**子进程**:Backup.ps1 / Restore.ps1 结尾会 跟 BakNRet.Tests.ps1 一样,脚本调用统一走**子进程**:Backup.ps1 / Restore.ps1 结尾会
`exit`,同进程 `&` 调用会把 Pester 宿主一起带走。 `exit`,同进程 `&` 调用会把 Pester 宿主一起带走。
@@ -32,6 +35,8 @@ BeforeAll {
$script:Sandbox = Join-Path $env:TEMP ('baknret-formats-' + [guid]::NewGuid().ToString('N').Substring(0, 8)) $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 New-Item -ItemType Directory -Path $script:Sandbox -Force | Out-Null
# 见 BakNRet.Tests.ps1 里的说明:本机沙箱禁止 PowerShell 为捕获原生子进程输出建管道,
# 所以走"临时 .cmd + 文件重定向 + Invoke-ExternalCommand(继承 stdio)"这条路。
function Invoke-BaknretScript { function Invoke-BaknretScript {
param( param(
[Parameter(Mandatory = $true)][string]$Script, [Parameter(Mandatory = $true)][string]$Script,
@@ -49,9 +54,24 @@ BeforeAll {
if ($value -is [array]) { $arguments += $value } else { $arguments += [string]$value } 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]@{ return [pscustomobject]@{
ExitCode = $LASTEXITCODE ExitCode = $exitCode
Lines = @($lines | ForEach-Object { [string]$_ }) Lines = @($lines | ForEach-Object { [string]$_ })
Output = (($lines | Out-String)) Output = (($lines | Out-String))
} }
@@ -71,7 +91,7 @@ AfterAll {
} }
# ============================================================================ # ============================================================================
Describe '软件名录:对象数组写法' { Describe '软件名录:Slot 形状(新契约)' {
# ============================================================================ # ============================================================================
BeforeAll { BeforeAll {
@@ -84,249 +104,406 @@ Describe '软件名录:对象数组写法' {
New-Item -ItemType Directory -Path $directory -Force | Out-Null New-Item -ItemType Directory -Path $directory -Force | Out-Null
Set-Content -LiteralPath (Join-Path $directory 'keep.txt') "keep-$directory" 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' $script:CollideRoot = Join-Path $script:FormatRoot 'collide'
foreach ($parent in 'p1', 'p2') { foreach ($parent in 'p1', 'p2') {
New-Item -ItemType Directory -Path (Join-Path $script:CollideRoot "$parent\dupdir") -Force | Out-Null 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 Set-Content -LiteralPath (Join-Path $script:CollideRoot "$parent\dupdir\x.txt") $parent
} }
$dirAPath = $script:DirA $script:MissingDir = Join-Path $script:FormatRoot 'not-here'
$dirBPath = $script:DirB
$collideP1 = Join-Path $script:CollideRoot 'p1\dupdir'
$collideP2 = Join-Path $script:CollideRoot 'p2\dupdir'
$script:FormatCatalog = Write-ListFile -Path (Join-Path $script:FormatRoot 'cat.psd1') -Content @" $script:FormatCatalog = Write-ListFile -Path (Join-Path $script:FormatRoot 'cat.psd1') -Content @"
@{ @{
'pair' = @( 'pair' = @{
@{ Path = '$dirAPath'; Description = '第一个目录' } A = @{ Path = '$script:DirA'; Description = '第一个 Slot' }
@{ Path = '$dirBPath'; Description = '第二个目录' } B = @{ Path = '$script:DirB'; Description = '第二个 Slot' }
) }
'collide' = @( 'solo' = @{ Only = @{ Path = '$script:DirA' } }
@{ Path = '$collideP1'; Description = 'p1 里的' } 'partial' = @{ Ok = @{ Path = '$script:DirA' }; Gone = @{ Path = '$script:MissingDir' } }
@{ Path = '$collideP2'; Description = 'p2 里的' } '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') }
$script:MissingDir = Join-Path $script:FormatRoot 'not-here' 'legacystr' = '$script:DirA'
$missingPath = $script:MissingDir
$script:PartialCatalog = Write-ListFile -Path (Join-Path $script:FormatRoot 'cat-partial.psd1') -Content @"
@{
'pair' = @(
@{ Path = '$dirAPath'; Description = '存在' }
@{ Path = '$missingPath'; Description = '不存在' }
)
} }
"@ "@
} }
It '一个软件多个目录:顺序与说明都被保留' { It '一个软件多个 Slot:Kind=Multi,Slot 按名排序且说明被保留' {
$catalog = Get-SoftwareCatalog -Path $script:FormatCatalog -MaxDepth 3 $catalog = Get-SoftwareCatalog -Path $script:FormatCatalog -MaxDepth 3 -NoCache
$catalog['pair'].Kind | Should -Be 'Multi' $catalog['pair'].Kind | Should -Be 'Multi'
@($catalog['pair'].Items).Count | Should -Be 2 @($catalog['pair'].Slots).Count | Should -Be 2
$catalog['pair'].Items[0].Resolved | Should -Be $script:DirA (@($catalog['pair'].Slots | ForEach-Object { $_.Name }) -join ',') | Should -Be 'A,B'
$catalog['pair'].Items[0].Description | Should -Be '第一个目录' $catalog['pair'].Slots[0].Description | Should -Be '第一个 Slot'
$catalog['pair'].Items[1].Description | Should -Be '第二个目录' $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' $entry = ConvertFrom-BackupListLine -Line 'pair'
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3 $resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3
@($resolved.Sources).Count | Should -Be 2 @($resolved.Items).Count | Should -Be 2
$resolved.Sources[0].SourcePath | Should -Be $script:DirA (@($resolved.Items | ForEach-Object { $_.ArchivePath }) -join ',') | Should -Be 'A,B'
$resolved.Sources[0].Description | Should -Be '第一个目录' (@($resolved.Items | ForEach-Object { $_.Kind }) -join ',') | Should -Be 'slot,slot'
$resolved.Sources[1].Description | Should -Be '第二个目录' (@($resolved.Items | ForEach-Object { $_.Origin }) -join ',') | Should -Be 'catalog,catalog'
@($resolved.Sources | ForEach-Object { $_.Origin }) | 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 '数组里"当前不存在"的目录仍然产出源(恢复要靠它还原回原位)' { It 'Slot 级排除写在 Slot 自己身上(相对本 Slot 的归档根)' {
$entry = ConvertFrom-BackupListLine -Line 'pair' $catalog = Get-SoftwareCatalog -Path $script:FormatCatalog -MaxDepth 3 -NoCache
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:PartialCatalog -MaxDepth 3 (@($catalog['slotex'].Slots[0].Exclude) -join '|') | Should -Be '!*Cache|logs\'
@($resolved.Sources).Count | Should -Be 2
@($resolved.Sources | ForEach-Object { $_.SourcePath }) | Should -Contain $script:MissingDir $entry = ConvertFrom-BackupListLine -Line 'slotex'
$resolved.Error | Should -Not -BeNullOrEmpty # 有提示 $resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3
$resolved.Blocking | Should -BeNullOrEmpty # 但不算致命 (@($resolved.Items[0].Exclude) -join '|') | Should -Be '!*Cache|logs\'
$resolved.HasExcludeOverride | Should -BeFalse
} }
It '纯字符串数组写法继续可用' { It '数组里"当前不存在"的 Slot 仍然产出归档项(恢复要靠它还原回原位)' {
$plain = Write-ListFile -Path (Join-Path $script:FormatRoot 'cat-plain.psd1') -Content "@{ 'pair2' = @('$script:DirA', '$script:DirB') }" $catalog = Get-SoftwareCatalog -Path $script:FormatCatalog -MaxDepth 3 -NoCache
$catalog = Get-SoftwareCatalog -Path $plain -MaxDepth 3 $catalog['partial'].Kind | Should -Be 'Partial'
$catalog['pair2'].Kind | Should -Be 'Multi' @($catalog['partial'].Missing) | Should -Contain $script:MissingDir
@($catalog['pair2'].Dirs).Count | Should -Be 2
$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 = @(...) } 写法继续可用' { It '文件 Slot:归档项是文件项(归档里就是名为 Slot 的文件)' {
$legacy = Write-ListFile -Path (Join-Path $script:FormatRoot 'cat-legacy.psd1') -Content "@{ 'pair3' = @{ Dirs = @('$script:DirA', '$script:DirB') } }" $entry = ConvertFrom-BackupListLine -Line 'fileapp'
$catalog = Get-SoftwareCatalog -Path $legacy -MaxDepth 3 $resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3
$catalog['pair3'].Kind | Should -Be 'Multi' @($resolved.Items).Count | Should -Be 1
@($catalog['pair3'].Dirs).Count | Should -Be 2 $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' $entry = ConvertFrom-BackupListLine -Line 'pair'
(Get-ItemArchiveName -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3) | Should -Be 'pair' (Get-ItemArchiveName -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3) | Should -Be 'pair'
} }
} }
# ============================================================================ # ============================================================================
Describe '两种写法都要支持 :+ 追加与 :- 排除' { Describe '清单修饰符:新契约格式' {
# ============================================================================ # ============================================================================
BeforeAll { BeforeAll {
$script:FormatRoot = Join-Path $script:Sandbox 'format' $script:FormatRoot = Join-Path $script:Sandbox 'format'
$script:FormatCatalog = Join-Path $script:FormatRoot 'cat.psd1'
$script:DirA = Join-Path $script:FormatRoot 'dirA' $script:DirA = Join-Path $script:FormatRoot 'dirA'
$script:DirB = Join-Path $script:FormatRoot 'dirB' $script:DirB = Join-Path $script:FormatRoot 'dirB'
$script:FormatCatalog = Join-Path $script:FormatRoot 'cat.psd1'
$script:CollideRoot = Join-Path $script:FormatRoot 'collide' $script:CollideRoot = Join-Path $script:FormatRoot 'collide'
} }
It '软件名写法::+ 追加一个目录' { It ':: 覆盖 Path:单 Slot 条目直接生效' {
$entry = ConvertFrom-BackupListLine -Line "pair :+ $script:CollideRoot" $entry = ConvertFrom-BackupListLine -Line "solo :: $script:DirB"
$entry.Overrides.ContainsKey('Path') | Should -BeTrue
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3 $resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3
@($resolved.Sources).Count | Should -Be 3 @($resolved.Items).Count | Should -Be 1
@($resolved.Sources | ForEach-Object { $_.Origin }) | Should -Contain 'append-path' $resolved.Items[0].ArchivePath | Should -Be 'Only'
} $resolved.Items[0].RealPath | Should -Be $script:DirB
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.Blocking | Should -BeNullOrEmpty $resolved.Blocking | Should -BeNullOrEmpty
} }
It '软件名与手写路径混在一行也认得(主目录是软件名,追加是路径)' { It ':: 覆盖遇到多 Slot 条目 -> Blocking(不知道给哪一个,绝不猜)' {
$entry = ConvertFrom-BackupListLine -Line "pair :+ $script:CollideRoot :- logs\" $entry = ConvertFrom-BackupListLine -Line "pair :: $script:DirB"
$resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3 $resolved = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3
$resolved.IsName | Should -BeTrue @($resolved.Items).Count | Should -Be 0
@($resolved.Sources).Count | Should -Be 3 $resolved.Blocking | Should -Match '不能用一个'
$entry.ExcludePatterns | Should -Be @('logs\')
} }
It '同一条目里出现两个同名目录 -> Blocking(明确报错,不静默混成一棵树)' { It ':- 排除与 :+ 包含并存,顺序任意' {
$entry = ConvertFrom-BackupListLine -Line 'collide' $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 = Resolve-BackupEntry -Entry $entry -CatalogPath $script:FormatCatalog -MaxDepth 3
@($resolved.Sources).Count | Should -Be 2 $resolved.HasExcludeOverride | Should -BeTrue
$resolved.Blocking | Should -Match '顶层同名' $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 '清单行尾的 `# 说明`' { Describe '集成:Slot 布局的打包与恢复' -Skip:(-not ($script:HasSevenZip -and $script:HasPwsh)) {
# ============================================================================
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)) {
# ============================================================================ # ============================================================================
BeforeAll { BeforeAll {
$script:AppendRoot = Join-Path $script:Sandbox 'append-e2e' $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:AppendA = Join-Path $script:AppendRoot 'srcA\dirA' $script:SlotCfgDir = Join-Path $script:SlotRoot 'apps\AppCfg'
$script:AppendB = Join-Path $script:AppendRoot 'srcB\dirB' $script:SlotInclude = Join-Path $script:SlotRoot 'psmodules'
foreach ($directory in $script:AppendA, $script:AppendB) {
New-Item -ItemType Directory -Path $directory -Force | Out-Null 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
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' }
} }
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: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:AppendList = Write-ListFile -Path (Join-Path $script:AppendRoot 'list.txt') ` $script:SlotBackupRun = Invoke-BaknretScript -Script $script:BackupScript -Parameters @{
-Content "$script:AppendA :+ $script:AppendB :- skip.log`n" BackupListPath = $script:SlotList
$script:AppendBackupDir = Join-Path $script:AppendRoot 'Backups' BackupDir = $script:SlotBackupDir
ConfigPath = $script:SlotConfig
$script:AppendBackupRun = Invoke-BaknretScript -Script $script:BackupScript -Parameters @{
BackupListPath = $script:AppendList
BackupDir = $script:AppendBackupDir
Force = $true Force = $true
QuietTool = $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 '备份前会打印空间预估与"够不够"的结论' { It '备份退出码 0,归档名就是软件名' {
$script:AppendBackupRun.Output | Should -Match '备份前空间预估' $script:SlotBackupRun.ExitCode | Should -Be 0
$script:AppendBackupRun.Output | Should -Match '要重打' $script:SlotArchive.BaseName | Should -Be 'appkit'
$script:AppendBackupRun.Output | Should -Match '结论:'
} }
It '备份成功,manifest.roots 记录两棵子树' { It '归档顶层就是各个 Slot 名(目录 Slot + 文件 Slot + Include 项)' {
$script:AppendBackupRun.ExitCode | Should -Be 0 $top = @(Get-ArchiveTopLevelNames -ArchivePath $script:SlotArchive.FullName -SevenZip $script:SevenZip)
$archive = @(Get-ChildItem -LiteralPath $script:AppendBackupDir -File -Filter *.7z)[0] (@($top | Sort-Object) -join ',') | Should -Be 'Cfg,Data,Extra,Modules'
$record = (Read-BaknretManifest -Path (Join-Path $script:AppendBackupDir 'manifest.json')).items[$archive.BaseName]
$record.roots | Should -Contain 'dirA'
$record.roots | Should -Contain 'dirB'
} }
It '归档里两棵树都在,且 :- 排除生效' { It 'manifest.layouts 记下每个归档项是目录还是文件' {
$verify = Join-Path $script:AppendRoot 'verify' $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 的文件,Slot 排除生效' {
$verify = Join-Path $script:SlotRoot 'verify'
New-Item -ItemType Directory -Path $verify -Force | Out-Null 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", $script:SlotArchive.FullName)) | Should -Be 0
(Invoke-ExternalCommand -FilePath $script:SevenZip -ArgumentList @('x', '-bso0', '-bsp0', '-y', "-o$verify", $archive.FullName)) | Should -Be 0
Test-Path -LiteralPath (Join-Path $verify 'dirA\a.txt') | Should -BeTrue Test-Path -LiteralPath (Join-Path $verify 'Data\one.txt') | Should -BeTrue
Test-Path -LiteralPath (Join-Path $verify 'dirB\b.txt') | Should -BeTrue Test-Path -LiteralPath (Join-Path $verify 'Data\sub\deep.txt') | Should -BeTrue
Test-Path -LiteralPath (Join-Path $verify 'dirA\skip.log') | Should -BeFalse 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 '删源后恢复:每个目录只落回自己的父目录,兄弟目录不会被复制过去' { It '真实恢复:目录 Slot、文件 Slot 与 Include 都落回各自的原位' {
Remove-Item -LiteralPath $script:AppendA -Recurse -Force Remove-Item -LiteralPath (Join-Path $script:SlotRoot 'apps') -Recurse -Force
Remove-Item -LiteralPath $script:AppendB -Recurse -Force Remove-Item -LiteralPath $script:SlotInclude -Recurse -Force
$run = Invoke-BaknretScript -Script $script:RestoreScript -Parameters @{ $run = Invoke-BaknretScript -Script $script:RestoreScript -Parameters @{
BackupListPath = $script:AppendList BackupListPath = $script:SlotList
BackupDir = $script:AppendBackupDir BackupDir = $script:SlotBackupDir
ConfigPath = $script:SlotConfig
Force = $true Force = $true
} }
$run.ExitCode | Should -Be 0 $run.ExitCode | Should -Be 0
$run.Output | Should -Match '恢复成功: appkit'
Test-Path -LiteralPath (Join-Path $script:AppendA 'a.txt') | Should -BeTrue (Get-Content -LiteralPath (Join-Path $script:SlotAppOne 'one.txt') -Raw).Trim() | Should -Be 'one'
Test-Path -LiteralPath (Join-Path $script:AppendB 'b.txt') | Should -BeTrue (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 # 被 Slot 排除的缓存没有进过归档,自然也不会被恢复出来
Test-Path -LiteralPath (Join-Path $script:AppendRoot 'srcA\dirB') | Should -BeFalse Test-Path -LiteralPath (Join-Path $script:SlotAppOne 'Cache\c.bin') | Should -BeFalse
Test-Path -LiteralPath (Join-Path $script:AppendRoot 'srcB\dirA') | 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 { BeforeAll {
@@ -338,8 +515,9 @@ Describe '集成:同一条目里两个同名目录会被拒绝执行' -Skip:(-
New-Item -ItemType Directory -Path $directory -Force | Out-Null New-Item -ItemType Directory -Path $directory -Force | Out-Null
Set-Content -LiteralPath (Join-Path $directory 'x.txt') 'x' Set-Content -LiteralPath (Join-Path $directory 'x.txt') 'x'
} }
Write-ListFile -Path $script:RejectCatalog -Content "@{`n 'collide' = @('$p1', '$p2')`n}`n" | Out-Null # Slot 叫 Data,Include 也要放进包内的 Data -> 同一个位置,必须报错
$script:RejectList = Write-ListFile -Path (Join-Path $script:RejectRoot 'list.txt') -Content "collide`n" 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:RejectConfig = Write-ListFile -Path (Join-Path $script:RejectRoot 'config.psd1') -Content "@{ SoftwareCatalog = '$script:RejectCatalog' }`n"
$script:RejectBackupDir = Join-Path $script:RejectRoot 'Backups' $script:RejectBackupDir = Join-Path $script:RejectRoot 'Backups'
@@ -352,9 +530,9 @@ Describe '集成:同一条目里两个同名目录会被拒绝执行' -Skip:(-
} }
} }
It '退出码 1,且给出"顶层同名"的原因,不生成归档' { It '退出码 1,且给出"归档内路径冲突"的原因,不生成归档' {
$script:RejectRun.ExitCode | Should -Be 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 @(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') $manifest = Read-BaknretManifest -Path (Join-Path $script:RejectBackupDir 'manifest.json')
$record = $manifest.items['collide'] $record = $manifest.items['collide']
$record.action | Should -Be 'failed' $record.action | Should -Be 'failed'
$record.reason | Should -Match '顶层同名' $record.reason | Should -Match '归档内路径冲突'
} }
} }
@@ -381,7 +559,7 @@ Describe '条目从清单里消失后,旧归档必须被点名为孤儿' -Skip
Set-Content -LiteralPath (Join-Path $script:OrphanSource 'data.txt') 'hello' Set-Content -LiteralPath (Join-Path $script:OrphanSource 'data.txt') 'hello'
$script:OrphanCatalog = Write-ListFile -Path (Join-Path $script:OrphanRoot 'cat.psd1') ` $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') ` $script:OrphanConfig = Write-ListFile -Path (Join-Path $script:OrphanRoot 'config.psd1') `
-Content "@{ SoftwareCatalog = '$script:OrphanCatalog' }`n" -Content "@{ SoftwareCatalog = '$script:OrphanCatalog' }`n"
$script:OrphanList = Join-Path $script:OrphanRoot 'list.txt' $script:OrphanList = Join-Path $script:OrphanRoot 'list.txt'
+487
View File
@@ -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
}
}
+852 -164
View File
File diff suppressed because it is too large. Load diff
+259 -69
View File
@@ -8,14 +8,23 @@
* 本脚本证明的是"**这一批真实归档**解得开,而且解出来的东西和源一致"。 * 本脚本证明的是"**这一批真实归档**解得开,而且解出来的东西和源一致"。
关键设计:**绝不碰真实目录**。做法是给一份临时名录(SoftwareCatalog), 关键设计:**绝不碰真实目录**。做法是给一份临时名录(SoftwareCatalog),
把软件名映射到临时目标目录,于是 Restore.ps1 会把归档解到临时目录, 把归档里的顶层条目名映射到临时目标目录,于是 Restore.ps1 会把归档解到临时目录,
而不是 ~\.ssh、C:\Programs\... 这些真地方。真实归档本身只被读取。 而不是 ~\.ssh、C:\Programs\... 这些真地方。真实归档本身只被读取。
归档内的一层名字怎么定,取决于**这个归档是哪种布局**(Backups\ 里两种都有):
* 重构后的新布局:包内顶层是 Slot 名(`<Slot>\<内容>`,文件 Slot 就是名为
`<Slot>` 的文件)——manifest 记录的 layouts 里有这个名字;
* 重构前的旧布局:包内顶层是源路径的末级名(`<末级名>\...`)——manifest 没有 layouts。
本脚本按 manifest 判断,把临时名录的 Slot 名设成归档里**真实存在的那一层名字**,
因此新旧布局都能被 Restore.ps1 正常解出来,而不是依赖"解不出来再回退"。
对拍规则(关键:先把"源变了"和"归档坏了"分开): 对拍规则(关键:先把"源变了"和"归档坏了"分开):
* 恢复树里每个文件都必须在活源里存在 —— 否则失败(说明归档里混进了别的东西);
* 内容不一致时看活源文件的修改时间:晚于归档时间 ⇒ 源在备份之后被改过, * 内容不一致时看活源文件的修改时间:晚于归档时间 ⇒ 源在备份之后被改过,
只提示、不算失败;不晚于归档时间却内容不同 ⇒ 归档或解压有问题,算失败; 只提示、不算失败;不晚于归档时间却内容不同 ⇒ 归档或解压有问题,算失败;
* 活源里在备份之后新增 / 删掉的文件只提示; * 归档里有、活源里没有的文件:如果它所在的活源目录(或最近的还在的祖辈)
的修改时间晚于归档时间 ⇒ 是备份之后从源里删掉的,只提示、不算失败;
否则 ⇒ 归档里混进了源里没有的东西,算失败;
* 活源里在备份之后新增的文件只提示;
* 一个条目一个文件都对不上 —— 失败(多半是空归档,必须点名)。 * 一个条目一个文件都对不上 —— 失败(多半是空归档,必须点名)。
真实机器上的归档常常是几周前的,所以"必须和今天逐字节一致"不是合理判据; 真实机器上的归档常常是几周前的,所以"必须和今天逐字节一致"不是合理判据;
@@ -26,8 +35,8 @@
pwsh -File .\tests\Restore-Drill.ps1 pwsh -File .\tests\Restore-Drill.ps1
.EXAMPLE .EXAMPLE
# 只演练指定条目,并保留下临时工作目录 # 只演练指定条目(写 BackupList.txt 里那样的行:软件名或绝对路径),并保留临时目录
pwsh -File .\tests\Restore-Drill.ps1 -Entries '.ssh','legendary' -KeepWorkRoot pwsh -File .\tests\Restore-Drill.ps1 -Entries 'OpenSSH','C:\Programs\MiFlash' -KeepWorkRoot
#> #>
[CmdletBinding()] [CmdletBinding()]
@@ -35,11 +44,13 @@ param(
# 归档所在目录;默认取 BackupConfig.psd1 里的 BackupDir # 归档所在目录;默认取 BackupConfig.psd1 里的 BackupDir
[string]$BackupDir, [string]$BackupDir,
# 要演练的条目(软件名)。默认是一组"小、静态、无排除规则"的条目 # 要演练的条目,写法与 BackupList.txt 的一行相同(软件名或绝对路径)。
# 默认是一组"小、静态、无排除规则"的条目;不存在的源 / 归档会被干净地跳过。
[string[]]$Entries = @( [string[]]$Entries = @(
'.ssh', 'legendary', 'scoop-config', 'opencode', 'OpenSSH', 'Legendary', 'OpenCode', 'PowerShell', 'WindowsPowerShell',
'PowerShell', 'WindowsPowerShell', 'MiFlash', 'MiFlash_Unlock', 'WindowsTerminal', 'TranslucentTB', 'Kazumi', 'PiliPlus',
'Startup', 'WindowsTerminal', 'Aria' 'C:\Programs\MiFlash', 'C:\Programs\MiFlash_Unlock',
'D:\UserData\Documents\Aria'
), ),
[string]$ConfigPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'BackupConfig.psd1'), [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 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 ''
Write-Host '== 真实归档恢复演练:归档 -> 临时目标 -> 与活源逐字节对拍 ==' -ForegroundColor Cyan Write-Host '== 真实归档恢复演练:归档 -> 临时目标 -> 与活源逐字节对拍 ==' -ForegroundColor Cyan
Write-Host " 归档目录:$BackupDir" Write-Host " 归档目录:$BackupDir(只读;恢复写盘只写临时目录)"
Write-Host " 软件名录:$catalogPath" Write-Host " 软件名录:$catalogPath"
Write-Host " 工作目录:$WorkRoot" Write-Host " 工作目录:$WorkRoot"
Write-Host '' 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 { function Compare-RestoredTree {
<# <#
.SYNOPSIS .SYNOPSIS
@@ -115,6 +168,7 @@ function Compare-RestoredTree {
Restored = 0 Restored = 0
Matched = 0 Matched = 0
Stale = @() Stale = @()
Removed = @()
Changed = @() Changed = @()
Extra = @() Extra = @()
Missing = @() Missing = @()
@@ -151,7 +205,11 @@ function Compare-RestoredTree {
$liveFile = Join-Path $liveRoot $relative $liveFile = Join-Path $liveRoot $relative
if (-not (Test-Path -LiteralPath $liveFile)) { if (-not (Test-Path -LiteralPath $liveFile)) {
if (Test-RemovedFromLiveAfterBackup -LiveRoot $liveRoot -Relative $relative -ArchiveTime $ArchiveTime) {
$report.Removed += $relative
} else {
$report.Extra += $relative $report.Extra += $relative
}
continue continue
} }
@@ -179,6 +237,66 @@ function Compare-RestoredTree {
return $report 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 = @() $rows = @()
$failures = @() $failures = @()
$checked = 0 $checked = 0
$entryIndex = 0
foreach ($name in $Entries) { foreach ($name in $Entries) {
$entryIndex++
$entry = ConvertFrom-BackupListLine -Line $name $entry = ConvertFrom-BackupListLine -Line $name
if (-not $entry) { continue } if (-not $entry) { continue }
@@ -199,84 +319,147 @@ foreach ($name in $Entries) {
continue continue
} }
$archivePath = Join-Path $BackupDir ($resolved.BaseName + '.7z') $items = @($resolved.Items)
if (-not (Test-Path -LiteralPath $archivePath)) { if ($items.Count -eq 0) {
$rows += [pscustomobject]@{ Entry = $name; Status = 'SKIP'; Detail = "归档不存在:$($resolved.BaseName).7z" } $reason = if ($resolved.Error) { $resolved.Error } else { '解析不出归档项' }
continue $rows += [pscustomobject]@{ Entry = $name; Status = 'SKIP'; Detail = $reason }
}
$archiveTime = (Get-Item -LiteralPath $archivePath).LastWriteTime
$sources = @($resolved.Sources)
if ($sources.Count -eq 0) {
$rows += [pscustomobject]@{ Entry = $name; Status = 'SKIP'; Detail = '名录解析不出源路径' }
continue 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 = '所有源目录当前都不存在,无法对拍' } $rows += [pscustomobject]@{ Entry = $name; Status = 'SKIP'; Detail = '所有源目录当前都不存在,无法对拍' }
continue continue
} }
$entryRoot = Join-Path (Join-Path $WorkRoot 'restore') $name # 临时名录:键 = 归档基础名(这样 Restore 能通过 manifest / 文件名找到归档),
New-Item -ItemType Directory -Path $entryRoot -Force | Out-Null # 每个 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, [System.IO.File]::WriteAllText($scratchCatalog,
"@{`n '$name' = @(`n$itemLines`n )`n}`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, "$name`n", [System.Text.UTF8Encoding]::new($false)) [System.IO.File]::WriteAllText($scratchList, "$catalogKey`n", [System.Text.UTF8Encoding]::new($false))
[System.IO.File]::WriteAllText($scratchConfig, @" [System.IO.File]::WriteAllText($scratchConfig, @"
@{ @{
BackupDir = '$BackupDir' BackupDir = '$scratchBackupRoot'
LogDir = '$(Join-Path $WorkRoot 'logs')' LogDir = '$scratchLogDir'
SoftwareCatalog = '$scratchCatalog' SoftwareCatalog = '$scratchCatalog'
CatalogMaxDepth = $($config.CatalogMaxDepth) CatalogMaxDepth = $($config.CatalogMaxDepth)
VerifyArchive = `$true VerifyArchive = `$true
} }
"@, [System.Text.UTF8Encoding]::new($false)) "@, [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,子进程既不会打断演练, $restore = Invoke-ScratchRestore -ScratchList $scratchList -ScratchConfig $scratchConfig -ScratchLogDir $scratchLogDir -ScratchBackupDir $scratchBackupRoot
# 给出的也是真正的进程退出码(和 Pester 套件里的做法一致)。
$restoreExit = 0
$restoreOutput = @()
try {
$restoreOutput = & pwsh -NoProfile -NonInteractive -File $restoreScript `
-BackupListPath $scratchList -ConfigPath $scratchConfig -BackupDir $BackupDir -Force 2>&1
$restoreExit = $LASTEXITCODE
} catch {
$restoreExit = -1
Write-Host (" Restore.ps1 调用失败:$_") -ForegroundColor Red
}
if ($restoreExit -ne 0) { if ($restore.Code -ne 0) {
foreach ($line in @($restoreOutput | Select-Object -Last 12)) { 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 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 continue
} }
@@ -285,17 +468,17 @@ foreach ($name in $Entries) {
$restoredCount = 0 $restoredCount = 0
$extra = @() $extra = @()
$stale = @() $stale = @()
$removed = @()
$changed = @() $changed = @()
$notArchived = @() $notArchived = @()
foreach ($pair in $pairs) { foreach ($pair in $pairs) {
# 活源本来就没了的不对拍(归档里也不该有它)
if (-not $pair.Exists) { continue }
$one = Compare-RestoredTree -RestoredPath $pair.Restored -LivePath $pair.Live -ArchiveTime $archiveTime $one = Compare-RestoredTree -RestoredPath $pair.Restored -LivePath $pair.Live -ArchiveTime $archiveTime
$matched += $one.Matched $matched += $one.Matched
$restoredCount += $one.Restored $restoredCount += $one.Restored
$extra += $one.Extra $extra += $one.Extra
$stale += $one.Stale $stale += $one.Stale
$removed += $one.Removed
$changed += $one.Changed $changed += $one.Changed
$notArchived += $one.Missing $notArchived += $one.Missing
} }
@@ -312,6 +495,10 @@ foreach ($name in $Entries) {
# 活源在归档之后被改过:源变了,不是归档坏了,只提示 # 活源在归档之后被改过:源变了,不是归档坏了,只提示
$detail += ";源在备份后变过 $($stale.Count) 个(不算失败)" $detail += ";源在备份后变过 $($stale.Count) 个(不算失败)"
} }
if ($removed.Count -gt 0) {
# 归档里有、活源里没了,且源目录在归档之后动过:也是"源变了",只提示
$detail += ";备份后从源里删掉 $($removed.Count) 个(不算失败)"
}
if ($changed.Count -gt 0) { if ($changed.Count -gt 0) {
if ($AllowChanged) { if ($AllowChanged) {
$detail += ";与活源不一致 $($changed.Count) 个(-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 ''
Write-Host '演练结果:' -ForegroundColor Cyan Write-Host '演练结果:' -ForegroundColor Cyan
$rows | Format-Table -AutoSize | Out-String -Width 200 | Write-Host $rows | Format-Table -AutoSize | Out-String -Width 200 | Write-Host
+432 -128
View File
@@ -1,17 +1,24 @@
<# <#
.SYNOPSIS .SYNOPSIS
BakNRet 端到端验收:真实备份 -> 校验排除 -> 删源 -> 恢复 -> 逐字节对拍。 BakNRet 端到端验收:真实备份 -> 校验归档布局与排除 -> 删源 -> 恢复 -> 逐字节对拍。
.DESCRIPTION .DESCRIPTION
单元测试只验证函数行为,这个脚本验证整条链路真的能用: 单元测试只验证函数行为,这个脚本验证整条链路真的能用。覆盖重构后的新契约:
1. 造一个含可排除内容的源目录(目录名故意带空格,顺带验证命令行引用);
2. 跑 Backup.ps1,断言退出码为 0、归档生成、manifest 记录正确;
3. 解压归档,断言被排除的内容确实不在里面;
4. 删掉源目录,跑 Restore.ps1,断言文件逐字节还原、被排除的内容没有被还原;
5. 断言 Backup -DryRun 与 Restore -DryRun 都不写盘;
6. 源路径不存在时记为 missing-source,而不是静默忽略。
全程只在临时目录里操作,不会碰到真实备份。 1. 字面路径条目:`:-` 排除 -> 删源 -> 恢复 -> 逐字节对拍(历史 `<末级名>\...` 布局);
2. 软件名录条目:一个软件一个归档,包内顶层是各 Slot(`<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 .EXAMPLE
pwsh -File .\tests\Run-E2E.ps1 pwsh -File .\tests\Run-E2E.ps1
@@ -37,6 +44,78 @@ Reset-TestResult
if (-not $WorkRoot) { if (-not $WorkRoot) {
$WorkRoot = Join-Path $env:TEMP ('bnr-' + [guid]::NewGuid().ToString('N').Substring(0, 6)) $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' $sourceParent = Join-Path $WorkRoot 'src'
$source = Join-Path $sourceParent 'My Code Space' # 名字带空格,专门压一下命令行引用 $source = Join-Path $sourceParent 'My Code Space' # 名字带空格,专门压一下命令行引用
@@ -44,14 +123,9 @@ $backupDir = Join-Path $WorkRoot 'Backups'
$listPath = Join-Path $WorkRoot 'list.txt' $listPath = Join-Path $WorkRoot 'list.txt'
$dryBackupDir = Join-Path $WorkRoot 'Backups-dry' $dryBackupDir = Join-Path $WorkRoot 'Backups-dry'
$verifyDir = Join-Path $WorkRoot 'verify' $verifyDir = Join-Path $WorkRoot 'verify'
$logDir1 = Join-Path $WorkRoot 'logs1'
Write-Host "" $cfg1 = Join-Path $WorkRoot 'cfg1.psd1'
Write-Host '== 端到端:备份 -> 排除 -> 删源 -> 恢复 -> 对拍 ==' -ForegroundColor Cyan New-E2EConfig -Path $cfg1 -LogDir $logDir1
Write-Host " 工作目录:$WorkRoot"
# ============================================================================
# 1. 造数据
# ============================================================================
foreach ($dir in 'logs', 'sub', 'Cache') { foreach ($dir in 'logs', 'sub', 'Cache') {
New-Item -ItemType Directory -Path (Join-Path $source $dir) -Force | Out-Null 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) (New-Object System.Random 42).NextBytes($blob)
[System.IO.File]::WriteAllBytes((Join-Path $source 'blob.bin'), $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 = @{} $expectedHashes = @{}
foreach ($relative in 'keep.txt', 'sub\b.txt', 'blob.bin') { 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)
} }
# ============================================================================ & $backupScript -BackupListPath $listPath -BackupDir $backupDir -ConfigPath $cfg1 -Force -QuietTool
# 2. 备份
# ============================================================================
& $backupScript -BackupListPath $listPath -BackupDir $backupDir -Force -QuietTool
$backupExitCode = $LASTEXITCODE $backupExitCode = $LASTEXITCODE
Test-Case '备份退出码为 0(旧实现会把成功的压缩判成失败)' { Test-Case '备份退出码为 0(旧实现会把成功的压缩判成失败)' {
@@ -94,7 +164,7 @@ Test-Case '归档已生成' {
$manifestPath = Join-Path $backupDir 'manifest.json' $manifestPath = Join-Path $backupDir 'manifest.json'
Test-Case 'manifest 记录了条目、动作与校验结果' { Test-Case 'manifest 记录了条目、动作、校验结果、roots 与 layouts' {
Assert-FileExists $manifestPath Assert-FileExists $manifestPath
$manifest = Read-BaknretManifest -Path $manifestPath $manifest = Read-BaknretManifest -Path $manifestPath
Assert-Equal 1 $manifest.items.Count Assert-Equal 1 $manifest.items.Count
@@ -105,28 +175,26 @@ Test-Case 'manifest 记录了条目、动作与校验结果' {
Assert-Equal $true $record.verified Assert-Equal $true $record.verified
Assert-Equal 0 $record.exitCode Assert-Equal 0 $record.exitCode
Assert-Equal $source $record.source 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 '备份过程写了日志文件' { Test-Case '备份过程写了日志文件(写进临时 LogDir,不污染仓库)' {
$logDir = Join-Path $projectRoot 'logs' $logs = @(Get-ChildItem -LiteralPath $logDir1 -File -Filter 'backup-*.log' -ErrorAction SilentlyContinue)
$logs = @(Get-ChildItem -LiteralPath $logDir -File -Filter 'backup-*.log' -ErrorAction SilentlyContinue)
Assert-True ($logs.Count -gt 0) '应生成 backup-*.log' 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) { if ($sevenZip) {
New-Item -ItemType Directory -Path $verifyDir -Force | Out-Null
$null = Invoke-ExternalCommand -FilePath $sevenZip ` $null = Invoke-ExternalCommand -FilePath $sevenZip `
-ArgumentList @('x', '-bso0', '-bsp0', '-y', "-o$verifyDir", $archives[0].FullName) -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\keep.txt')
Assert-FileExists (Join-Path $verifyDir 'My Code Space\sub\b.txt') Assert-FileExists (Join-Path $verifyDir 'My Code Space\sub\b.txt')
Assert-FileExists (Join-Path $verifyDir 'My Code Space\blob.bin') 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 目录' Assert-FileMissing (Join-Path $verifyDir 'My Code Space\Cache\c.bin') '!*Cache 应命中 Cache 目录'
} }
# ============================================================================
# 4. 删源后恢复,逐字节对拍
# ============================================================================
Remove-Item -LiteralPath $source -Recurse -Force Remove-Item -LiteralPath $source -Recurse -Force
Test-Case '源目录确实已被删除(保证下面的恢复不是空操作)' { Test-Case '源目录确实已被删除(保证下面的恢复不是空操作)' {
Assert-FileMissing $source Assert-FileMissing $source
} }
& $restoreScript -BackupListPath $listPath -BackupDir $backupDir -Force & $restoreScript -BackupListPath $listPath -BackupDir $backupDir -ConfigPath $cfg1 -Force
$restoreExitCode = $LASTEXITCODE $restoreExitCode = $LASTEXITCODE
Test-Case '恢复退出码为 0' { Test-Case '恢复退出码为 0' {
@@ -158,7 +222,7 @@ Test-Case '恢复出的文件与源逐字节一致' {
foreach ($relative in $expectedHashes.Keys) { foreach ($relative in $expectedHashes.Keys) {
$restored = Join-Path $source $relative $restored = Join-Path $source $relative
Assert-FileExists $restored 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' $lockSource = Join-Path $sourceParent 'Locked Case'
$lockBackupDir = Join-Path $WorkRoot 'Backups-lock' $lockBackupDir = Join-Path $WorkRoot 'Backups-lock'
$lockList = Join-Path $WorkRoot 'lock.txt' $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 New-Item -ItemType Directory -Path $lockSource -Force | Out-Null
Set-Content -LiteralPath (Join-Path $lockSource 'a.txt') -Value 'aaa' -Encoding UTF8 Set-Content -LiteralPath (Join-Path $lockSource 'a.txt') -Value 'aaa' -Encoding UTF8
[System.IO.File]::WriteAllText($lockList, "$lockSource`n", [System.Text.UTF8Encoding]::new($false)) [System.IO.File]::WriteAllText($lockList, "$lockSource`n", [System.Text.UTF8Encoding]::new($false))
# 第一轮:没有占用,归档是"干净"的 & $backupScript -BackupListPath $lockList -BackupDir $lockBackupDir -ConfigPath $cfg2 -Force -QuietTool
& $backupScript -BackupListPath $lockList -BackupDir $lockBackupDir -Force -QuietTool
$cleanExitCode = $LASTEXITCODE $cleanExitCode = $LASTEXITCODE
$cleanArchive = Get-ChildItem -LiteralPath $lockBackupDir -File -Filter *.7z | Select-Object -First 1 $cleanArchive = Get-ChildItem -LiteralPath $lockBackupDir -File -Filter *.7z | Select-Object -First 1
$cleanSize = $cleanArchive.Length $cleanSize = $cleanArchive.Length
@@ -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) $lockStream = [System.IO.File]::Open($lockedPath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::None)
try { try {
& $backupScript -BackupListPath $lockList -BackupDir $lockBackupDir -Force -QuietTool & $backupScript -BackupListPath $lockList -BackupDir $lockBackupDir -ConfigPath $cfg2 -Force -QuietTool
$warnExitCode = $LASTEXITCODE $warnExitCode = $LASTEXITCODE
$afterArchive = Get-ChildItem -LiteralPath $lockBackupDir -File -Filter *.7z | Select-Object -First 1 $afterArchive = Get-ChildItem -LiteralPath $lockBackupDir -File -Filter *.7z | Select-Object -First 1
$afterRecord = (Read-BaknretManifest -Path $lockManifestPath).items[$afterArchive.BaseName] $afterRecord = (Read-BaknretManifest -Path $lockManifestPath).items[$afterArchive.BaseName]
@@ -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 $acceptExitCode = $LASTEXITCODE
$acceptedRecord = (Read-BaknretManifest -Path $lockManifestPath).items[$afterArchive.BaseName] $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 名:<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 } 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 $dryRestoreExitCode = $LASTEXITCODE
Test-Case 'Restore -DryRun:退出码 0、不创建目标目录' { Test-Case 'Restore -DryRun:退出码 0、不创建目标目录' {
@@ -241,7 +616,7 @@ Test-Case 'Restore -DryRun:退出码 0、不创建目标目录' {
Assert-FileMissing $source Assert-FileMissing $source
} }
& $backupScript -BackupListPath $listPath -BackupDir $dryBackupDir -Force -QuietTool -DryRun & $backupScript -BackupListPath $listPath -BackupDir $dryBackupDir -ConfigPath $cfg1 -Force -QuietTool -DryRun
$dryBackupExitCode = $LASTEXITCODE $dryBackupExitCode = $LASTEXITCODE
Test-Case 'Backup -DryRun:退出码 0、不写归档也不写 manifest' { 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 '归档名重复时直接报失败,不静默互相覆盖' { Test-Case '归档名重复时直接报失败,不静默互相覆盖' {
$dupList = Join-Path $catRoot 'dup.txt' $dupList = Join-Path $catRoot 'dup.txt'
[System.IO.File]::WriteAllText($dupList, "my-app`nmy-app`n", [System.Text.UTF8Encoding]::new($false)) [System.IO.File]::WriteAllText($dupList, "my-app`nmy-app`n", [System.Text.UTF8Encoding]::new($false))
@@ -324,28 +640,16 @@ Test-Case '归档名重复时直接报失败,不静默互相覆盖' {
Test-Case '字面路径不受名录影响,仍走路径命名' { Test-Case '字面路径不受名录影响,仍走路径命名' {
$literalList = Join-Path $catRoot 'list-literal.txt' $literalList = Join-Path $catRoot 'list-literal.txt'
[System.IO.File]::WriteAllText($literalList, "$catTarget`n", [System.Text.UTF8Encoding]::new($false)) $literalDir = Join-Path $catRoot 'Backups-literal'
& $backupScript -BackupListPath $literalList -BackupDir $catBackupDir -ConfigPath $catConfig -Force -QuietTool [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-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' $missingList = Join-Path $WorkRoot 'missing.txt'
[System.IO.File]::WriteAllText($missingList, "Z:\definitely-not-here-12345`n", [System.Text.UTF8Encoding]::new($false)) [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 $missingExitCode = $LASTEXITCODE
Test-Case '源路径不存在被记为 missing-source,退出码仍为 0(跳过不算失败)' { Test-Case '源路径不存在被记为 missing-source,退出码仍为 0(跳过不算失败)' {
+17 -2
View File
@@ -74,11 +74,26 @@ $configuration.Run.Exit = $false
$configuration.Output.Verbosity = $Verbosity $configuration.Output.Verbosity = $Verbosity
if ($Tag) { $configuration.Filter.Tag = $Tag } 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 $result = Invoke-Pester -Configuration $configuration
# 容器级失败(发现阶段的语法错误、Describe 外的异常)不会进 FailedCount,
# 只会在输出里出现一行 "Container failed" —— 不显式检查就会把"根本没跑起来"
# 报成"全部通过"。这里把它也当成失败。
$failedContainers = @($result.Containers | Where-Object { $_.Result -eq 'Failed' })
Write-Host '' Write-Host ''
if ($result.FailedCount -gt 0) { if ($result.FailedCount -gt 0 -or $failedContainers.Count -gt 0) {
Write-Host ("Pester 测试通过 {0} 项,失败 {1} 项,跳过 {2} 项" -f $result.PassedCount, $result.FailedCount, $result.SkippedCount) -ForegroundColor Red 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 exit 1
} }
+773 -153
View File
File diff suppressed because it is too large. Load diff
+95 -27
View File
@@ -1,14 +1,20 @@
<# <#
.SYNOPSIS .SYNOPSIS
把按路径命名的旧归档重命名成软件名,并重建 manifest.json。 把归档名对齐到当前清单规则,并重建 manifest.json。
.DESCRIPTION .DESCRIPTION
重构前的归档名是 `<末级名>_from_<上级路径>`(如 FooClolor_from_C_+Programs.7z)。 归档名由清单条目决定:
引入软件名录后,归档名默认就是软件名(FooClolor.7z)。这个脚本负责把存量归档搬过去。
* 软件名条目 -> 归档名 = 软件名(`Edge.7z`);
* 手写路径条目 -> 归档名 = `<末级名>_from_<上级路径>`(`FooClolor_from_C_+Programs.7z`)。
条目写法变过(把软件名改成手写路径、改名、合并条目……)之后,磁盘上的旧归档名就与当前
规则对不上了 —— 那样的归档恢复不到,会被当成孤儿。这个脚本负责把它们搬过去。
做法: 做法:
1. 遍历清单条目,算出"旧名"(路径命名算法)与"新名"(当前规则); 1. 遍历清单条目,算出**当前规则下的目标名**,以及一组**候选旧名**
2. 只在两者不同、且旧名归档确实存在时才处理; (路径命名算法 / 名录里的软件名 / manifest 里记过的归档名);
2. 目标名已经存在就跳过;否则在候选旧名里找实际存在的归档;
3. 重命名(不是复制,同卷上是元数据操作,不搬数据); 3. 重命名(不是复制,同卷上是元数据操作,不搬数据);
4. 重建 manifest.json,把旧记录的历史字段(成功次数、SHA256 等)迁过去; 4. 重建 manifest.json,把旧记录的历史字段(成功次数、SHA256 等)迁过去;
5. 比对重命名前后的文件大小做完整性自检。 5. 比对重命名前后的文件大小做完整性自检。
@@ -74,6 +80,29 @@ function Find-ArchiveByBaseName {
$manifestOld = Read-BaknretManifest -Path $manifestPath $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 = @() $plan = @()
$unchanged = 0 $unchanged = 0
$missingOld = 0 $missingOld = 0
@@ -85,23 +114,9 @@ foreach ($line in (Get-Content -LiteralPath $BackupListPath)) {
$item = ConvertFrom-BackupListLine -Line $line $item = ConvertFrom-BackupListLine -Line $line
if (-not $item) { continue } if (-not $item) { continue }
# 旧名 = 对"真实源路径"跑路径命名算法。
# 注意:清单里现在写的是软件名,直接把它丢给 Get-BackupBaseName 会得到一个
# 恰好和软件名一模一样的"旧名"(legendary -> legendary),于是永远算不出
# 真正的旧名。必须先解析出真实路径。
$resolved = Resolve-BackupEntry -Entry $item -CatalogPath $catalogPath -MaxDepth $config.CatalogMaxDepth $resolved = Resolve-BackupEntry -Entry $item -CatalogPath $catalogPath -MaxDepth $config.CatalogMaxDepth
$newName = $resolved.BaseName $newName = $resolved.BaseName
if (-not $newName) { continue }
$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 ($seenNew.ContainsKey($newName)) { if ($seenNew.ContainsKey($newName)) {
$conflicts += "归档名 '$newName' 被 '$($seenNew[$newName])' 和 '$($item.Path)' 同时使用" $conflicts += "归档名 '$newName' 被 '$($seenNew[$newName])' 和 '$($item.Path)' 同时使用"
@@ -109,11 +124,57 @@ foreach ($line in (Get-Content -LiteralPath $BackupListPath)) {
} }
$seenNew[$newName] = $item.Path $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 } if (-not $oldFile) { $missingOld++; continue }
$plan += [pscustomobject]@{ $plan += [pscustomobject]@{
@@ -190,10 +251,14 @@ $now = (Get-Date).ToString('o')
foreach ($entry in $entries) { foreach ($entry in $entries) {
$file = Find-ArchiveByBaseName -BaseName $entry.NewName -Directory $BackupDir -Formats $supportedFormats $file = Find-ArchiveByBaseName -BaseName $entry.NewName -Directory $BackupDir -Formats $supportedFormats
# 历史字段优先从新键取,其次从旧键(路径命名)取 # 历史字段优先从新键取,其次从候选旧名里取
$previous = $null $previous = $null
if ($manifestOld.items.Contains($entry.NewName)) { $previous = $manifestOld.items[$entry.NewName] } 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 = { $getPrevious = {
param([string]$Field) param([string]$Field)
@@ -205,7 +270,10 @@ foreach ($entry in $entries) {
baseName = $entry.NewName baseName = $entry.NewName
source = $entry.Item.Path source = $entry.Item.Path
resolvedSource = [Environment]::ExpandEnvironmentVariables($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 }) catalog = $(if ($entry.Resolved.CatalogEntry) { $entry.Resolved.CatalogEntry.Path } else { $null })
archive = $(if ($file) { $file.Name } else { $entry.NewName + '.7z' }) archive = $(if ($file) { $file.Name } else { $entry.NewName + '.7z' })
action = $(if ($file) { 'backed-up' } else { 'missing-source' }) action = $(if ($file) { 'backed-up' } else { 'missing-source' })
@@ -218,7 +286,7 @@ foreach ($entry in $entries) {
verified = $false verified = $false
warnings = $false warnings = $false
attemptWarnings = $false attemptWarnings = $false
encrypted = ($entry.Item.Flags -contains 'encrypt') encrypted = [bool]$entry.Resolved.Encrypt
sourceFiles = (& $getPrevious 'sourceFiles') sourceFiles = (& $getPrevious 'sourceFiles')
sourceBytes = (& $getPrevious 'sourceBytes') sourceBytes = (& $getPrevious 'sourceBytes')
archiveBytes = $(if ($file) { $file.Length } else { $null }) archiveBytes = $(if ($file) { $file.Length } else { $null })
+182
View File
@@ -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 }
}
+423
View File
@@ -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'
}
}
+252
View File
@@ -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'
+205
View File
@@ -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 的文件) |
| 排除与追加 | `:-` 的 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 下,便于事后排查;确认不要了再手工删该目录
```
+126
View File
@@ -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)
}
+118
View File
@@ -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
}
+491
View File
@@ -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
+45
View File
@@ -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
+40
View File
@@ -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
@@ -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')
}
+26
View File
@@ -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 # 仅恢复,不备份(备份端跳过)
@@ -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 = '故意写错,由清单里的 :: 覆盖成存在的目录' }
}
}
+126
View File
@@ -0,0 +1,126 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
BakNRet 隔离测试 VM 的无人值守应答文件(离线部署路径)。
Windows 用 DISM 展开到 VHDX 之后,本文件被放到 C:\Windows\Panther\unattend.xml,
首次启动时由 Windows 在 specialize 与 oobeSystem 两个阶段读取。
设计要点:
* 不启用已废弃的 SkipMachineOOBE / SkipUserOOBE —— 在 Windows 11 25H2 上它们会让
OOBE 卡住;这里改用 OOBE 隐藏项 + BypassNRO + 明确的本地账户;
* 只创建一个本地管理员 lab,避免 OOBE 索要微软账户;
* AutoLogon 三次,用来跑 FirstLogonCommands 里的供给脚本;
* 口令占位符 __LABPASSWORD__ 由 tools\lab\New-BakNRetLab.ps1 在注入前替换成随机口令,
口令只留在宿主机 D:\VMs\BakNRet-Lab\state\credentials.json,不进版本库。
-->
<unattend xmlns="urn:schemas-microsoft-com:unattend">
<settings pass="specialize">
<component name="Microsoft-Windows-Shell-Setup"
processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35"
language="neutral"
versionScope="nonSxS"
xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<ComputerName>BAKNRET-LAB</ComputerName>
<TimeZone>China Standard Time</TimeZone>
<RegisteredOwner>BakNRet Lab</RegisteredOwner>
<RegisteredOrganization>BakNRet Lab</RegisteredOrganization>
</component>
<component name="Microsoft-Windows-Deployment"
processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35"
language="neutral"
versionScope="nonSxS"
xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<RunSynchronous>
<RunSynchronousCommand wcm:action="add">
<Order>1</Order>
<Description>跳过 OOBE 的联网 / 微软账户强制</Description>
<Path>reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\OOBE" /v BypassNRO /t REG_DWORD /d 1 /f</Path>
</RunSynchronousCommand>
<RunSynchronousCommand wcm:action="add">
<Order>2</Order>
<Description>关掉“让我们完成设备设置”一类打扰</Description>
<Path>reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\UserProfileEngagement" /v ScoobeSystemSettingEnabled /t REG_DWORD /d 0 /f</Path>
</RunSynchronousCommand>
</RunSynchronous>
</component>
</settings>
<settings pass="oobeSystem">
<component name="Microsoft-Windows-International-Core"
processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35"
language="neutral"
versionScope="nonSxS"
xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<InputLocale>zh-CN</InputLocale>
<SystemLocale>zh-CN</SystemLocale>
<UILanguage>zh-CN</UILanguage>
<UserLocale>zh-CN</UserLocale>
</component>
<component name="Microsoft-Windows-Shell-Setup"
processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35"
language="neutral"
versionScope="nonSxS"
xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<OOBE>
<HideEULAPage>true</HideEULAPage>
<HideOEMRegistrationScreen>true</HideOEMRegistrationScreen>
<HideOnlineAccountScreens>true</HideOnlineAccountScreens>
<HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE>
<NetworkLocation>Work</NetworkLocation>
<ProtectYourPC>3</ProtectYourPC>
</OOBE>
<UserAccounts>
<LocalAccounts>
<LocalAccount wcm:action="add">
<Name>lab</Name>
<DisplayName>Lab</DisplayName>
<Description>BakNRet 隔离测试账户</Description>
<Group>Administrators</Group>
<Password>
<Value>__LABPASSWORD__</Value>
<PlainText>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>