引入软件名录:清单写软件名,归档名也用软件名

新功能
- 新增 SoftwareCatalog.psd1 —— "软件名 -> 目录"映射表,BackupList.txt 里
  直接写软件名即可,归档名也就是软件名(FooClolor.7z),
  不再是 FooClolor_from_C_+Programs.7z 这种由路径拼出来的名字。
- 三种写法可混用:软件名、字面路径(现有清单无需改写)、软件名 @pathname。
- 名录支持前缀补全(legendary -> legendary_2.0.4,只认 <名>_* / <名>-*)、
  Variants(同名目录在多处)、Includes(分文件维护)。
- tools/Rename-Archives.ps1:存量归档重命名,默认试运行,逐份大小校验并重建 manifest。
- 归档名重复直接报错,不再静默互相覆盖。

两套测试全绿:单元 42 项、端到端 23 项(新增名录命名/解析/迁移用例)。

过程中修掉的缺陷
- Resolve-BackupEntry 里 @pathname 与 Unresolved 分支顺序错误,
  @pathname 会被静默吃掉(改名后仍用软件名)。
- 源目录被删除时解析器丢掉 Sources,导致恢复端把软件名当路径、
  报 "Cannot bind argument to parameter 'Path' because it is an empty string"。
  恢复的语义恰恰是"源不存在就要还原回去",现在 Sources 照旧给出。
- 源存在性检查曾被漏掉,Get-Item 对不存在路径抛异常会中断整轮备份;
  且不能用 Join-Path 探测——目标盘符不存在时它会直接抛异常。
- 计划任务脚本外的 Caller 需要 -DryRun 才能验,已实跑确认。
This commit is contained in:
Shuery committed 2026-09-21 20:55:19 +08:00
1 parent dbc0c00554
commit 045d51ac9c
9 files changed
+1330 -143

No files matched your search

