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
+8775
-1691
No files matched your search
+245
-166
@@ -16,6 +16,12 @@
|
||||
跳过和失败从此有据可查,而不是只剩一行滚过去的控制台告警。
|
||||
5. 结尾按失败数 exit,并写日志文件,计划任务能正确判断成败。
|
||||
6. 磁盘空间守卫:放不下就拒绝该条目,低于阈值则告警。
|
||||
|
||||
与 SoftwareCatalog.psd1 的 Slot 结构配套:
|
||||
* 一个软件 = 一个归档,归档内是 `<Slot>\<该 Path 的内容>`;
|
||||
* 打包前用暂存目录 + junction / 硬链接把 Slot 名变成包里真实的目录名
|
||||
(7z 没有"入库时改名"的能力),打包后立刻拆掉暂存目录;
|
||||
* 清单行首 `+` = 仅备份、`-` = 仅恢复。
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
@@ -116,7 +122,11 @@ if (-not (Test-Path -LiteralPath $BackupDir)) {
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $BackupListPath)) {
|
||||
$template = "# BackupList.txt`n# 语法: <路径> [ :: <排除模式>[,<排除模式>...] ] [ @<标记> ]`n# 示例: %UserProfile%\.ssh`n"
|
||||
$template = "# BackupList.txt`n" +
|
||||
"# 语法: [+|-] <软件名 或 绝对路径> [:: <路径>] [:- <排除模式>[,<排除模式>...]] [:+ <追加项>[,<追加项>...]] [:encrypt | :!encrypt] [@ <Key>='<值>'] [# 说明]`n" +
|
||||
"# 示例: Edge`n" +
|
||||
"# %UserProfile%\.ssh :encrypt`n" +
|
||||
"# 完整语法见 README 与 BackupList.txt 自身的注释。`n"
|
||||
[System.IO.File]::WriteAllText($BackupListPath, $template, [System.Text.UTF8Encoding]::new($false))
|
||||
Write-Log '模板 BackupList.txt 已创建,请编辑后重试。' -Level INFO
|
||||
Stop-BaknretLog
|
||||
@@ -148,6 +158,8 @@ $toolQuietArgument = if ($showToolOutput) { @() } else { @('-bso0', '-bsp0') }
|
||||
$lines = Get-Content -LiteralPath $BackupListPath
|
||||
$seenBaseNames = @{}
|
||||
$processed = 0; $skipped = 0; $failed = 0; $planned = 0
|
||||
$securityErrorCount = 0 # 有条目"安全描述符里有读不到的对象"
|
||||
$securityFailed = 0 # 有条目"安全描述符完全没存下来"
|
||||
$failures = @()
|
||||
$freeSpaceGB = Get-BaknretFreeSpaceGB -Path $BackupDir
|
||||
if ($freeSpaceGB -ge 0) {
|
||||
@@ -179,6 +191,7 @@ function New-ItemRecord {
|
||||
source = $Source
|
||||
resolvedSource = $ResolvedSource
|
||||
roots = @()
|
||||
layouts = @()
|
||||
catalog = $null
|
||||
archive = $null
|
||||
action = $null
|
||||
@@ -192,6 +205,7 @@ function New-ItemRecord {
|
||||
warnings = $false
|
||||
attemptWarnings = $false
|
||||
encrypted = $false
|
||||
security = $null
|
||||
sourceFiles = $null
|
||||
sourceBytes = $null
|
||||
archiveBytes = $null
|
||||
@@ -221,6 +235,12 @@ function Save-ItemRecord {
|
||||
$Record.warnings = [bool]$previous.warnings
|
||||
}
|
||||
|
||||
# security 描述的是"当前在位的归档"的旁挂文件,和 warnings 同理:
|
||||
# 只有真的换了归档才更新它,否则跳过的那次会把已有记录清成 $null。
|
||||
if ($Action -ne 'backed-up' -and $previous -and ($previous.PSObject.Properties.Name -contains 'security')) {
|
||||
$Record.security = $previous.security
|
||||
}
|
||||
|
||||
if ($previous) {
|
||||
if ($previous.PSObject.Properties.Name -contains 'lastSuccessAt') { $Record.lastSuccessAt = $previous.lastSuccessAt }
|
||||
if ($previous.PSObject.Properties.Name -contains 'successCount') { $Record.successCount = [int]$previous.successCount }
|
||||
@@ -240,14 +260,17 @@ function Save-ItemRecord {
|
||||
|
||||
# 压缩 + 校验 + 原子替换;返回 @{ Ok; ExitCode; Warnings; Reason }
|
||||
#
|
||||
# $SourceGroups 支持"一个软件包含多个目录":每个元素是
|
||||
# @{ ParentDir; RelativePaths; Label }。7z/RAR 对同一归档多次 `a` 会把内容并入,
|
||||
# 所以按父目录分组、逐组追加,归档里每个目录仍保留自己的名字与层级。
|
||||
# 归档内容由调用方决定:它已经用 New-BaknretArchiveStaging 把每个归档项按"归档内的名字"
|
||||
# 挂进了 $StagingRoot(目录走 junction、文件走硬链接/复制),所以这里只做三件事:
|
||||
# 1. 以暂存目录为工作目录调用压缩工具,把项名加进去;
|
||||
# 2. 用 7z t 校验,并核对多 Slot 条目的每个顶层名字都真的在包里;
|
||||
# 3. 有警告时按保护策略决定是否原子替换。
|
||||
function Invoke-BackupItem {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][array]$SourceGroups,
|
||||
[Parameter(Mandatory = $true)][array]$SourceItems,
|
||||
[Parameter(Mandatory = $true)][string]$StagingRoot,
|
||||
[Parameter(Mandatory = $true)][string]$FinalPath,
|
||||
[string[]]$ExcludePatterns,
|
||||
[string[]]$ExcludePatterns = @(),
|
||||
[switch]$UseEncryption,
|
||||
[switch]$ProtectPrevious,
|
||||
[switch]$AcceptWarnings
|
||||
@@ -258,87 +281,62 @@ function Invoke-BackupItem {
|
||||
|
||||
$warnings = $false
|
||||
$lastExitCode = 0
|
||||
$lastParentDir = $null
|
||||
$itemNames = @($SourceItems | ForEach-Object { [string]$_.ArchivePath })
|
||||
$realPaths = @($SourceItems | ForEach-Object { [string]$_.RealPath })
|
||||
|
||||
try {
|
||||
if ($SourceGroups.Count -eq 0) {
|
||||
if ($SourceItems.Count -eq 0) {
|
||||
return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '没有可打包的源目录' }
|
||||
}
|
||||
|
||||
$groupIndex = 0
|
||||
foreach ($group in $SourceGroups) {
|
||||
$groupIndex++
|
||||
$parentDir = $group.ParentDir
|
||||
$relativePaths = @($group.RelativePaths)
|
||||
if ($relativePaths.Count -eq 0) { continue }
|
||||
$lastParentDir = $parentDir
|
||||
if ($tool.Name -eq '7z') {
|
||||
$optimized = Get-Optimized7zArgument -SourcePath $realPaths -Level $script:Config.CompressionLevel
|
||||
$argument = @($optimized.Argument) + $toolQuietArgument + @($ExcludePatterns)
|
||||
|
||||
# 排除模式的**前缀用这一组的源目录名**(归档里就是这个层级)。
|
||||
$prefixName = if ($group.Label) { $group.Label } else { Split-Path -Path $relativePaths[0] -Leaf }
|
||||
$excludeArgument = Get-ArchiveExcludeArgument -ItemName $prefixName -Patterns $ExcludePatterns
|
||||
|
||||
if ($tool.Name -eq '7z') {
|
||||
$probePath = "$($parentDir.TrimEnd('\'))\$($relativePaths[0])"
|
||||
$optimized = Get-Optimized7zArgument -SourcePath $probePath -Level $script:Config.CompressionLevel
|
||||
$argument = @($optimized.Argument) + $toolQuietArgument + $excludeArgument
|
||||
|
||||
if ($UseEncryption) {
|
||||
if (-not $password) {
|
||||
return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '需要加密但取不到口令(见 README「加密」)' }
|
||||
}
|
||||
$argument += "-p$password"
|
||||
if ($script:Config.Encryption.EncryptHeaders) { $argument += '-mhe=on' }
|
||||
if ($UseEncryption) {
|
||||
if (-not $password) {
|
||||
return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '需要加密但取不到口令(见 README「加密」)' }
|
||||
}
|
||||
|
||||
$argument += $tempPath
|
||||
foreach ($relative in $relativePaths) { $argument += $relative }
|
||||
|
||||
$exitCode = Invoke-ExternalCommand -FilePath $tool.Command -ArgumentList $argument -WorkingDirectory $parentDir
|
||||
$lastExitCode = $exitCode
|
||||
# 7z: 0 成功;1 警告(有文件读不到或跳过);2 及以上为失败
|
||||
if ($exitCode -ne 0 -and $exitCode -ne 1) {
|
||||
return [pscustomobject]@{ Ok = $false; ExitCode = $exitCode; Warnings = $warnings; Reason = "第 $groupIndex 组($prefixName)压缩工具退出码 $exitCode" }
|
||||
}
|
||||
if ($exitCode -eq 1) { $warnings = $true }
|
||||
$argument += "-p$password"
|
||||
if ($script:Config.Encryption.EncryptHeaders) { $argument += '-mhe=on' }
|
||||
}
|
||||
elseif ($tool.Name -eq 'RAR') {
|
||||
$argument = @('a', '-m5', '-idp', '-idn') + $toolQuietArgument + $excludeArgument
|
||||
if ($UseEncryption) {
|
||||
if (-not $password) {
|
||||
return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '需要加密但取不到口令' }
|
||||
}
|
||||
$argument += "-p$password"
|
||||
}
|
||||
$argument += $tempPath
|
||||
foreach ($relative in $relativePaths) { $argument += $relative }
|
||||
|
||||
$exitCode = Invoke-ExternalCommand -FilePath $tool.Command -ArgumentList $argument -WorkingDirectory $parentDir
|
||||
$lastExitCode = $exitCode
|
||||
if ($exitCode -ne 0) {
|
||||
return [pscustomobject]@{ Ok = $false; ExitCode = $exitCode; Warnings = $warnings; Reason = "第 $groupIndex 组($prefixName)压缩工具退出码 $exitCode" }
|
||||
}
|
||||
$argument += $tempPath
|
||||
$argument += $itemNames
|
||||
|
||||
$exitCode = Invoke-ExternalCommand -FilePath $tool.Command -ArgumentList $argument -WorkingDirectory $StagingRoot
|
||||
$lastExitCode = $exitCode
|
||||
# 7z: 0 成功;1 警告(有文件读不到或跳过);2 及以上为失败
|
||||
if ($exitCode -ne 0 -and $exitCode -ne 1) {
|
||||
return [pscustomobject]@{ Ok = $false; ExitCode = $exitCode; Warnings = $warnings; Reason = "压缩工具退出码 $exitCode" }
|
||||
}
|
||||
else {
|
||||
if ($UseEncryption) {
|
||||
return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '内置 ZIP 不支持加密,请改用 7z 或去掉 encrypt 标记' }
|
||||
}
|
||||
# Compress-Archive 不能追加;多组时逐组重打(先把已有临时归档解开再合并会让代码复杂得多,
|
||||
# 而 ZIP 本来就是降级路径,这里只保证内容完整)
|
||||
$fullPaths = @($relativePaths | ForEach-Object { Join-Path $parentDir $_ })
|
||||
if ($groupIndex -gt 1 -and (Test-Path -LiteralPath $tempPath)) {
|
||||
$staging = Join-Path $env:TEMP ("bnr-zip-" + [guid]::NewGuid().ToString('N'))
|
||||
New-Item -ItemType Directory -Path $staging -Force | Out-Null
|
||||
try {
|
||||
Expand-Archive -LiteralPath $tempPath -DestinationPath $staging -Force
|
||||
$fullPaths += @(Get-ChildItem -LiteralPath $staging -Force | Select-Object -ExpandProperty FullName)
|
||||
Compress-Archive -Path $fullPaths -DestinationPath $tempPath -CompressionLevel Optimal -Force
|
||||
} finally {
|
||||
Remove-Item -LiteralPath $staging -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
} else {
|
||||
Compress-Archive -Path $fullPaths -DestinationPath $tempPath -CompressionLevel Optimal -Force
|
||||
if ($exitCode -eq 1) { $warnings = $true }
|
||||
}
|
||||
elseif ($tool.Name -eq 'RAR') {
|
||||
$argument = @('a', '-m5', '-idp', '-idn') + $toolQuietArgument + @($ExcludePatterns)
|
||||
if ($UseEncryption) {
|
||||
if (-not $password) {
|
||||
return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '需要加密但取不到口令' }
|
||||
}
|
||||
$argument += "-p$password"
|
||||
}
|
||||
$argument += $tempPath
|
||||
$argument += $itemNames
|
||||
|
||||
$exitCode = Invoke-ExternalCommand -FilePath $tool.Command -ArgumentList $argument -WorkingDirectory $StagingRoot
|
||||
$lastExitCode = $exitCode
|
||||
if ($exitCode -ne 0) {
|
||||
return [pscustomobject]@{ Ok = $false; ExitCode = $exitCode; Warnings = $warnings; Reason = "压缩工具退出码 $exitCode" }
|
||||
}
|
||||
}
|
||||
else {
|
||||
if ($UseEncryption) {
|
||||
return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '内置 ZIP 不支持加密,请改用 7z 或去掉加密' }
|
||||
}
|
||||
# Compress-Archive 没有排除开关,也没有加密;这是降级路径,只保证内容完整。
|
||||
# 暂存目录里的名字就是归档内的名字,所以布局与 7z 分支一致。
|
||||
$fullPaths = @($SourceItems | ForEach-Object { Join-Path $StagingRoot $_.ArchivePath })
|
||||
Compress-Archive -Path $fullPaths -DestinationPath $tempPath -CompressionLevel Optimal -Force
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $tempPath)) {
|
||||
@@ -351,22 +349,22 @@ function Invoke-BackupItem {
|
||||
if ($UseEncryption -and $password) { $verifyArgument += "-p$password" }
|
||||
$verifyArgument += $tempPath
|
||||
|
||||
$verifyCode = Invoke-ExternalCommand -FilePath $tool.Command -ArgumentList $verifyArgument -WorkingDirectory $lastParentDir
|
||||
$verifyCode = Invoke-ExternalCommand -FilePath $tool.Command -ArgumentList $verifyArgument -WorkingDirectory $StagingRoot
|
||||
if ($verifyCode -ne 0) {
|
||||
Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue
|
||||
return [pscustomobject]@{ Ok = $false; ExitCode = $verifyCode; Warnings = $false; Reason = "归档校验失败(7z t 退出码 $verifyCode),已丢弃临时文件" }
|
||||
}
|
||||
Write-Log '归档校验通过(7z t)' -Level DEBUG
|
||||
|
||||
# 多目录时确认每个目录都真的进了归档:7z 的"警告"可能只体现在某一组里
|
||||
if ($SourceGroups.Count -gt 1) {
|
||||
# 多个 Slot / 追加项时确认每一个顶层名字都真的进了归档:7z 的"警告"可能只体现在某一部分上
|
||||
if ($SourceItems.Count -gt 1) {
|
||||
$listed = @(Get-ArchiveTopLevelNames -ArchivePath $tempPath -SevenZip $tool.Command -Password $(if ($UseEncryption) { $password } else { $null }))
|
||||
if ($listed.Count -gt 0) {
|
||||
$expected = @($SourceGroups | ForEach-Object { Split-Path -Path $_.RelativePaths[0] -Leaf })
|
||||
$expected = @($SourceItems | ForEach-Object { [string]$_.TopName } | Select-Object -Unique)
|
||||
$absent = @($expected | Where-Object { $_ -notin $listed })
|
||||
if ($absent.Count -gt 0) {
|
||||
Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue
|
||||
return [pscustomobject]@{ Ok = $false; ExitCode = $lastExitCode; Warnings = $true; Reason = ("归档缺少这些目录:{0}(归档内实际有:{1})" -f ($absent -join '、'), ($listed -join '、')) }
|
||||
return [pscustomobject]@{ Ok = $false; ExitCode = $lastExitCode; Warnings = $true; Reason = ("归档缺少这些顶层条目:{0}(归档内实际有:{1})" -f ($absent -join '、'), ($listed -join '、')) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -419,16 +417,17 @@ foreach ($planLine in $lines) {
|
||||
$planResolved = Resolve-BackupEntry -Entry $planItem -CatalogPath $catalogPath -MaxDepth $script:Config.CatalogMaxDepth
|
||||
if (-not $planResolved.BaseName) { continue }
|
||||
if (-not (Test-ItemSelected -DisplayPath $planDisplayPath -BaseName $planResolved.BaseName)) { continue }
|
||||
if ($planResolved.Direction -eq 'restore') { continue }
|
||||
if ($planResolved.Blocking) { continue }
|
||||
|
||||
$planSources = @($planResolved.Sources | Where-Object { Test-Path -LiteralPath $_.SourcePath })
|
||||
if ($planSources.Count -eq 0) { $spaceNoSource++; continue }
|
||||
$planItems = @($planResolved.Items | Where-Object { Test-Path -LiteralPath $_.RealPath })
|
||||
if ($planItems.Count -eq 0) { $spaceNoSource++; continue }
|
||||
|
||||
$planSourceBytes = [int64]0
|
||||
$planSourceFiles = 0
|
||||
$planLatest = $null
|
||||
foreach ($planSource in $planSources) {
|
||||
$planSummary = Get-FolderSummary -FolderPath $planSource.SourcePath
|
||||
foreach ($planSource in $planItems) {
|
||||
$planSummary = Get-FolderSummary -FolderPath $planSource.RealPath
|
||||
$planSourceBytes += [int64]$planSummary.TotalSize
|
||||
$planSourceFiles += [int]$planSummary.FileCount
|
||||
if ($planSummary.LatestModifiedTime -and (-not $planLatest -or $planSummary.LatestModifiedTime -gt $planLatest)) {
|
||||
@@ -533,29 +532,35 @@ foreach ($line in $lines) {
|
||||
continue
|
||||
}
|
||||
|
||||
$record = New-ItemRecord -BaseName $baseName -Source $displayPath -ResolvedSource $sourcePath -Phase 'backup'
|
||||
$record.archive = $baseName + $tool.Extension
|
||||
if ($resolved.CatalogEntry) { $record.catalog = $resolved.CatalogEntry.Path }
|
||||
$finalPath = Join-Path $BackupDir $record.archive
|
||||
|
||||
# root= 在 README 里被列为可用标记,但归档内的根目录实际上始终是源目录名
|
||||
# (见 README「设计取舍」:不套一层软件名目录)。7z 命令行也没有"入库时改名"
|
||||
# 的能力,所以这里明确告警而不是让它静默失效——静默失效正是本次重构要消灭的东西。
|
||||
if (@($item.Flags | Where-Object { $_ -like 'root=*' }).Count -gt 0) {
|
||||
Write-Log "警告: $displayPath 使用了 root= 标记,该功能尚未实现(归档内的根目录始终是源目录名),本次忽略" -Level WARN
|
||||
}
|
||||
|
||||
# 备份列表里写重了会生成两个同名归档,互相覆盖 —— 直接报错,不猜。
|
||||
# 归档名登记必须在方向过滤**之前**:`-`(仅恢复)的条目不会产生归档,
|
||||
# 但它对应的归档是有主的,不能被下游的孤儿审计当成没人要的孤儿。
|
||||
# 备份列表里写重了会生成两个同名归档、互相覆盖 —— 直接报错,不猜。
|
||||
if ($seenBaseNames.ContainsKey($baseName)) {
|
||||
$reason = "归档名 '$baseName' 与清单中的 '$($seenBaseNames[$baseName])' 重复(由 '$displayPath' 生成),两者会互相覆盖"
|
||||
Write-Log "失败: $displayPath,$reason" -Level ERROR
|
||||
$record = New-ItemRecord -BaseName $baseName -Source $displayPath -ResolvedSource $sourcePath -Phase 'backup'
|
||||
Save-ItemRecord -Record $record -Action 'failed' -Reason $reason | Out-Null
|
||||
$failed++; $failures += $displayPath
|
||||
continue
|
||||
}
|
||||
$seenBaseNames[$baseName] = $displayPath
|
||||
|
||||
# 归档内顶层同名冲突:明确失败,绝不把两个目录静默搅进同一棵树
|
||||
if ($resolved.Direction -eq 'restore') {
|
||||
Write-Log "跳过(行首 -,仅恢复): $displayPath" -Level INFO
|
||||
continue
|
||||
}
|
||||
|
||||
$record = New-ItemRecord -BaseName $baseName -Source $displayPath -ResolvedSource $sourcePath -Phase 'backup'
|
||||
$record.archive = $baseName + $tool.Extension
|
||||
if ($resolved.CatalogEntry) { $record.catalog = $resolved.CatalogEntry.Path }
|
||||
$finalPath = Join-Path $BackupDir $record.archive
|
||||
|
||||
# root= 是历史标记:包内的一层目录现在由名录里的 Slot 决定,这里只告警不静默忽略。
|
||||
if (@($item.Flags | Where-Object { $_ -like 'root=*' }).Count -gt 0) {
|
||||
Write-Log "警告: $displayPath 使用了 root= 标记。归档内的一层目录现在由 Slot 决定,该标记已废弃,本次忽略" -Level WARN
|
||||
}
|
||||
|
||||
# 归档内路径冲突:明确失败,绝不把两块内容静默搅进同一棵树
|
||||
if ($resolved.Blocking) {
|
||||
Write-Log "失败: $displayPath,$($resolved.Blocking)" -Level ERROR
|
||||
Save-ItemRecord -Record $record -Action 'failed' -Reason $resolved.Blocking | Out-Null
|
||||
@@ -563,16 +568,22 @@ foreach ($line in $lines) {
|
||||
continue
|
||||
}
|
||||
|
||||
# 动手之前先把"这条会打包哪些目录、排除了什么、为什么"讲清楚
|
||||
# 动手之前先把"这条会打包哪些目录、归档里叫什么、排除了什么、为什么"讲清楚
|
||||
$planListExcludes = @()
|
||||
$planCatalogExcludes = @()
|
||||
if ($resolved.HasExcludeOverride) {
|
||||
$planListExcludes = @($resolved.ExcludePatterns)
|
||||
} else {
|
||||
$planCatalogExcludes = @($resolved.Items | ForEach-Object { @($_.Exclude) } | Where-Object { $_ } | Select-Object -Unique)
|
||||
}
|
||||
Write-BackupEntryPlan -Resolved $resolved -DisplayPath $displayPath `
|
||||
-ListExcludes @($item.ExcludePatterns) -ConfigExcludes @($script:Config.DefaultExcludes) `
|
||||
-Comment $item.Comment
|
||||
-ListExcludes $planListExcludes -CatalogExcludes $planCatalogExcludes `
|
||||
-ConfigExcludes @($script:Config.DefaultExcludes) -Comment $item.Comment
|
||||
|
||||
# Sources 为空 = 解析不出任何源(名录里没这个软件名、或路径拆不出父/子级)。
|
||||
# Items 为空 = 解析不出任何归档项(名录里没这个软件名、或路径拆不出末级名)。
|
||||
# 注意不能用 $resolved.Error 判断:名录里的路径不存在时 Error 有值,
|
||||
# 但 Sources 是给出的(恢复端要靠它把内容还原回原位),备份端由下面的
|
||||
# 存在性检查统一处理。
|
||||
if ($resolved.Sources.Count -eq 0) {
|
||||
# 但 Items 是给出的(恢复端要靠它把内容还原回原位),备份端由下面的存在性检查统一处理。
|
||||
if ($resolved.Items.Count -eq 0) {
|
||||
$reason = if ($resolved.Error) { $resolved.Error } else { '解析不出任何源路径' }
|
||||
Write-Log "跳过: $displayPath,$reason" -Level WARN
|
||||
Save-ItemRecord -Record $record -Action 'missing-source' -Reason $reason | Out-Null
|
||||
@@ -582,65 +593,50 @@ foreach ($line in $lines) {
|
||||
|
||||
# 源存在性检查必须在 Get-FolderSummary / Get-Item 之前:
|
||||
# 两者对不存在的路径要么抛异常、要么返回会误导判断的空摘要。
|
||||
# 注意不能用 Join-Path 探测:目标盘符不存在时它会直接抛异常。
|
||||
# 源路径存在性以 SourcePath 为准:RelativePaths 是"归档里的名字",
|
||||
# 目前两者一致,但 SourcePath 才是磁盘上的真实位置。
|
||||
$expectedRoots = 0
|
||||
$missingRoots = @()
|
||||
foreach ($source in $resolved.Sources) {
|
||||
$expectedRoots++
|
||||
if (-not (Test-Path -LiteralPath $source.SourcePath)) { $missingRoots += $source.SourcePath }
|
||||
}
|
||||
$missingItems = @($resolved.Items | Where-Object { -not (Test-Path -LiteralPath $_.RealPath) })
|
||||
|
||||
if ($missingRoots.Count -ge $expectedRoots) {
|
||||
if ($missingItems.Count -ge $resolved.Items.Count) {
|
||||
$missingText = @($missingItems | ForEach-Object { $_.RealPath }) -join ';'
|
||||
Write-Log "跳过: $displayPath,源路径不存在" -Level WARN
|
||||
Save-ItemRecord -Record $record -Action 'missing-source' -Reason ('源路径不存在:' + ($missingRoots -join ';')) | Out-Null
|
||||
Save-ItemRecord -Record $record -Action 'missing-source' -Reason ('源路径不存在:' + $missingText) | Out-Null
|
||||
$skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
if ($missingRoots.Count -gt 0) {
|
||||
Write-Log ("警告: {0} 有 {1} 个源路径不存在,本次只备份存在的部分:{2}" -f $displayPath, $missingRoots.Count, ($missingRoots -join ';')) -Level WARN
|
||||
if ($missingItems.Count -gt 0) {
|
||||
Write-Log ("警告: {0} 有 {1} 个归档项的源路径不存在,本次只打包存在的部分:{2}" -f `
|
||||
$displayPath, $missingItems.Count, (@($missingItems | ForEach-Object { $_.RealPath }) -join ';')) -Level WARN
|
||||
}
|
||||
|
||||
# 归档里只放真实存在的源
|
||||
$liveSources = @()
|
||||
foreach ($source in $resolved.Sources) {
|
||||
if (Test-Path -LiteralPath $source.SourcePath) {
|
||||
$liveSources += [pscustomobject]@{
|
||||
RootName = $source.RootName
|
||||
ParentDir = $source.ParentDir
|
||||
RelativePaths = @($source.RelativePaths)
|
||||
SourcePath = $source.SourcePath
|
||||
Description = $source.Description
|
||||
Origin = $source.Origin
|
||||
$liveItems = @($resolved.Items | Where-Object { Test-Path -LiteralPath $_.RealPath })
|
||||
|
||||
# 归档内的顶层条目名 = 真实存在的归档项在包内的第一层名字(Slot 名 / 源目录名)。
|
||||
# 这里记录可核对的事实,备份成功后还会用 Get-ArchiveTopLevelNames 与归档内容对账。
|
||||
$record.roots = @($liveItems | ForEach-Object { $_.TopName } | Select-Object -Unique)
|
||||
|
||||
# 恢复端要知道每个项在归档里是目录还是文件:记在 manifest 里,
|
||||
# 这样目标机器上目标还不存在(全新恢复)时也判断得出来。
|
||||
$record.layouts = @($liveItems | ForEach-Object {
|
||||
[ordered]@{
|
||||
name = $_.ArchivePath
|
||||
kind = $(if ($_.IsFile) { 'file' } else { 'dir' })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
# 归档内的顶层条目名 = 每个**真实存在**的源在归档里的第一层名字,也就是源目录
|
||||
# (或源文件)自己的名字。刻意不用 $resolved.Sources[].RootName:那套"归档内套一层
|
||||
# 软件名"的设想已按设计取舍放弃,实际布局始终是 <源目录名>\...。
|
||||
# 这里记录可核对的事实,之前写成软件名会让 Edge(实际是 "User Data")之类的条目对不上。
|
||||
$record.roots = @($liveSources | ForEach-Object {
|
||||
$_.RelativePaths | ForEach-Object { ($_ -split '[\\/]')[0] }
|
||||
} | Select-Object -Unique)
|
||||
|
||||
$primarySource = $liveSources[0].SourcePath
|
||||
$parentDir = $liveSources[0].ParentDir
|
||||
# 排除模式的前缀始终用**源目录名**(归档里就是这个层级)
|
||||
$itemName = Split-Path -Path $primarySource -Leaf
|
||||
|
||||
if (-not $parentDir -or -not $itemName) {
|
||||
Write-Log "跳过: $displayPath,无法处理根目录" -Level WARN
|
||||
Save-ItemRecord -Record $record -Action 'invalid-path' -Reason '无法拆出父目录或末级名' | Out-Null
|
||||
$primarySource = $liveItems[0].RealPath
|
||||
if ([string]::IsNullOrWhiteSpace($primarySource)) {
|
||||
Write-Log "跳过: $displayPath,无法确定主源路径" -Level WARN
|
||||
Save-ItemRecord -Record $record -Action 'invalid-path' -Reason '归档项没有可用路径' | Out-Null
|
||||
$skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
$summary = Get-FolderSummary -FolderPath $primarySource
|
||||
foreach ($source in $liveSources[1..($liveSources.Count - 1)]) {
|
||||
$extra = Get-FolderSummary -FolderPath $source.SourcePath
|
||||
# 从第二个归档项开始累加。刻意不用 `$liveItems[1..($liveItems.Count-1)]`:
|
||||
# 只有一项时 `1..0` 会退化成 `1,0`,把同一份源数两遍(旧实现的隐蔽 bug)。
|
||||
for ($index = 1; $index -lt $liveItems.Count; $index++) {
|
||||
$extra = Get-FolderSummary -FolderPath $liveItems[$index].RealPath
|
||||
$summary.FileCount += $extra.FileCount
|
||||
$summary.TotalSize += $extra.TotalSize
|
||||
if ($extra.LatestModifiedTime -and $extra.LatestModifiedTime -gt $summary.LatestModifiedTime) {
|
||||
@@ -693,13 +689,38 @@ foreach ($line in $lines) {
|
||||
continue
|
||||
}
|
||||
|
||||
$useEncryption = $encryptAll -or ($item.Flags -contains 'encrypt')
|
||||
$useEncryption = $encryptAll -or [bool]$resolved.Encrypt
|
||||
$record.encrypted = [bool]$useEncryption
|
||||
$startedAt = Get-Date
|
||||
$record.attemptedAt = $startedAt.ToString('o')
|
||||
|
||||
# 配置里的全局排除 + 本条目的排除
|
||||
$effectiveExcludes = @($script:Config.DefaultExcludes) + @($item.ExcludePatterns)
|
||||
# 排除参数:条目级 `:-` / `@ Exclude` 覆盖优先,否则用名录里各 Slot 自己的 Exclude;
|
||||
# 再叠上 BackupConfig.psd1 的 DefaultExcludes。
|
||||
# 模式先用 `<顶层名>\` 前缀分配到对应归档项上(`Scoop :- GlobalPersist\steam`),
|
||||
# 再逐项翻译成 7z 的 -x! / -xr!,最后去重合并成一次调用的参数。
|
||||
$patternSource = if ($resolved.HasExcludeOverride) {
|
||||
@($resolved.ExcludePatterns)
|
||||
} else {
|
||||
@($resolved.Items | ForEach-Object { @($_.Exclude) } | Where-Object { $_ } | Select-Object -Unique)
|
||||
}
|
||||
$allPatterns = @($script:Config.DefaultExcludes) + $patternSource
|
||||
$scopeMap = Split-BaknretPatternScope -Items $liveItems -Patterns $allPatterns
|
||||
|
||||
$excludeLists = @()
|
||||
$excludeError = $null
|
||||
for ($index = 0; $index -lt $liveItems.Count; $index++) {
|
||||
$expanded = Get-BaknretExcludeArgument -Item $liveItems[$index] -Patterns @($scopeMap[$index])
|
||||
if ($expanded.Error) { $excludeError = $expanded.Error }
|
||||
$excludeLists += , @($expanded.Arguments)
|
||||
}
|
||||
$effectiveExcludes = @(Merge-BaknretExcludeArgument -ArgumentLists $excludeLists)
|
||||
|
||||
if ($excludeError) {
|
||||
Write-Log "失败: $displayPath,$excludeError" -Level ERROR
|
||||
Save-ItemRecord -Record $record -Action 'failed' -Reason $excludeError | Out-Null
|
||||
$failed++; $failures += $displayPath
|
||||
continue
|
||||
}
|
||||
|
||||
# 只有在"现有归档是完整的"时才值得保护它。没有 manifest 记录
|
||||
# (本次重构之前留下的归档)时按完整处理——宁可保守。
|
||||
@@ -711,21 +732,19 @@ foreach ($line in $lines) {
|
||||
}
|
||||
}
|
||||
|
||||
# 多目录:每个源组各带自己的父目录与相对名。7z 会对同一归档逐组追加。
|
||||
# Label 刻意留空:排除模式的前缀必须是**归档里的那一层名字**,也就是源目录名
|
||||
# (归档内布局是 `<源目录名>\...`)。若把软件名当 Label 传下去,
|
||||
# 排除模式就会变成 `软件名\skip.bin`,与实际路径对不上而静默失效。
|
||||
$sourceGroups = @($liveSources | ForEach-Object {
|
||||
[pscustomobject]@{
|
||||
ParentDir = $_.ParentDir
|
||||
RelativePaths = @($_.RelativePaths)
|
||||
Label = $null
|
||||
}
|
||||
})
|
||||
|
||||
$result = Invoke-BackupItem -SourceGroups $sourceGroups `
|
||||
-FinalPath $finalPath -ExcludePatterns $effectiveExcludes -UseEncryption:$useEncryption `
|
||||
-ProtectPrevious:$protectPrevious -AcceptWarnings:$AcceptWarnings
|
||||
# 归档内的一层目录名由 Slot / 追加项的归档内路径决定,所以先把它们以正确的名字
|
||||
# 挂进暂存目录(junction / 硬链接),再让压缩工具以暂存目录为工作目录打包。
|
||||
$stagingRoot = $null
|
||||
try {
|
||||
$stagingRoot = New-BaknretArchiveStaging -Items $liveItems
|
||||
$result = Invoke-BackupItem -SourceItems $liveItems -StagingRoot $stagingRoot `
|
||||
-FinalPath $finalPath -ExcludePatterns $effectiveExcludes -UseEncryption:$useEncryption `
|
||||
-ProtectPrevious:$protectPrevious -AcceptWarnings:$AcceptWarnings
|
||||
} catch {
|
||||
$result = [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = "准备归档内容失败:$_" }
|
||||
} finally {
|
||||
Remove-BaknretArchiveStaging -Root $stagingRoot
|
||||
}
|
||||
|
||||
$record.exitCode = $result.ExitCode
|
||||
$record.attemptWarnings = [bool]$result.Warnings
|
||||
@@ -748,6 +767,58 @@ foreach ($line in $lines) {
|
||||
Write-Log "备份成功: $baseName" -Level INFO
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 安全描述符(属主 / ACL)写进旁挂文件 <归档名>.acl.json
|
||||
# ------------------------------------------------------------------
|
||||
# 归档格式装不下它(7z 的 -sni 官方说明是"只能写进 WIM 归档"),所以放在归档旁边,
|
||||
# 和归档一样先写 .tmp 再原子替换。属主必须一起存:C:\ProgramData 的 ACL 里有
|
||||
# (A;OICIIO;GA;;;CO) CREATOR OWNER + inherit-only + GENERIC_ALL
|
||||
# 而 CREATOR OWNER 是访问检查时才替换的占位符 —— 替换成"被检查对象的属主"。
|
||||
# 只回放 ACE 文本、不恢复属主,等于把"谁创建的东西谁有全权"里的"谁"换成跑脚本的账户。
|
||||
$securityMode = [string]$script:Config.Security.Mode
|
||||
$securityFatal = $false
|
||||
if ($securityMode -and ($securityMode -ne 'Off')) {
|
||||
$sidecarName = "$baseName.acl.json"
|
||||
$sidecarPath = Join-Path $BackupDir $sidecarName
|
||||
try {
|
||||
$capture = Get-BaknretSecurityRecords -Items $liveItems -ScopeMap $scopeMap -Mode $securityMode `
|
||||
-IncludeSacl:([bool]$script:Config.Security.IncludeSacl)
|
||||
Save-BaknretSecuritySidecar -Path $sidecarPath -Records $capture.Records -Mode $securityMode `
|
||||
-IncludeSacl:([bool]$script:Config.Security.IncludeSacl) `
|
||||
-Errors $capture.Errors -Scanned $capture.Scanned | Out-Null
|
||||
|
||||
$record.security = [ordered]@{
|
||||
file = $sidecarName
|
||||
mode = $securityMode
|
||||
objects = $capture.Kept
|
||||
scanned = $capture.Scanned
|
||||
errors = $capture.Errors
|
||||
capturedAt = (Get-Date).ToString('o')
|
||||
}
|
||||
Write-Log ("安全描述符:{0} 个对象写进 {1}(扫描 {2} 个,读不到 {3} 个)" -f `
|
||||
$capture.Kept, $sidecarName, $capture.Scanned, $capture.Errors) -Level INFO
|
||||
|
||||
if ($capture.Errors -gt 0) {
|
||||
$securityErrorCount++
|
||||
$unreadable = @($capture.Records | Where-Object { $_.e } | Select-Object -First 3 -ExpandProperty p)
|
||||
Write-Log (" {0} 个对象的安全描述符读不到(恢复后它们的属主/ACL 会是新建对象的默认值),例如:{1}" -f `
|
||||
$capture.Errors, ($unreadable -join '、')) -Level WARN
|
||||
}
|
||||
} catch {
|
||||
$securityFailed++
|
||||
Write-Log "安全描述符采集/写盘失败:$displayPath —— $_" -Level WARN
|
||||
$record.security = [ordered]@{ file = $sidecarName; error = "$_" }
|
||||
if ([bool]$script:Config.Security.FailOnError) { $securityFatal = $true }
|
||||
}
|
||||
|
||||
if ($securityFatal) {
|
||||
Write-Log "失败: $displayPath,归档已替换,但安全描述符没能存下来(Security.FailOnError = \$true)" -Level ERROR
|
||||
Save-ItemRecord -Record $record -Action 'failed' -Reason '归档已替换,但安全描述符没能存下来' | Out-Null
|
||||
$failed++; $failures += $displayPath
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if ($Hash -or $script:Config.ComputeHash) {
|
||||
$record.sha256 = (Get-FileHash -LiteralPath $finalPath -Algorithm SHA256).Hash
|
||||
Write-Log "SHA256: $($record.sha256)" -Level DEBUG
|
||||
@@ -790,6 +861,7 @@ if ($DryRun) {
|
||||
# 判据只用清单,**不能用 manifest**:manifest 会一直留着历史条目,
|
||||
# 于是"从清单里删掉某个条目(或把它合并进另一个条目)"留下的归档会被历史记录遮住,
|
||||
# 审计就永远不会报——那正是最需要报出来的情况。
|
||||
# $seenBaseNames 在方向过滤之前就登记,所以"行首 + / -"的条目也算有主。
|
||||
# 只在整表运行时做:带 -Only/-Skip 时未选中的条目本来就不在 $seenBaseNames 里,
|
||||
# 那种情况下报出来的全是假孤儿。
|
||||
if (-not $DryRun -and $Only.Count -eq 0 -and $Skip.Count -eq 0) {
|
||||
@@ -815,6 +887,13 @@ if ($failures.Count -gt 0) {
|
||||
foreach ($failure in $failures) { Write-Log " - $failure" -Level ERROR }
|
||||
}
|
||||
|
||||
if ($securityFailed -gt 0) {
|
||||
Write-Log ("有 {0} 个条目的安全描述符完全没能存下来(manifest 的 security.error 里有原文)" -f $securityFailed) -Level WARN
|
||||
}
|
||||
if ($securityErrorCount -gt 0) {
|
||||
Write-Log ("有 {0} 个条目存在'读不到安全描述符'的对象;恢复后这些对象的属主/ACL 是新建对象的默认值,可查 manifest 的 security.errors" -f $securityErrorCount) -Level WARN
|
||||
}
|
||||
|
||||
$summaryText = "备份完成。成功: $processed, 跳过: $skipped, 失败: $failed"
|
||||
if ($DryRun) { $summaryText += ", 试运行计划: $planned" }
|
||||
Write-Log $summaryText -Level INFO
|
||||
|
||||
Reference in new issue
Block a user