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:
1 parent
7173e8ae10
commit
2937eb6652
32 files changed
+8718
-1634
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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`,或者干脆把条目写成绝对路径。
|
||||||
|
|
||||||
|
### 从旧版迁移(重要)
|
||||||
|
|
||||||
|
1. **包内布局变了。** 重构前生成的归档,包内顶层是源目录名;现在软件名条目多了一层 Slot。
|
||||||
|
`Restore.ps1` 会识别这种情况(归档里没有该 Slot 时打印告警并按旧布局解),
|
||||||
|
所以**旧归档仍然恢复得出来**;但要让包内结构统一,跑一次 `.\Backup.ps1 -Force` 重打即可
|
||||||
|
(`-Force` 会忽略"源未更新"判断)。
|
||||||
|
2. **手写路径条目的归档名可能变了。** 清单里把原来的软件名改成绝对路径之后,
|
||||||
|
归档名会从 `<软件名>` 变成 `<末级名>_from_<...>`。用重命名工具对齐(**默认试运行**、
|
||||||
|
逐份大小校验、重建 manifest,只改名不搬数据):
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
.\tools\Rename-Archives.ps1 # 先看计划
|
.\tools\Rename-Archives.ps1 # 先看计划
|
||||||
.\tools\Rename-Archives.ps1 -Apply # 确认后执行
|
.\tools\Rename-Archives.ps1 -Apply # 确认后执行
|
||||||
```
|
```
|
||||||
|
|
||||||
> **合并条目 = 换归档名。** 例如把 `scoop-config` / `scoop-persist` 合成一个 `scoop`
|
它会先用当前规则算出目标名,再从"路径命名算法 / 名录里的软件名 / manifest 里记录过的归档名"
|
||||||
> 数组条目后,归档名从两个变成 `scoop.7z`;旧的 `scoop-config.7z` / `scoop-persist.7z`
|
里找磁盘上真实存在的旧文件。
|
||||||
> 就**没有清单条目指向了**(会出现在孤儿归档审计里)。确认新的 `scoop.7z` 校验通过之后
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
`CREATOR OWNER`(`S-1-3-0`)不是账户,是**访问检查时才替换的占位符** —— 替换成
|
||||||
|
"被检查对象的属主"。所以这句话的真实含义是「谁创建的东西谁有全权」。只回放 ACE 文本、
|
||||||
|
不恢复属主,等于把里面的"谁"换成了跑恢复脚本的账户,**原程序(服务账户 / 专用用户)
|
||||||
|
反而没了读写权限**。真机实测(`tools\lab\Lab.ps1 acl-test`):
|
||||||
|
|
||||||
|
```text
|
||||||
|
原属主 = S-1-5-18 (NT AUTHORITY\SYSTEM)
|
||||||
|
恢复后属主 = S-1-5-18 ← 正确恢复(要靠显式启用的 SeRestorePrivilege)
|
||||||
|
负对照属主 = S-1-5-32-544 ← 只搬文件、不回放安全描述符时,属主落到"跑脚本的账户"
|
||||||
|
```
|
||||||
|
|
||||||
|
**怎么做**:
|
||||||
|
|
||||||
|
- 备份时把每个对象的 SDDL(`Get-Acl` 的原文,含 `O:` / `G:` / `D:`)写进旁挂文件
|
||||||
|
`Backups/<归档名>.acl.json`,键是**归档内相对路径**(目标机器上 `%UserProfile%` 和
|
||||||
|
名录的前缀补全都会变,只有归档内路径两端同源)。
|
||||||
|
- SDDL 里的 SID 是**数值形式**,`CO` / `OW` 这类占位符原样保留。全程**不做账户名解析**
|
||||||
|
—— 名字解析会把占位符映射成当前用户,或者直接抛 `IdentityNotMappedException`,
|
||||||
|
那正是"权限落到脚本头上"的另一种成因。
|
||||||
|
- 恢复时在**解压之后**、对真实目标路径**自顶向下**回放:父目录先写,子对象的继承才收敛。
|
||||||
|
原本不 `protected` 的 DACL 只写显式 ACE,其余交给父目录重新继承(保住活继承语义);
|
||||||
|
`protected` 的原样写。
|
||||||
|
- 写属主要 `SeRestorePrivilege`,而且**必须显式启用**:管理员的过滤令牌里它默认是 disabled,
|
||||||
|
`Set-Acl` / `SetAccessControl` 都不会替你打开。所以**恢复要在管理员(或 SYSTEM)下跑**,
|
||||||
|
脚本启动时会明确告警"属主将无法恢复,只能恢复 DACL"。
|
||||||
|
- 写失败有三级回退:`属主+属组+DACL` → `属主+DACL` → `仅 DACL`(属组常常是最先失败的那个,
|
||||||
|
而它对访问判定几乎没影响,不能因为它把属主一起丢掉)。
|
||||||
|
- 对象的安全描述符读不到(系统目录里很常见)时**带错误记账**、写进 sidecar 并计入 manifest
|
||||||
|
的 `security.errors`,恢复时跳过它并告警 —— 而不是当成"这个对象没有特殊权限"。
|
||||||
|
|
||||||
|
配置在 `BackupConfig.psd1`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Security = @{
|
||||||
|
Mode = 'Full' # Off | Full | Smart | Roots
|
||||||
|
IncludeSacl = $false # 连审计规则(SACL)一起存取,需要 SeSecurityPrivilege
|
||||||
|
SidMap = @{} # 跨机恢复的 SID 映射:@{ 'S-1-5-21-旧' = 'S-1-5-21-新' }
|
||||||
|
FailOnError = $false # sidecar 写不出来时,是否把该条目算作失败
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `Full`(默认):每个对象都存。**正确性优先**,几万文件的树 sidecar 几 MB。
|
||||||
|
- `Smart`:只存"继承复现不出来"的对象(protected / 有显式 ACE / 属主属组与父目录不同 /
|
||||||
|
继承链已脱节)。判据偏保守,但终究是启发式,所以不是默认。
|
||||||
|
- `Roots`:只存每个归档项的根,最省。
|
||||||
|
- `Off`:完全不采集,恢复出来的就是新建对象的默认值。
|
||||||
|
|
||||||
|
`Restore.ps1` 另有 `-SkipSecurity` 可以只恢复文件内容。
|
||||||
|
|
||||||
|
**已知取舍(有意为之)**:
|
||||||
|
|
||||||
|
- 归档旁边没有 `acl.json` 的旧归档照常恢复,只是打一行告警说明"属主/ACL 是默认值"。
|
||||||
|
- **陈旧继承 ACE 会被"冻结"**:如果某个对象的 DACL 里留着已经没有任何出处的继承 ACE
|
||||||
|
(父目录改过权限、Windows 自己也不会再传播它),那它靠继承复现不出来,只能整套冻结成
|
||||||
|
显式 ACE **并置 protected** —— 这是唯一"既不丢 ACE、也不产生重复 ACE"的做法(实测:
|
||||||
|
目标上原本就留着那条陈旧 ACE,再补一条显式 ACE 会让同一条 ACE 出现两次)。
|
||||||
|
代价是这个对象从此不跟随父目录,而它本来就已经跟父目录脱节了。
|
||||||
|
- ACL 只跟着归档旁边的 `acl.json` 走:**搬归档时要把同名的 `.acl.json` 一起搬**。
|
||||||
|
|
||||||
## 恢复语义
|
## 恢复语义
|
||||||
|
|
||||||
- 用 `7z x` 把归档里**该目标对应的那棵子树**解到目标的父目录,覆盖同名文件。
|
- 每一项只解出**它自己那棵子树**(`<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
@@ -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
@@ -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 的设置文件。'
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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(事件溯源订单)冲突,但值得重开,因为……
|
||||||
@@ -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。
|
||||||
+352
-174
@@ -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' = @(
|
|
||||||
@{ Path = '$collideP1'; Description = 'p1 里的' }
|
|
||||||
@{ Path = '$collideP2'; Description = 'p2 里的' }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
"@
|
'solo' = @{ Only = @{ Path = '$script:DirA' } }
|
||||||
|
'partial' = @{ Ok = @{ Path = '$script:DirA' }; Gone = @{ Path = '$script:MissingDir' } }
|
||||||
$script:MissingDir = Join-Path $script:FormatRoot 'not-here'
|
'slotex' = @{ Data = @{ Path = '$script:DirA'; Exclude = '!*Cache,logs\' } }
|
||||||
$missingPath = $script:MissingDir
|
'fileapp' = @{ Cfg = @{ Path = '$script:CfgFile'; Encrypt = `$true } }
|
||||||
$script:PartialCatalog = Write-ListFile -Path (Join-Path $script:FormatRoot 'cat-partial.psd1') -Content @"
|
'conflict' = @{ Data = @{ Path = '$script:DirA' } }
|
||||||
@{
|
'legacyarr' = @('$script:DirA', '$script:DirB')
|
||||||
'pair' = @(
|
'legacydirs' = @{ Dirs = @('$script:DirA', '$script:DirB') }
|
||||||
@{ Path = '$dirAPath'; Description = '存在' }
|
'legacystr' = '$script:DirA'
|
||||||
@{ 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'
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large.
Load diff
+259
-69
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+95
-27
@@ -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 })
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
@@ -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 下,便于事后排查;确认不要了再手工删该目录
|
||||||
|
```
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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')
|
||||||
|
}
|
||||||
@@ -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 = '故意写错,由清单里的 :: 覆盖成存在的目录' }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
Reference in new issue
Block a user