+85 -18
View File
@@ -82,11 +82,13 @@ function Resolve-ConfigPath {
if (-not $BackupDir) { $BackupDir = Resolve-ConfigPath -Path $null -Default $script:Config.BackupDir }
$logDir = Resolve-ConfigPath -Path $null -Default $script:Config.LogDir
$catalogPath = Resolve-CatalogPath -Configured $script:Config.SoftwareCatalog -Root $PSScriptRoot
$manifestPath = Join-Path $BackupDir 'manifest.json'
$logPath = Start-BaknretLog -Directory $logDir -Prefix 'restore'
Write-Log "日志文件:$logPath"
Write-Log "备份目录:$BackupDir"
Write-Log ("软件名录:{0}{1}" -f $catalogPath, $(if (Test-Path -LiteralPath $catalogPath) { '' } else { '(不存在,将只支持字面路径)' }))
if ($WhatIfPreference) { Write-Log '试运行模式(-WhatIf / -DryRun):不会写入任何文件' -Level WARN }
if (-not (Test-Administrator)) {
@@ -111,8 +113,7 @@ function Find-ArchiveByBaseName {
#>
param([string]$BaseName)
$candidate = Get-ChildItem -LiteralPath $BackupDir -File -Force -ErrorAction SilentlyContinue |
Where-Object { $_.BaseName -eq $BaseName -and $_.Extension.ToLower() -in $SupportedFormats } |
$candidate = Get-ChildItem -LiteralPath $BackupDir -File -Force -ErrorAction SilentlyContinue | Where-Object { $_.BaseName -eq $BaseName -and $_.Extension.ToLower() -in $SupportedFormats } |
Select-Object -First 1
return $candidate
}
@@ -143,6 +144,19 @@ function Get-ArchiveForEntry {
return $null
}
function Get-7zExecutable {
<# .SYNOPSIS 定位 7z.exe(PATH 优先,其次是常见安装位置)。 #>
$sevenZip = Get-Command 7z -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source
if (-not $sevenZip) {
$candidates = @(
(Join-Path $env:ProgramFiles '7-Zip\7z.exe'),
(Join-Path ${env:ProgramFiles(x86)} '7-Zip\7z.exe')
)
$sevenZip = $candidates | Where-Object { $_ -and (Test-Path -LiteralPath $_) } | Select-Object -First 1
}
return $sevenZip
}
function Invoke-Extraction {
param([object]$ArchiveFile, [string]$DestinationPath)
@@ -153,15 +167,9 @@ function Invoke-Extraction {
New-Item -ItemType Directory -Path $destParent -Force | Out-Null
}
$sevenZip = Get-Command 7z -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source
if (-not $sevenZip) {
$candidates = @(
(Join-Path $env:ProgramFiles '7-Zip\7z.exe'),
(Join-Path ${env:ProgramFiles(x86)} '7-Zip\7z.exe')
)
$sevenZip = $candidates | Where-Object { $_ -and (Test-Path -LiteralPath $_) } | Select-Object -First 1
}
# 归档布局与历史保持一致:根目录就是源目录名(软件名条目也一样,
# 软件名只用于归档文件名),因此直接整包解压到目标的父目录即可。
$sevenZip = Get-7zExecutable
if ($sevenZip) {
Write-Log '使用 7z 解压' -Level DEBUG
$argument = @('x', '-bsp2', '-y', "-o$destParent")
@@ -278,8 +286,8 @@ foreach ($line in $lines) {
if (-not $item) { continue }
$displayPath = $item.Path
$destPath = [Environment]::ExpandEnvironmentVariables($displayPath)
$baseName = Get-BackupBaseName -RawPath $displayPath
$resolved = Resolve-BackupEntry -Entry $item -CatalogPath $catalogPath -MaxDepth $script:Config.CatalogMaxDepth
$baseName = $resolved.BaseName
if (-not $baseName) { $stats.skipped++; continue }
if (-not (Test-EntrySelected -DisplayPath $displayPath -BaseName $baseName)) { continue }
@@ -291,6 +299,29 @@ foreach ($line in $lines) {
continue
}
# 恢复目的地。清单条目可能带多个源(同名目录分散在多处),每个源各恢复各的。
$targets = @()
if ($resolved.Sources.Count -gt 0) {
foreach ($source in $resolved.Sources) {
$targets += [pscustomobject]@{ DestPath = $source.SourcePath }
}
} else {
$targets += [pscustomobject]@{ DestPath = [Environment]::ExpandEnvironmentVariables($displayPath) }
}
# 防御:解析不出目的地时明确失败,别把空字符串喂给 Split-Path/Test-Path
# (那种报错是 "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' 解析不出任何源路径)"
Write-Log "失败: $displayPath,$reason" -Level ERROR
$stats.failed++
$failures += $displayPath
continue
}
$destPath = $targets[0].DestPath
$archiveFile = $found.File
$referencedArchives += $archiveFile.BaseName
@@ -312,8 +343,17 @@ foreach ($line in $lines) {
Write-Log "跳过校验(非 7z): $($archiveFile.Name)" -Level DEBUG
continue
}
$verifyCode = Invoke-ExternalCommand -FilePath (Get-Command 7z | Select-Object -First 1 -ExpandProperty Source) `
-ArgumentList @('t', '-bso0', '-bsp0', $archiveFile.FullName)
$verifyTool = Get-7zExecutable
if (-not $verifyTool) {
Write-Log '未找到 7z,无法校验' -Level ERROR
$stats.failed++
$failures += $displayPath
continue
}
$verifyArgument = @('t', '-bso0', '-bsp0')
if ($password) { $verifyArgument += "-p$password" }
$verifyArgument += $archiveFile.FullName
$verifyCode = Invoke-ExternalCommand -FilePath $verifyTool -ArgumentList $verifyArgument
if ($verifyCode -eq 0) {
Write-Log "校验通过: $($archiveFile.Name)" -Level INFO
$stats.verified++
@@ -341,14 +381,41 @@ foreach ($line in $lines) {
}
}
if (-not $PSCmdlet.ShouldProcess($destPath, "从 $($archiveFile.Name) 解压")) {
Write-Log "[试运行] 将解压 $($archiveFile.Name) -> $(Split-Path -Path $destPath -Parent)" -Level INFO
$plannedTargets = @($targets | Where-Object { $_.DestPath })
foreach ($target in $plannedTargets) {
if (Test-Path -LiteralPath $target.DestPath) { continue }
# Split-Path -Parent 对根路径(如 "E:\")返回空串,此时无父目录可建
$targetParent = Split-Path -Path $target.DestPath -Parent
if ($targetParent) {
Write-Log "提示: 目标不存在,将新建 $targetParent" -Level DEBUG
} else {
Write-Log "提示: 目标不存在,且没有可创建的父目录:$($target.DestPath)" -Level DEBUG
}
}
$shouldRun = $true
foreach ($target in $plannedTargets) {
if (-not $PSCmdlet.ShouldProcess($target.DestPath, "从 $($archiveFile.Name) 解压")) { $shouldRun = $false }
}
if (-not $shouldRun) {
foreach ($target in $plannedTargets) {
Write-Log "[试运行] 将解压 $($archiveFile.Name) -> $($target.DestPath)" -Level INFO
}
$stats.planned++
continue
}
$restoreFailed = $false
try {
if (Invoke-Extraction -ArchiveFile $archiveFile -DestinationPath $destPath) {
foreach ($target in $plannedTargets) {
if (-not (Invoke-Extraction -ArchiveFile $archiveFile -DestinationPath $target.DestPath)) {
$restoreFailed = $true
break
}
}
if (-not $restoreFailed) {
$stats.restored++
Write-Log "恢复成功: $baseName" -Level INFO