三件事:
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 层。这条要改行为、且影响真实名录的解析
结果,留给你拍板,不在本提交里动手。
244 lines
11 KiB
PowerShell
244 lines
11 KiB
PowerShell
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
|
||
}
|
||
}
|