Files
BakNRet/BakNRet/Public/Resolve-BackupEntry.ps1
T
Shuery 187d2759fd style: 按微软规范落地静态分析,并全仓机械重排
三件事:

1) tools\Install-TestDependencies.ps1 现在也把 PSScriptAnalyzer 装进仓库内的 .tools\modules
(不动机器上的全局模块,与 Pester 同一策略)。

2) PSScriptAnalyzerSettings.psd1:这是必要的,不是装饰 —— 那 6 条格式规则
(括号、缩进、空格、对齐、大小写)默认全是 Disabled,所以不带 -Settings 的
`Invoke-ScriptAnalyzer -Severity Warning,Error` 会**静默漏掉全部排版问题**。本文件用 Rules
把它们打开(而不是用 IncludeRules 换一套),于是默认规则与格式规则同时生效。
三条有意的排除都写明了理由:PSAvoidUsingWriteHost(彩色控制台输出是这份工具的刻意设计)、
PSUseShouldProcessForStateChangingFunctions(WhatIf 的边界在入口脚本,给库里 27 个改状态的
函数都加上反而会"静默跳过",备份看着成功却什么都没做)、PSAvoidUsingPlainTextForPassword
(7z 只接受命令行口令,这是 7z 的限制,README 里写明了取舍)。

3) tools\Invoke-Analyzer.ps1:独立门禁(不塞进 Pester 用例 —— 套件跑一次二十多秒,
混进去会让"测试红了"这句话失去分辨力),路径过滤与验收门槛的 Encode/Parse 两层一致。

全仓重排结果:706 条告警 -> 67 条。修掉的 639 条全部是格式(闭括号 168、空格 80、
对齐 68、缩进 60、行长 229)。重排后 9/9 验收全绿、100 个文件两版解析零错、
276 个断言原样通过 —— 机械重排没有改变任何可观察行为。

如实说明两件事:

  * 行长上限设成 160,**不是**官方默认的 120。120 在本仓库意味着 270 处改动(主要是
    中文注释与测试夹具里的一行式目录),160 意味着 41 处。160 仍是"宽但可读",而理由是写在
    配置文件里的:这不是悄悄放宽,想收紧到 120 时那份清单就在分析器输出里。

  * 剩余 67 条里,41 条是上面那批行长,其余 26 条是分析器找出的真问题(未使用参数 6、
    空 catch 6、MD5 指纹 1、覆盖内置命令 1、switch 默认值 1 等)。其中
    Find-ChildDirectoryByName 的 MaxDepth 参数从未被使用 —— 也就是配置里的
    CatalogMaxDepth = 5 是假的,前缀补全实际只查 1 层。这条要改行为、且影响真实名录的解析
    结果,留给你拍板,不在本提交里动手。
2026-09-27 09:46:08 +08:00

