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
+361
-36
@@ -16,6 +16,10 @@
|
||||
真实目录的破坏性操作,必须能先看清单再决定。
|
||||
4. 支持 -Only / -Skip 只恢复指定条目,-VerifyOnly 只校验不写盘。
|
||||
5. 结尾按失败数 exit。
|
||||
6. 清单行首 `+`(仅备份)的条目会跳过;`-`(仅恢复)的条目照常恢复。
|
||||
7. 归档内的一层目录由 SoftwareCatalog 的 Slot 决定(`<Slot>\<内容>`),
|
||||
恢复时只解出该 Slot 那棵子树,并通过"目标父目录下的 junction"直接落地
|
||||
(零拷贝;建不出连接点时退回先解到临时目录再合并)。
|
||||
#>
|
||||
|
||||
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
|
||||
@@ -48,7 +52,11 @@ param(
|
||||
|
||||
# 只对归档做 7z t 校验,不解压
|
||||
[Parameter()]
|
||||
[switch]$VerifyOnly
|
||||
[switch]$VerifyOnly,
|
||||
|
||||
# 不恢复安全描述符(属主 / ACL):默认会按 <归档名>.acl.json 回放
|
||||
[Parameter()]
|
||||
[switch]$SkipSecurity
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
@@ -157,25 +165,32 @@ function Get-7zExecutable {
|
||||
return $sevenZip
|
||||
}
|
||||
|
||||
function Invoke-Extraction {
|
||||
param([object]$ArchiveFile, [string]$DestinationPath, [string]$RelativePath)
|
||||
function Invoke-ExtractionRaw {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
把归档里某个子树解到指定目录,不关心"落地"问题。
|
||||
|
||||
.DESCRIPTION
|
||||
归档布局:软件名条目是 `<Slot>\...`(Slot 就是归档内的一层目录),
|
||||
手写路径条目是 `<源目录名>\...`。这里只负责把指定的那条路径解出来。
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][object]$ArchiveFile,
|
||||
[Parameter(Mandatory = $true)][string]$Destination,
|
||||
[string]$RelativePath,
|
||||
[string]$Password
|
||||
)
|
||||
|
||||
$extension = $ArchiveFile.Extension.ToLower()
|
||||
$destParent = Split-Path -Path $DestinationPath -Parent
|
||||
|
||||
if (-not (Test-Path -LiteralPath $destParent)) {
|
||||
New-Item -ItemType Directory -Path $destParent -Force | Out-Null
|
||||
if (-not (Test-Path -LiteralPath $Destination)) {
|
||||
New-Item -ItemType Directory -Path $Destination -Force | Out-Null
|
||||
}
|
||||
|
||||
# 归档布局与历史保持一致:顶层就是**源目录名**(软件名只用于归档文件名)。
|
||||
# 一个条目可能打包了好几个目录(软件名录里的数组写法 / `:+` 追加),
|
||||
# 所以**不能整包往每个目标里倒** —— 那会把兄弟目录也复制到不相干的父目录下。
|
||||
# 这里只解出该目标自己那棵子树($RelativePath),其余不动。
|
||||
$sevenZip = Get-7zExecutable
|
||||
if ($sevenZip) {
|
||||
Write-Log '使用 7z 解压' -Level DEBUG
|
||||
$argument = @('x', '-bsp2', '-y', "-o$destParent")
|
||||
if ($password) { $argument += "-p$password" }
|
||||
$argument = @('x', '-bsp2', '-y', "-o$Destination")
|
||||
if ($Password) { $argument += "-p$Password" }
|
||||
$argument += $ArchiveFile.FullName
|
||||
if ($RelativePath) { $argument += $RelativePath }
|
||||
|
||||
@@ -189,7 +204,7 @@ function Invoke-Extraction {
|
||||
$rarExe = Get-Command rar, unrar -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source
|
||||
if (-not $rarExe) { throw '未找到 RAR 工具' }
|
||||
Write-Log '使用 RAR 解压' -Level DEBUG
|
||||
$argument = @('x', '-idp', '-idn', '-y', $ArchiveFile.FullName, "$destParent\")
|
||||
$argument = @('x', '-idp', '-idn', '-y', $ArchiveFile.FullName, "$Destination\")
|
||||
if ($RelativePath) { $argument += $RelativePath }
|
||||
$exitCode = Invoke-ExternalCommand -FilePath $rarExe -ArgumentList $argument
|
||||
if ($exitCode -ne 0) { throw "RAR 解压失败(退出码:$exitCode)" }
|
||||
@@ -199,13 +214,13 @@ function Invoke-Extraction {
|
||||
if ($RelativePath) {
|
||||
Write-Log "内置 ZIP 不支持只解子树,将整包解压($RelativePath)" -Level WARN
|
||||
}
|
||||
Expand-Archive -LiteralPath $ArchiveFile.FullName -DestinationPath $destParent -Force
|
||||
Expand-Archive -LiteralPath $ArchiveFile.FullName -DestinationPath $Destination -Force
|
||||
}
|
||||
'.tar' {
|
||||
$tarExe = Get-Command tar -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source
|
||||
if (-not $tarExe) { throw '未找到 TAR 工具' }
|
||||
Write-Log '使用 TAR 解压' -Level DEBUG
|
||||
$argument = @('-xf', $ArchiveFile.FullName, '-C', $destParent)
|
||||
$argument = @('-xf', $ArchiveFile.FullName, '-C', $Destination)
|
||||
if ($RelativePath) { $argument += $RelativePath }
|
||||
$exitCode = Invoke-ExternalCommand -FilePath $tarExe -ArgumentList $argument
|
||||
if ($exitCode -ne 0) { throw "TAR 解压失败(退出码:$exitCode)" }
|
||||
@@ -215,6 +230,210 @@ function Invoke-Extraction {
|
||||
return $true
|
||||
}
|
||||
|
||||
function Invoke-ExtractionByLayout {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
按**当前归档布局**(软件名条目 = `<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
|
||||
$stats = @{ restored = 0; skipped = 0; failed = 0; verified = 0; planned = 0 }
|
||||
$securityApplied = 0 # 本次回放成功的安全描述符对象数
|
||||
$failures = @()
|
||||
$referencedArchives = @()
|
||||
|
||||
@@ -307,6 +527,23 @@ foreach ($line in $lines) {
|
||||
if (-not $baseName) { $stats.skipped++; continue }
|
||||
if (-not (Test-EntrySelected -DisplayPath $displayPath -BaseName $baseName)) { continue }
|
||||
|
||||
if ($resolved.Direction -eq 'backup') {
|
||||
# 仅备份的条目照样要登记归档名:审计要能看出"这个归档是有主的",
|
||||
# 否则它会被误报成孤儿(只是它本来就恢复不到,因为行首写了 +)。
|
||||
$referencedArchives += $baseName
|
||||
Write-Log "跳过(行首 +,仅备份): $displayPath" -Level DEBUG
|
||||
continue
|
||||
}
|
||||
|
||||
# 解析阶段就定死的结构性错误(名录条目有问题、归档内路径冲突):
|
||||
# 恢复一半比明确失败更危险,所以整条失败。
|
||||
if ($resolved.Blocking) {
|
||||
Write-Log "失败: $displayPath,$($resolved.Blocking)" -Level ERROR
|
||||
$stats.failed++
|
||||
$failures += $displayPath
|
||||
continue
|
||||
}
|
||||
|
||||
$found = Get-ArchiveForEntry -Entry ([pscustomobject]@{ baseName = $baseName }) -Manifest $manifest
|
||||
if (-not $found) {
|
||||
Write-Log "跳过: $displayPath,未找到归档 $baseName" -Level WARN
|
||||
@@ -314,25 +551,59 @@ foreach ($line in $lines) {
|
||||
continue
|
||||
}
|
||||
|
||||
# 恢复目的地。一个条目可能带多个源(软件名录里的数组写法、`:+` 追加、
|
||||
# 或同名目录分散在多处),每个源只还原**它自己那棵子树**。
|
||||
$targets = @()
|
||||
if ($resolved.Sources.Count -gt 0) {
|
||||
foreach ($source in $resolved.Sources) {
|
||||
$targets += [pscustomobject]@{
|
||||
DestPath = $source.SourcePath
|
||||
RelativePath = @($source.RelativePaths)[0]
|
||||
Description = $source.Description
|
||||
Origin = $source.Origin
|
||||
}
|
||||
# "是目录还是文件"的判据,按可靠性排序:
|
||||
# 1. 目标在磁盘上真实存在 -> 直接看它;
|
||||
# 2. manifest 记的 layouts(备份时记录的归档内条目类型)-> 全新恢复时靠它;
|
||||
# 3. 名录解析出来的 IsFile(源当前存在时才有值);
|
||||
# 4. 都没有就按目录处理。
|
||||
$layouts = @{}
|
||||
if ($found.Record -and ($found.Record.PSObject.Properties.Name -contains 'layouts')) {
|
||||
foreach ($layout in @($found.Record.layouts)) {
|
||||
if (-not $layout) { continue }
|
||||
$layoutName = [string]$layout.name
|
||||
if ([string]::IsNullOrWhiteSpace($layoutName)) { continue }
|
||||
$layouts[$layoutName.ToLower()] = [string]$layout.kind
|
||||
}
|
||||
} else {
|
||||
$expanded = [Environment]::ExpandEnvironmentVariables($displayPath)
|
||||
}
|
||||
|
||||
# 恢复目的地:一个条目可以挂多个归档项(名录里多个 Slot、`:+` 追加),
|
||||
# 每一项只还原**它自己那棵子树**,不会把兄弟项也复制过去。
|
||||
$targets = @()
|
||||
foreach ($entryItem in @($resolved.Items)) {
|
||||
$dest = [string]$entryItem.RealPath
|
||||
if ([string]::IsNullOrWhiteSpace($dest)) { continue }
|
||||
|
||||
$isFile = [bool]$entryItem.IsFile
|
||||
if (Test-Path -LiteralPath $dest -PathType Leaf) {
|
||||
$isFile = $true
|
||||
} elseif (Test-Path -LiteralPath $dest -PathType Container) {
|
||||
$isFile = $false
|
||||
} elseif ($layouts.ContainsKey(([string]$entryItem.ArchivePath).ToLower())) {
|
||||
$isFile = ($layouts[([string]$entryItem.ArchivePath).ToLower()] -eq 'file')
|
||||
}
|
||||
|
||||
$targets += [pscustomobject]@{
|
||||
DestPath = $expanded
|
||||
RelativePath = (Split-Path -Path $expanded -Leaf)
|
||||
Description = $null
|
||||
Origin = 'path'
|
||||
ArchivePath = [string]$entryItem.ArchivePath
|
||||
RealPath = $dest
|
||||
DestPath = $dest
|
||||
IsFile = $isFile
|
||||
Description = $entryItem.Description
|
||||
Origin = $entryItem.Origin
|
||||
}
|
||||
}
|
||||
|
||||
# 兜底:解析不出归档项时按字面路径处理(历史清单里的裸路径)
|
||||
if ($targets.Count -eq 0 -and -not $resolved.IsName) {
|
||||
$expanded = [Environment]::ExpandEnvironmentVariables($displayPath)
|
||||
if (-not [string]::IsNullOrWhiteSpace($expanded)) {
|
||||
$targets += [pscustomobject]@{
|
||||
ArchivePath = (Split-Path -Path $expanded -Leaf)
|
||||
RealPath = $expanded
|
||||
DestPath = $expanded
|
||||
IsFile = (Test-Path -LiteralPath $expanded -PathType Leaf)
|
||||
Description = $null
|
||||
Origin = 'path'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,7 +611,7 @@ foreach ($line in $lines) {
|
||||
# (那种报错是 "Cannot bind argument to parameter 'Path' because it is an empty string")
|
||||
$targets = @($targets | Where-Object { $_.DestPath -and -not [string]::IsNullOrWhiteSpace($_.DestPath) })
|
||||
if ($targets.Count -eq 0) {
|
||||
$reason = "无法确定恢复目的地(清单条目 '$displayPath' 解析不出任何源路径)"
|
||||
$reason = "无法确定恢复目的地(清单条目 '$displayPath' 解析不出任何归档项)"
|
||||
Write-Log "失败: $displayPath,$reason" -Level ERROR
|
||||
$stats.failed++
|
||||
$failures += $displayPath
|
||||
@@ -415,7 +686,9 @@ foreach ($line in $lines) {
|
||||
foreach ($target in $plannedTargets) {
|
||||
$targetExists = Test-Path -LiteralPath $target.DestPath
|
||||
Write-Log (" 目标:{0}" -f $target.DestPath)
|
||||
Write-Log (" 归档内子树:{0};{1}" -f $target.RelativePath, $(if ($targetExists) { '已存在,将覆盖同名文件' } else { '不存在,将新建' }))
|
||||
Write-Log (" 归档内路径:{0}({1});{2}" -f $target.ArchivePath,
|
||||
$(if ($target.IsFile) { '文件' } else { '目录' }),
|
||||
$(if ($targetExists) { '已存在,将覆盖同名文件' } else { '不存在,将新建' }))
|
||||
if ($target.Description) { Write-Log (" 介绍:{0}" -f $target.Description) }
|
||||
}
|
||||
|
||||
@@ -446,13 +719,64 @@ foreach ($line in $lines) {
|
||||
$restoreFailed = $false
|
||||
try {
|
||||
foreach ($target in $plannedTargets) {
|
||||
if (-not (Invoke-Extraction -ArchiveFile $archiveFile -DestinationPath $target.DestPath -RelativePath $target.RelativePath)) {
|
||||
if (-not (Invoke-Extraction -ArchiveFile $archiveFile -Item $target -Password $password)) {
|
||||
$restoreFailed = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $restoreFailed) {
|
||||
# ------------------------------------------------------------------
|
||||
# 安全描述符(属主 / ACL)回放
|
||||
# ------------------------------------------------------------------
|
||||
# 解压出来的对象:属主是"跑恢复脚本的进程"、DACL 是"从目标父目录继承 + 进程默认"。
|
||||
# 对 C:\ProgramData 这类目录是致命的 —— 那里的 (A;OICIIO;GA;;;CO) 靠
|
||||
# CREATOR OWNER 把全权给"对象的属主",属主一变,原程序就没了权限。
|
||||
# 必须在解压**之后**、对真实目标路径做(连接点在 Invoke-Extraction 里已经拆掉了)。
|
||||
if ($SkipSecurity) {
|
||||
Write-Log '按 -SkipSecurity 跳过了安全描述符恢复' -Level DEBUG
|
||||
} elseif (([string]$script:Config.Security.Mode) -eq 'Off') {
|
||||
Write-Log '配置里 Security.Mode = Off,跳过安全描述符恢复' -Level DEBUG
|
||||
} else {
|
||||
$sidecarName = $null
|
||||
if ($found.Record -and ($found.Record.PSObject.Properties.Name -contains 'security') -and $found.Record.security) {
|
||||
$sidecarName = [string]$found.Record.security.file
|
||||
}
|
||||
if (-not $sidecarName) { $sidecarName = "$baseName.acl.json" }
|
||||
|
||||
$sidecar = Read-BaknretSecuritySidecar -Path (Join-Path $BackupDir $sidecarName)
|
||||
if (-not $sidecar) {
|
||||
Write-Log ("这个归档没有安全描述符旁挂文件({0}):恢复出来的属主/ACL 是新建对象的默认值 —— 原程序若依赖特殊权限(ProgramData 下的 CREATOR OWNER 最典型),会报无读写权限" -f $sidecarName) -Level WARN
|
||||
} else {
|
||||
$sidMap = @{}
|
||||
if ($script:Config.Security.SidMap) { $sidMap = $script:Config.Security.SidMap }
|
||||
|
||||
$secTotal = 0; $secApplied = 0; $secOwnerFailed = 0; $secSkipped = 0; $secFailed = 0
|
||||
$secMessages = @()
|
||||
foreach ($target in $plannedTargets) {
|
||||
$sec = Restore-BaknretSecurity -Sidecar $sidecar -ArchiveRoot $target.ArchivePath `
|
||||
-TargetPath $target.DestPath -SidMap $sidMap
|
||||
$secTotal += $sec.Total
|
||||
$secApplied += $sec.Applied
|
||||
$secOwnerFailed += $sec.OwnerFailed
|
||||
$secSkipped += $sec.Skipped
|
||||
$secFailed += $sec.Failed
|
||||
$secMessages += @($sec.Failures)
|
||||
}
|
||||
|
||||
$securityApplied += $secApplied
|
||||
Write-Log ("安全描述符:回放 {0}/{1} 个对象(属主/属组未恢复 {2},跳过 {3},失败 {4})" -f `
|
||||
$secApplied, $secTotal, $secOwnerFailed, $secSkipped, $secFailed) -Level INFO
|
||||
foreach ($message in @($secMessages | Select-Object -First 5)) {
|
||||
Write-Log (" ! {0}" -f $message) -Level WARN
|
||||
}
|
||||
if ($secFailed -gt 0) {
|
||||
Write-Log ("恢复成功但安全描述符有 {0} 个对象失败,已计入失败条目(退出码 1)" -f $secFailed) -Level ERROR
|
||||
$failures += $displayPath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$stats.restored++
|
||||
Write-Log "恢复成功: $baseName" -Level INFO
|
||||
|
||||
@@ -515,6 +839,7 @@ if ($failures.Count -gt 0) {
|
||||
}
|
||||
|
||||
$summaryText = "恢复完成 - 成功:$($stats.restored),跳过:$($stats.skipped),失败:$($stats.failed)"
|
||||
if ($securityApplied -gt 0) { $summaryText += ",安全描述符:$securityApplied 个对象" }
|
||||
if ($VerifyOnly) { $summaryText = "校验完成 - 通过:$($stats.verified),失败:$($stats.failed)" }
|
||||
if ($stats.planned -gt 0) { $summaryText += ",试运行计划:$($stats.planned)" }
|
||||
Write-Log $summaryText -Level INFO
|
||||
|
||||
Reference in new issue
Block a user