244 lines
11 KiB
PowerShell
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
function Resolve-BackupEntry {
<#
.SYNOPSIS
把清单条目解析成"实际要打包什么、归档里长什么样"。
.DESCRIPTION
返回:
IsName / BaseName / ArchiveFlavor / Direction
CatalogEntry —— 名录条目(软件名写法才有)
Items —— 归档项数组(见 New-BaknretArchiveItem)
Encrypt —— 该归档是否加密
ExcludePatterns / HasExcludeOverride —— 条目级 `:-` / `@ Exclude` 覆盖
Includes / HasIncludeOverride —— 条目级 `:+` / `@ Include` 覆盖
Error —— 可恢复的问题(例如名录里路径不存在)
Blocking —— 必须整条失败的问题(归档内路径冲突等)
归档内部布局:
* 软件名条目 -> `<Slot>\<Path 内容>`(文件 Slot 就是名为 `<Slot>` 的文件);
* 手写路径 -> `<末级名>\...`(与历史归档一致,不变)。
名录里的 Slot 存在但路径当前不存在时**照样产出归档项**:源被删掉正是要恢复的场景,
备份端按存在性跳过,恢复端靠它把内容还原回原位。
#>
param(
$Entry,
[string]$CatalogPath,
[int]$MaxDepth = 5
)
$isName = -not (Test-LiteralPath -Path $Entry.Path)
$forcePathFlavor = ($Entry.Flags -contains 'pathname')
$baseName = Get-ItemArchiveName -Entry $Entry -CatalogPath $CatalogPath -MaxDepth $MaxDepth
$overrides = $Entry.Overrides
if (-not $overrides) { $overrides = @{} }
$overridePath = if ($overrides.ContainsKey('Path')) { [string]$overrides['Path'] } else { $null }
$hasExcludeOverride = $overrides.ContainsKey('Exclude')
$entryExclude = if ($hasExcludeOverride) { @($overrides['Exclude']) } else { @() }
$hasIncludeOverride = $overrides.ContainsKey('Include')
$entryInclude = if ($hasIncludeOverride) { @($overrides['Include']) } else { @() }
$hasEncryptOverride = $overrides.ContainsKey('Encrypt')
$items = @()
$catalogEntry = $null
$errorText = $null
$blocking = $null
$archiveFlavor = if ($isName) { 'name' } else { 'path' }
if (-not $isName) {
# ---- 写法二:用户手写的目录 / 文件 ----
$real = [string]$Entry.Path
if ($overridePath) { $real = $overridePath }
$real = [Environment]::ExpandEnvironmentVariables($real).Trim()
$leaf = Split-Path -Path $real -Leaf
if ($forcePathFlavor) { $archiveFlavor = 'path' }
$exists = $false
$isFile = $false
if ($real) {
$exists = Test-Path -LiteralPath $real
if ($exists) {
$item = Get-Item -LiteralPath $real -Force -ErrorAction SilentlyContinue
if ($item) { $isFile = -not $item.PSIsContainer }
}
}
if ([string]::IsNullOrWhiteSpace($leaf) -or [string]::IsNullOrWhiteSpace($real)) {
$errorText = "无法从路径里拆出末级名:$real"
}
else {
$items += New-BaknretArchiveItem -ArchivePath $leaf -RealPath $real -Kind 'path' `
-Origin 'path' -Exists $exists -IsFile $isFile
}
}
else {
# ---- 写法一:软件名录里的软件名 ----
$catalog = Get-SoftwareCatalog -Path $CatalogPath -MaxDepth $MaxDepth
if (-not $catalog.ContainsKey($Entry.Path)) {
$errorText = "软件名录里没有 '$($Entry.Path)'"
}
else {
$catalogEntry = $catalog[$Entry.Path]
# 名录条目自身有问题(Slot 缺 Path、前缀补全命中多个目录……)时整条失败:
# 继续跑只会"少打包一块"或"任选一个目录",那正是最该避免的静默错误。
if ($catalogEntry.Error) {
$errorText = $catalogEntry.Error
if (-not $blocking) { $blocking = "软件名录里的 '$($Entry.Path)' 有问题:$($catalogEntry.Error)" }
}
$slots = @($catalogEntry.Slots)
if ($overridePath -and $slots.Count -ne 1) {
$blocking = ("'{0}' 有 {1} 个 Slot,不能用一个 `::` / `@ Path=` 覆盖路径;请写清楚是哪个 Slot" -f `
$Entry.Path, $slots.Count)
}
else {
foreach ($slot in $slots) {
$resolvedPath = $slot.Resolved
$exists = $slot.Exists
$isFile = $slot.IsFile
if ($overridePath) {
$resolvedPath = [Environment]::ExpandEnvironmentVariables($overridePath).Trim()
$exists = Test-Path -LiteralPath $resolvedPath
$isFile = $false
if ($exists) {
$item = Get-Item -LiteralPath $resolvedPath -Force -ErrorAction SilentlyContinue
if ($item) { $isFile = -not $item.PSIsContainer }
}
}
$items += New-BaknretArchiveItem -ArchivePath $slot.Name -RealPath $resolvedPath -Kind 'slot' `
-Slot $slot.Name -Description $slot.Description -Origin 'catalog' `
-Exists $exists -IsFile $isFile -Exclude $slot.Exclude
}
}
}
}
# ------------------------------------------------------------------
# 包含项:`<归档内相对路径>:<宿主机绝对路径>`,把宿主机上的目录 / 文件放到包内指定位置。
# 条目级写 `:+` / `@ Include=` 就覆盖名录里的 Include;没写就用名录里各 Slot 的。
# ------------------------------------------------------------------
$includeTexts = @()
if ($hasIncludeOverride) {
$includeTexts = @($entryInclude)
}
elseif ($catalogEntry) {
foreach ($slot in @($catalogEntry.Slots)) { $includeTexts += @($slot.Include) }
}
foreach ($includeText in $includeTexts) {
if ([string]::IsNullOrWhiteSpace($includeText)) { continue }
$text = ([string]$includeText).Trim()
$archivePart = ''
$hostPart = $text
$separator = $text.IndexOf(':')
if ($separator -ge 0) {
$archivePart = $text.Substring(0, $separator).Trim()
$hostPart = $text.Substring($separator + 1).Trim()
# 写成 `D:\extra\ps-modules:Modules`(宿主机在前)时纠正并告警。
# 判据:第一个冒号前只有盘符那一个字母,而且紧跟着 `\` 或 `/`。
# 这时按**最后一个**冒号切,才能把宿主机路径完整地拿回来。
if ($archivePart -match '^[A-Za-z]$' -and ($hostPart.StartsWith('\') -or $hostPart.StartsWith('/'))) {
$lastSeparator = $text.LastIndexOf(':')
if ($lastSeparator -gt $separator) {
Write-Log ("包含项写得像'宿主机:归档内':{0} —— 语法应为 <归档内相对路径>:<宿主机绝对路径>,已按后者解释" -f $text) -Level WARN
$hostPart = $text.Substring(0, $lastSeparator).Trim()
$archivePart = $text.Substring($lastSeparator + 1).Trim()
}
}
}
$hostPath = [Environment]::ExpandEnvironmentVariables($hostPart).Trim()
if ([string]::IsNullOrWhiteSpace($hostPath)) {
Write-Log "包含项 '$text' 里没有宿主机路径,已忽略" -Level WARN
continue
}
$exists = Test-Path -LiteralPath $hostPath
$isFile = $false
if ($exists) {
$item = Get-Item -LiteralPath $hostPath -Force -ErrorAction SilentlyContinue
if ($item) { $isFile = -not $item.PSIsContainer }
}
$archivePath = $archivePart
if ([string]::IsNullOrWhiteSpace($archivePath)) { $archivePath = Split-Path -Path $hostPath -Leaf }
$items += New-BaknretArchiveItem -ArchivePath $archivePath -RealPath $hostPath -Kind 'include' `
-Origin 'include' -Exists $exists -IsFile $isFile
}
# ------------------------------------------------------------------
# 归档内路径冲突拦截
# 一个目录 / 文件在包内只能有一个位置:重名会互相覆盖,祖宗关系会混成一棵树。
# 宁可明确报错,也不要静默搅在一起。
# ------------------------------------------------------------------
$seen = @{}
$collisions = @()
foreach ($item in $items) {
$key = ([string]$item.ArchivePath).ToLower()
if (-not $key) { continue }
if ($seen.ContainsKey($key)) {
$collisions += ("'{0}'({1} 与 {2})" -f $item.ArchivePath, $seen[$key], $item.RealPath)
}
else {
$seen[$key] = $item.RealPath
}
}
foreach ($item in $items) {
$key = ([string]$item.ArchivePath).ToLower()
foreach ($other in $seen.Keys) {
if ($other -eq $key) { continue }
if ($other.StartsWith("$key\") -or $key.StartsWith("$other\")) {
$collisions += ("'{0}' 与 '{1}' 是父子关系,包内会互相覆盖" -f $item.ArchivePath, $other)
}
}
}
$collisions = @($collisions | Select-Object -Unique)
if ($collisions.Count -gt 0) {
$blocking = ("归档内路径冲突:{0}。每个 Slot / 追加项在包内必须有唯一位置," +
"请改 Slot 名或归档内相对路径。") -f ($collisions -join ';')
}
# ------------------------------------------------------------------
# 加密:清单覆盖优先,其次是名录里各 Slot 的 Encrypt 取或。
# 一个软件一个归档,所以 Slot 之间不一致时按"加密"处理(宁可多加密,不可漏加密)。
# ------------------------------------------------------------------
$encrypt = $false
if ($hasEncryptOverride) {
$encrypt = [bool]$overrides['Encrypt']
}
elseif ($catalogEntry) {
$slots = @($catalogEntry.Slots)
$encryptedSlots = @($slots | Where-Object { $_.Encrypt })
$encrypt = $encryptedSlots.Count -gt 0
if ($encryptedSlots.Count -gt 0 -and $encryptedSlots.Count -lt $slots.Count) {
Write-Log ("{0}:名录里各 Slot 的 Encrypt 不一致,整个归档按加密处理" -f $Entry.Path) -Level WARN
}
}
return [pscustomobject]@{
IsName = [bool]$isName
CatalogEntry = $catalogEntry
BaseName = $baseName
ArchiveFlavor = $archiveFlavor
Direction = $Entry.Direction
Items = @($items)
Encrypt = [bool]$encrypt
ExcludePatterns = @($entryExclude)
HasExcludeOverride = [bool]$hasExcludeOverride
Includes = @($entryInclude)
HasIncludeOverride = [bool]$hasIncludeOverride
Source = $Entry.Path
Error = $errorText
Blocking = $blocking
}
}