<# .SYNOPSIS BakNRet —— 备份 / 恢复脚本的公共功能模块。 .DESCRIPTION 提供日志(控制台 + 落盘)、外部命令调用(可取得真实退出码)、 BackupList.txt 语法解析、归档命名与逆向解析、目录摘要、manifest 读写、 磁盘剩余空间查询等公共能力。 兼容 Windows PowerShell 5.1 与 PowerShell 7.x: * 不使用 ?? / 三元运算符 / Join-String / -AsHashtable 等 6.0+ 语法; * 不使用 ProcessStartInfo.ArgumentList(5.1 上不存在),改为自行构造命令行。 模块内出现的备份清单语法(BackupList.txt 每一行): [+|-] <软件名 或 绝对路径> [修饰符...] [# 说明] [:: ] [:- <模式>[,...]] [:+ <包含项>[,...]] [:encrypt | :!encrypt] [@ =''] 标记(必须是独立的空白分隔记号,前后都要有空格): + 仅备份,不恢复(Restore.ps1 跳过) - 仅恢复,不备份(Backup.ps1 跳过) :: 覆盖 Path,等价于 `@ Path='...'` :- 排除模式,等价于 `@ Exclude='...'` :+ 追加包含项(<归档内相对路径>:<宿主机绝对路径>),等价于 `@ Include='...'` :encrypt 该条目加密(`@ Encrypt='$true'`) :!encrypt 该条目不加密(`@ Encrypt='$false'`) @ Key='值' 覆盖 SoftwareCatalog.psd1 里的同名默认字段 兼容的历史写法:`@encrypt` / `@!encrypt` / `@pathname` / `@root=<名>`, 以及用双引号包住路径或模式值。 归档内布局(SoftwareCatalog.psd1 的 Slot 是包内的一层目录): 软件名条目 -> \<该 Path 的内容>(Path 是文件时就是名为 的文件) 手写路径 -> <路径末级名>\...(历史布局,不变) #> $script:LogConfig = @{ TimeFormat = 'yyyy-MM-dd HH:mm:ss' EnableDebug = $false FilePath = $null } $script:LogEncoding = [System.Text.UTF8Encoding]::new($false) # 名录读取缓存:一次运行里同一个文件只 Import 一次,`$( ... )` 也只求值一次。 # 键是文件路径,值里带内容指纹,文件被改过就自然失效。 $script:CatalogCache = @{} $script:CatalogExpressionCache = @{} # ============================================================================ # 日志 # ============================================================================ function Set-BaknretDebug { <# .SYNOPSIS 打开 DEBUG 级别日志。 #> param([switch]$Enabled = $true) $script:LogConfig.EnableDebug = [bool]$Enabled } function Start-BaknretLog { <# .SYNOPSIS 把后续日志同时写入 /-<时间戳>.log,返回日志文件路径。 #> param( [Parameter(Mandatory = $true)][string]$Directory, [string]$Prefix = 'run' ) if (-not (Test-Path -LiteralPath $Directory)) { New-Item -ItemType Directory -Path $Directory -Force | Out-Null } $name = '{0}-{1}.log' -f $Prefix, (Get-Date -Format 'yyyyMMdd-HHmmss') $path = Join-Path $Directory $name $script:LogConfig.FilePath = $path [System.IO.File]::WriteAllText($path, '', $script:LogEncoding) return $path } function Stop-BaknretLog { <# .SYNOPSIS 停止写入日志文件。 #> $script:LogConfig.FilePath = $null } function Get-BaknretLogPath { <# .SYNOPSIS 返回当前日志文件路径(未启用时返回 $null)。 #> return $script:LogConfig.FilePath } function Write-Log { <# .SYNOPSIS 写一条日志到控制台,并在启用日志文件时落盘。 .DESCRIPTION 落盘失败不会影响主流程(吞掉异常),因为备份本身比日志更重要。 #> param( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [ValidateNotNullOrEmpty()] [string]$Message, [Parameter()] [ValidateSet('INFO', 'WARN', 'ERROR', 'DEBUG')] [string]$Level = 'INFO' ) process { if ($Level -eq 'DEBUG' -and -not $script:LogConfig.EnableDebug) { return } $timestamp = Get-Date -Format $script:LogConfig.TimeFormat $line = "[$timestamp] [$Level] $Message" $colorMap = @{ 'INFO' = 'Green' 'WARN' = 'Yellow' 'ERROR' = 'Red' 'DEBUG' = 'Gray' } Write-Host $line -ForegroundColor $colorMap[$Level] if ($script:LogConfig.FilePath) { try { [System.IO.File]::AppendAllText( $script:LogConfig.FilePath, $line + [Environment]::NewLine, $script:LogEncoding) } catch { # 日志落盘失败时保持沉默:不能因为写日志失败而让备份失败。 } } } } # ============================================================================ # 环境 # ============================================================================ function Test-Administrator { <# .SYNOPSIS 当前进程是否以管理员身份运行。 #> $principal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } function Get-BaknretFreeSpaceGB { <# .SYNOPSIS 返回 $Path 所在卷的剩余空间(GB);无法确定时返回 -1。 .DESCRIPTION 只用 cmdlet(Split-Path -Qualifier + Get-PSDrive), 不做 .NET 静态调用以外的假设,便于在受限环境下运行。 #> param([Parameter(Mandatory = $true)][string]$Path) try { $resolved = $Path if (Test-Path -LiteralPath $Path) { $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop if ($item.PSProvider.Name -eq 'FileSystem') { $resolved = $item.FullName } } $qualifier = Split-Path -Qualifier $resolved -ErrorAction Stop if (-not $qualifier) { return -1 } $drive = Get-PSDrive -Name $qualifier.TrimEnd(':') -ErrorAction Stop if ($null -eq $drive.Free) { return -1 } return [math]::Round($drive.Free / 1GB, 2) } catch { return -1 } } # ============================================================================ # 外部命令 # ============================================================================ function ConvertTo-NativeArgumentString { <# .SYNOPSIS 按 Windows 的命令行引用规则,把参数数组拼成单个命令行字符串。 .DESCRIPTION ProcessStartInfo.Arguments 只接受字符串,而 PowerShell 5.1 没有 ArgumentList。手工拼参数会让含空格 / 引号 / 结尾反斜杠的路径出问题 (旧实现就是手工在参数里塞引号,反而让 7z 的排除模式全部失效)。 这里用标准算法:反斜杠只在引号前翻倍,内部引号前加反斜杠。 #> param([string[]]$ArgumentList = @()) $parts = New-Object System.Collections.Generic.List[string] foreach ($argument in $ArgumentList) { if ($null -eq $argument) { continue } $value = [string]$argument if ($value.Length -gt 0 -and $value -notmatch '[\s"]') { $parts.Add($value) continue } $builder = New-Object System.Text.StringBuilder [void]$builder.Append('"') $backslashes = 0 foreach ($ch in $value.ToCharArray()) { if ($ch -eq '\') { $backslashes++; continue } if ($ch -eq '"') { [void]$builder.Append('\' * (2 * $backslashes + 1)) [void]$builder.Append('"') $backslashes = 0 continue } if ($backslashes -gt 0) { [void]$builder.Append('\' * $backslashes) $backslashes = 0 } [void]$builder.Append($ch) } if ($backslashes -gt 0) { [void]$builder.Append('\' * (2 * $backslashes)) } [void]$builder.Append('"') $parts.Add($builder.ToString()) } return ($parts -join ' ') } function Invoke-ExternalCommand { <# .SYNOPSIS 运行外部程序并返回其真实退出码。 .DESCRIPTION 不要用 Start-Process -PassThru 取退出码:在 PowerShell 7.7.0-preview.4 上它稳定返回 $null,会把成功的压缩判成失败(旧版 Backup.ps1 的致命问题)。 这里用 .NET Process 直接启动并继承控制台:子进程输出实时可见, ExitCode 可靠,且不经过 PowerShell 的管道捕获。 注意:不要给子进程做 stdout/stderr 重定向——某些受限环境会拒绝创建管道。 工具自己的输出直接进控制台,结构化记录由日志与 manifest 承担。 #> param( [Parameter(Mandatory = $true)][string]$FilePath, [string[]]$ArgumentList = @(), [string]$WorkingDirectory ) $startInfo = New-Object System.Diagnostics.ProcessStartInfo $startInfo.FileName = $FilePath $startInfo.Arguments = ConvertTo-NativeArgumentString -ArgumentList $ArgumentList $startInfo.UseShellExecute = $false $startInfo.CreateNoWindow = $false if ($WorkingDirectory) { $startInfo.WorkingDirectory = $WorkingDirectory } Write-Log ('执行: {0} {1}' -f $FilePath, $startInfo.Arguments) -Level DEBUG $process = [System.Diagnostics.Process]::Start($startInfo) try { $process.WaitForExit() return $process.ExitCode } finally { $process.Dispose() } } function Resolve-CompressionTool { <# .SYNOPSIS 探测可用的压缩工具,优先 7z,其次 RAR,最后内置 ZIP。 .DESCRIPTION 只返回工具身份,不再返回没人用的 FullArgs / FallbackArgs (旧实现里 7z 的那两份参数是死代码,真正的参数由 Get-Optimized7zArgument 生成)。 #> $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 } if ($sevenZip) { Write-Log '检测到 7z 压缩工具' -Level DEBUG return [pscustomobject]@{ Name = '7z'; Command = $sevenZip; Extension = '.7z' } } $rar = Get-Command rar, winrar -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source if ($rar) { Write-Log '检测到 RAR 压缩工具' -Level DEBUG return [pscustomobject]@{ Name = 'RAR'; Command = $rar; Extension = '.rar' } } Write-Log '使用内置 ZIP 工具' -Level DEBUG return [pscustomobject]@{ Name = 'ZIP'; Command = 'Compress-Archive'; Extension = '.zip' } } function Get-Optimized7zArgument { <# .SYNOPSIS 根据源目录规模生成 7z 压缩参数(字典大小、线程数、快速字节数)。 .DESCRIPTION SourcePath 可以是多个(一个条目可能有多个 Slot / 追加项),字典大小按合计规模算。 #> param( [Parameter(Mandatory = $true)][string[]]$SourcePath, [int]$Level = 9 ) $totalSize = 0 $fileCount = 0 foreach ($path in $SourcePath) { if ([string]::IsNullOrWhiteSpace($path)) { continue } $item = Get-Item -LiteralPath $path -ErrorAction Stop if ($item.PSIsContainer) { $files = @(Get-ChildItem -LiteralPath $path -File -Recurse -ErrorAction SilentlyContinue) $fileCount += $files.Count $totalSize += [int64](@($files | Measure-Object -Property Length -Sum).Sum) } else { $fileCount++ $totalSize += [int64]$item.Length } Write-Log ("分析路径 '{0}':已累计 {1} 个文件,{2} MB" -f $path, $fileCount, [math]::Round($totalSize / 1MB, 2)) -Level DEBUG } if ($null -eq $totalSize) { $totalSize = 0 } $totalSizeMB = [math]::Round($totalSize / 1MB, 2) Write-Log ("合计分析:{0} 个文件,总大小 {1} MB" -f $fileCount, $totalSizeMB) -Level DEBUG if ($totalSizeMB -gt 1024) { $dictSize = '1024m' } elseif ($totalSizeMB -gt 100) { $dictSize = '256m' } elseif ($totalSizeMB -gt 10) { $dictSize = '32m' } else { $dictSize = '16m' } try { $cpuCores = (Get-CimInstance Win32_ComputerSystem -ErrorAction Stop).NumberOfLogicalProcessors $threads = [math]::Max(1, $cpuCores - 1) } catch { $threads = 2 } Write-Log ("参数优化:字典=$dictSize, 线程=$threads, 级别=$Level") -Level DEBUG return [pscustomobject]@{ # 只放压缩相关开关。输出开关(-bso0/-bsp0 或默认进度)必须由调用方 # 单独加一次:7z 对同一个开关出现两次会直接报 # "Multiple instances for switch" 并以退出码 7 失败。 Argument = @('a', '-t7z', "-mx=$Level", "-md=$dictSize", '-ms=on', "-mmt=$threads") FileCount = $fileCount TotalSize = $totalSize TotalSizeMB = $totalSizeMB } } # ============================================================================ # BackupList.txt 解析 # ============================================================================ function Split-BaknretToken { <# .SYNOPSIS 把清单的一行切成空白分隔的记号;引号内的空白不切分,引号本身留在记号里。 .DESCRIPTION 保留引号是为了让调用方分得清 `:- 'a,b'`(一个带逗号的值)与 `:- a,b`(两个值)。 引号不配对时按"引号一直延伸到行尾"处理,不抛异常——清单是手写的, 解析器要能给出可读的结果,而不是崩在半个引号上。 #> param([AllowEmptyString()][string]$Text) $tokens = New-Object System.Collections.Generic.List[string] $builder = New-Object System.Text.StringBuilder $quote = [char]0 foreach ($ch in ([string]$Text).ToCharArray()) { if ($quote -ne [char]0) { [void]$builder.Append($ch) if ($ch -eq $quote) { $quote = [char]0 } continue } if ($ch -eq "'" -or $ch -eq '"') { $quote = $ch [void]$builder.Append($ch) continue } if ([char]::IsWhiteSpace($ch)) { if ($builder.Length -gt 0) { $tokens.Add($builder.ToString()) [void]$builder.Clear() } continue } [void]$builder.Append($ch) } if ($builder.Length -gt 0) { $tokens.Add($builder.ToString()) } # 刻意不用 `,$array` 包一层:调用方都用 @(...) 收结果,包了反而会变成"数组套数组"。 return $tokens.ToArray() } function Remove-BaknretQuote { <# .SYNOPSIS 去掉值两端成对的引号(单双都认);不成对时原样返回。 #> param([AllowEmptyString()][string]$Text) $value = ([string]$Text).Trim() if ($value.Length -ge 2) { $first = $value[0] $last = $value[$value.Length - 1] if (($first -eq $last) -and ($first -eq "'" -or $first -eq '"')) { return $value.Substring(1, $value.Length - 2) } } return $value } function Test-BaknretMarker { <# .SYNOPSIS 判断一个记号是不是清单修饰符,返回它的种类;不是则返回 $null。 .DESCRIPTION 修饰符必须是**独立记号**(前后都有空白),所以这里做的是全等比较, 不是前缀匹配:`C:\a:-b` 仍然是一个路径,不会被看成 `:-`。 #> param([AllowEmptyString()][string]$Token) $text = ([string]$Token).Trim() if (-not $text) { return $null } switch -CaseSensitive ($text) { '::' { return 'path' } ':-' { return 'exclude' } ':+' { return 'include' } ':encrypt' { return 'encrypt' } ':!encrypt' { return 'noencrypt' } } if ($text.StartsWith('@')) { return 'at' } return $null } function ConvertFrom-BaknretPatternList { <# .SYNOPSIS 把修饰符的值列表拼成字符串并按 `,` / `;` 拆成多个模式。 #> param([string[]]$Values = @()) $parts = @() foreach ($value in @($Values)) { $text = Remove-BaknretQuote -Text ([string]$value) if ([string]::IsNullOrWhiteSpace($text)) { continue } $parts += @($text -split '[,;]' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) } return @($parts) } function ConvertFrom-BackupListLine { <# .SYNOPSIS 解析 BackupList.txt 的一行。 .DESCRIPTION 返回 $null 表示注释 / 空行。正常返回包含: Direction —— 'both' | 'backup'(行首 +,仅备份)| 'restore'(行首 -,仅恢复) Path —— 目标原文(软件名或字面路径),**归档命名以它为准** IsName —— 是否按软件名去名录里查 Overrides —— 显式给出的覆盖字段(hashtable,用 ContainsKey 判断有没有写) Path / Exclude / Include / Encrypt ExcludePatterns / Includes —— Overrides 的便捷视图(没写时是空数组) Flags —— 兼容的历史标记(pathname / root=<名>) Comment —— 行尾 `# 说明` Raw —— 原始行 与旧实现的区别: * `::` 现在表示"覆盖 Path"(旧版是 `:-` 的历史别名),排除一律写 `:-`; * 新增行首 `+` / `-` 方向、`:encrypt` / `:!encrypt`、`@ Key='Value'` 覆盖; * 修饰符必须是独立记号(前后加空格),所以 `C:\a:-b` 仍然是路径; * 行首方向标记是唯一例外:`+` / `-` 贴在目标上(`+Edge`)或独立成记号 (`+ Edge`)都认。详见下面判定处的注释。 #> param([Parameter(ValueFromPipeline = $true)][AllowEmptyString()][string]$Line) process { $content = ([string]$Line).Trim() if ([string]::IsNullOrEmpty($content) -or $content.StartsWith('#')) { return $null } # 行内注释:`#` 前面有空白时,它后面整段是"这条为什么这么配"的说明。 # 解析时摘出来单独放在 Comment 里,运行时打印,让人一眼看懂排除/追加的理由。 # (路径里的 `#` 必须紧贴前一个字符,所以 `C:\a#b` 不会受影响。) $comment = $null $commentIndex = $content.IndexOf(' #') if ($commentIndex -ge 0) { $comment = $content.Substring($commentIndex + 1).Trim().TrimStart('#').Trim() $content = $content.Substring(0, $commentIndex).Trim() if ([string]::IsNullOrEmpty($content)) { return $null } } $tokens = @(Split-BaknretToken -Text $content) if ($tokens.Count -eq 0) { return $null } # 整行被一对引号包住是**历史写法**(`"C:\a b\CodeSpace :: X\"`)。 # 现在修饰符必须是独立记号,所以引号里的 `::` / `:-` 不再是修饰符。 # 这里刻意**不**替用户重新切分:老写法里的 `::` 当年是"排除",现在 `::` 是 # "覆盖 Path"——猜着切会把排除表当成新的源路径,比报错更糟。只告警。 if ($tokens.Count -eq 1) { $raw = $tokens[0] if ($raw.Length -ge 2) { $first = $raw[0] $last = $raw[$raw.Length - 1] if ($first -eq $last -and ($first -eq '"' -or $first -eq "'")) { $inner = $raw.Substring(1, $raw.Length - 2) foreach ($innerToken in @(Split-BaknretToken -Text $inner)) { if (Test-BaknretMarker -Token $innerToken) { Write-Log "整行被引号包住,引号里的修饰符不会被识别(历史写法)。请去掉外层引号,并注意现在 `:-` 才是排除、`::` 是覆盖 Path:$Line" -Level WARN break } } } } } # 行首方向标记:`+` 仅备份、`-` 仅恢复。 # # 两种写法都认:独立成记号(`+ Edge`)与贴在目标上(`+Edge`)。后者是本仓库清单 # 里的主流写法,而过去只认前者 —— 于是 `+WindowsTerminal` 被当成一个名叫 # `+WindowsTerminal` 的软件名,名录里查不到就退回当目录名,目录又不存在, # 整条静默记成 missing-source 跳过;备份按"跳过不算失败"退出 0,所以一直没暴露。 # # 只放宽"行首"这一个位置:修饰符(:: / :- / :+ / @)仍然必须是独立记号, # 否则 `C:\a:-b` 这类路径会被切坏 —— 那是另一条已经钉住的行为。 $direction = 'both' if ($tokens[0] -eq '+') { $direction = 'backup' $tokens = @($tokens | Select-Object -Skip 1) } elseif ($tokens[0] -eq '-') { $direction = 'restore' $tokens = @($tokens | Select-Object -Skip 1) } elseif ($tokens[0].Length -gt 1 -and $tokens[0][0] -eq '+') { $direction = 'backup' $tokens[0] = $tokens[0].Substring(1) } elseif ($tokens[0].Length -gt 1 -and $tokens[0][0] -eq '-') { $direction = 'restore' $tokens[0] = $tokens[0].Substring(1) } if ($tokens.Count -eq 0) { return $null } # 第一个修饰符之前是目标。目标可以带空格(比如带引号的 "C:\Program Files\App"), # 所以这里取"第一个修饰符记号之前的全部记号",而不是只取第一个记号。 $firstMarker = -1 for ($index = 0; $index -lt $tokens.Count; $index++) { if (Test-BaknretMarker -Token $tokens[$index]) { $firstMarker = $index; break } } if ($firstMarker -eq 0) { Write-Log "清单行缺少目标,已忽略:$Line" -Level WARN return $null } if ($firstMarker -lt 0) { $targetText = ($tokens -join ' ') $markerTokens = @() } else { $targetText = (($tokens[0..($firstMarker - 1)]) -join ' ') $markerTokens = @($tokens[$firstMarker..($tokens.Count - 1)]) } $target = Remove-BaknretQuote -Text $targetText if ([string]::IsNullOrWhiteSpace($target)) { return $null } $overrides = @{} $flags = @() $unknownKeys = @() $index = 0 while ($index -lt $markerTokens.Count) { $kind = Test-BaknretMarker -Token $markerTokens[$index] $inline = $null if ($kind -eq 'at') { $inline = $markerTokens[$index].Substring(1) } $index++ $values = @() if (-not [string]::IsNullOrWhiteSpace($inline)) { $values += $inline } while ($index -lt $markerTokens.Count -and -not (Test-BaknretMarker -Token $markerTokens[$index])) { $values += $markerTokens[$index] $index++ } switch ($kind) { 'path' { $value = Remove-BaknretQuote -Text ($values -join ' ') if (-not [string]::IsNullOrWhiteSpace($value)) { if ($overrides.ContainsKey('Path')) { Write-Log "同一条目里给了多次路径覆盖,用最后一个:$Line" -Level WARN } $overrides['Path'] = $value } } # 同类记号可以出现多次(`Foo :- a :- b`),**累积**而不是后者覆盖前者: # 静默丢掉前一条排除规则正是这个工具最不该犯的错。 'exclude' { $parsed = @(ConvertFrom-BaknretPatternList -Values $values) if ($overrides.ContainsKey('Exclude')) { $overrides['Exclude'] = @($overrides['Exclude']) + $parsed } else { $overrides['Exclude'] = $parsed } } 'include' { $parsed = @(ConvertFrom-BaknretPatternList -Values $values) if ($overrides.ContainsKey('Include')) { $overrides['Include'] = @($overrides['Include']) + $parsed } else { $overrides['Include'] = $parsed } } 'encrypt' { if ($overrides.ContainsKey('Encrypt')) { Write-Log "同一条目里给了多次加密开关,用最后一个:$Line" -Level WARN } $overrides['Encrypt'] = $true } 'noencrypt' { if ($overrides.ContainsKey('Encrypt')) { Write-Log "同一条目里给了多次加密开关,用最后一个:$Line" -Level WARN } $overrides['Encrypt'] = $false } 'at' { $text = Remove-BaknretQuote -Text ($values -join ' ') if ([string]::IsNullOrWhiteSpace($text)) { continue } $equals = $text.IndexOf('=') if ($equals -lt 0) { # 兼容历史写法:`@encrypt` / `@!encrypt` / `@pathname` / `@root=名` foreach ($legacy in @(ConvertFrom-BaknretPatternList -Values @($text))) { $name = $legacy.Trim().TrimStart('@') if ($name -ieq 'encrypt') { $overrides['Encrypt'] = $true } elseif ($name -ieq '!encrypt') { $overrides['Encrypt'] = $false } elseif ($name) { $flags += $name } } continue } $key = $text.Substring(0, $equals).Trim() $value = Remove-BaknretQuote -Text $text.Substring($equals + 1) switch -Regex ($key) { '(?i)^path$' { $overrides['Path'] = $value } '(?i)^exclude$' { $parsed = @(ConvertFrom-BaknretPatternList -Values @($value)) if ($overrides.ContainsKey('Exclude')) { $overrides['Exclude'] = @($overrides['Exclude']) + $parsed } else { $overrides['Exclude'] = $parsed } } '(?i)^include$' { $parsed = @(ConvertFrom-BaknretPatternList -Values @($value)) if ($overrides.ContainsKey('Include')) { $overrides['Include'] = @($overrides['Include']) + $parsed } else { $overrides['Include'] = $parsed } } '(?i)^encrypt$' { $overrides['Encrypt'] = [bool]($value -match '(?i)^(\$?true|1|yes|on)$') } '(?i)^root$' { $flags += "root=$value" } default { $unknownKeys += $key } } } } } foreach ($unknown in $unknownKeys) { Write-Log "清单里的 @ 字段 '$unknown' 不是已知字段(Path / Exclude / Include / Encrypt),已忽略:$Line" -Level WARN } $resolvedExclude = @() if ($overrides.ContainsKey('Exclude')) { $resolvedExclude = @($overrides['Exclude']) } $resolvedInclude = @() if ($overrides.ContainsKey('Include')) { $resolvedInclude = @($overrides['Include']) } return [pscustomobject]@{ Direction = $direction Path = $target # 目录名或文件名,需要靠 SoftwareCatalog 换成真实路径; # 带分隔符或 %变量% 的写法按字面路径处理。 IsName = (-not (Test-LiteralPath -Path $target)) Overrides = $overrides ExcludePatterns = $resolvedExclude Includes = $resolvedInclude Flags = @($flags) UnknownKeys = @($unknownKeys) Comment = $comment Raw = $Line } } } function Test-LiteralPath { <# .SYNOPSIS 判断清单里的一行是不是"字面路径"(而非软件名)。 .DESCRIPTION 出现分隔符(\ 或 /)或 %环境变量% 就当作字面路径,其余按软件名去名录里查。 这条规则保证:现有的全路径清单不需要任何改写就能继续工作。 #> param([AllowEmptyString()][string]$Path) if ([string]::IsNullOrWhiteSpace($Path)) { return $true } if ($Path.Contains('\') -or $Path.Contains('/')) { return $true } if ($Path.Contains('%')) { return $true } return $false } function Get-BaknretRegexExclude { <# .SYNOPSIS 把一条 `!re:<正则>` 展开成若干 `-x!<归档内路径>` 参数。 .DESCRIPTION 7z 本身只认通配符,不认正则,所以正则只能由脚本自己遍历源目录后翻译成 一条条精确的 `-x!<完整归档内路径>`: * 逐层遍历,命中"目录名或相对路径"就把该目录整个排除,并且**不再往下走** (否则一个命中会产生成千上万条参数); * 展开结果有上限(MaxMatches),超过就明确报错,而不是悄悄漏排除或写出超长命令行。 注意:`!<通配>`(例如 `!*Cache`)不走这里——它在 .NET 里是非法正则 (`*` 前没有可重复的表达式),仍然按"任意层级匹配组件名"翻译成 `-xr!`。 #> param( [Parameter(Mandatory = $true)]$Item, [Parameter(Mandatory = $true)][string]$Pattern, [int]$MaxMatches = 300 ) $arguments = @() $errorText = $null try { $regex = [System.Text.RegularExpressions.Regex]::new( $Pattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) } catch { return [pscustomobject]@{ Arguments = @(); Matches = 0; Error = "排除正则非法:$Pattern —— $($_.Exception.Message)" } } $real = [string]$Item.RealPath if (-not $real -or -not (Test-Path -LiteralPath $real)) { return [pscustomobject]@{ Arguments = @(); Matches = 0; Error = $null } } $root = Get-Item -LiteralPath $real -Force -ErrorAction SilentlyContinue if (-not $root) { return [pscustomobject]@{ Arguments = @(); Matches = 0; Error = $null } } if (-not $root.PSIsContainer) { if ($regex.IsMatch($root.Name)) { $arguments += "-x!$($Item.ArchivePath)" } return [pscustomobject]@{ Arguments = @($arguments); Matches = $arguments.Count; Error = $null } } # 用显式栈做深度优先遍历:命中就整棵剪掉,所以匹配数是"命中的最浅层数"。 $stack = New-Object System.Collections.Generic.Stack[object] foreach ($child in @(Get-ChildItem -LiteralPath $root.FullName -Force -ErrorAction SilentlyContinue)) { $stack.Push(@{ Relative = $child.Name; Item = $child }) } while ($stack.Count -gt 0) { $node = $stack.Pop() $relative = [string]$node.Relative $entry = $node.Item if ($regex.IsMatch($entry.Name) -or $regex.IsMatch($relative)) { $arguments += ('-x!{0}\{1}' -f $Item.ArchivePath, ($relative -replace '/', '\')) if ($arguments.Count -gt $MaxMatches) { $errorText = "排除正则 $Pattern 命中的路径超过 $MaxMatches 条,7z 命令行会过长;请改用更粗的通配模式(例如 !*Cache)" break } continue } if ($entry.PSIsContainer) { foreach ($child in @(Get-ChildItem -LiteralPath $entry.FullName -Force -ErrorAction SilentlyContinue)) { $stack.Push(@{ Relative = ('{0}\{1}' -f $relative, $child.Name); Item = $child }) } } } return [pscustomobject]@{ Arguments = @($arguments); Matches = $arguments.Count; Error = $errorText } } function Get-BaknretExcludeArgument { <# .SYNOPSIS 把一个归档项的模式列表翻译成 7z 的 `-x!` / `-xr!` 参数。 .DESCRIPTION 传进来的模式**已经按项分配好**(见 Split-BaknretPatternScope),因此这里 拿到的模式一律是"相对该项归档根"的: * `<相对路径>` -> `-x!\<相对路径>`(锚定在归档根) * `!<通配>` -> `-xr!<通配>`(任意层级,模式里的空格自动转 `?`) * `!re:<正则>` -> 遍历源目录翻译成若干 `-x!<完整路径>`(见 Get-BaknretRegexExclude) 7z 排除语义(已实测确认): * `-x!<完整归档内路径>` 匹配对象的完整路径,所以要带上项自己的归档根名; * 模式里不能有空格,也不能自己写引号; * 参数总长度有上限,超了明确报错,不静默丢规则。 #> param( [Parameter(Mandatory = $true)]$Item, [string[]]$Patterns = @(), [int]$MaxRegexMatches = 300, [int]$MaxCommandLineChars = 15000 ) $arguments = @() $errorText = $null foreach ($pattern in @($Patterns)) { if ([string]::IsNullOrWhiteSpace($pattern)) { continue } $text = ([string]$pattern).Trim() if ($text.StartsWith('!re:')) { $regexText = $text.Substring(4).Trim() if (-not $regexText) { continue } $expanded = Get-BaknretRegexExclude -Item $Item -Pattern $regexText -MaxMatches $MaxRegexMatches if ($expanded.Error) { $errorText = $expanded.Error; continue } $arguments += @($expanded.Arguments) continue } if ($text.StartsWith('!')) { $component = $text.Substring(1).Trim() if (-not $component) { continue } $arguments += ('-xr!{0}' -f ($component -replace ' ', '?')) continue } $relative = $text.Trim([char[]]@('\', '/')) if (-not $relative) { continue } $arguments += ('-x!{0}\{1}' -f $Item.ArchivePath, ($relative -replace ' ', '?')) } $totalChars = 0 foreach ($argument in $arguments) { $totalChars += $argument.Length + 1 } if (-not $errorText -and $totalChars -gt $MaxCommandLineChars) { $errorText = "排除参数合计约 $totalChars 字符,超过命令行安全长度;请用更粗的通配模式(例如 !*Cache)" } return , [pscustomobject]@{ Arguments = @($arguments); Error = $errorText } } function Split-BaknretPatternScope { <# .SYNOPSIS 把条目级的模式按 `<归档项名>\` 前缀分配到各个归档项上。 .DESCRIPTION 软件目录里的一个软件可以有多个 Slot(各是一个归档内的顶层目录), 所以 `:-` / `Exclude` 里的模式要用第一段点名它作用在哪个 Slot 上: Scoop :- GlobalPersist\steam\steamapps 这里把 `GlobalPersist\` 摘掉、只把 `steam\steamapps` 交给 GlobalPersist 这一项; 第一段没点名任何项时,普通模式对每个项各展开一份(`<项>\<模式>`), `!` 开头与 `!re:` 开头本来就是"任意层级"的,直接广播到每一项,由调用方去重。 返回 hashtable:项的下标 -> 模式数组。 #> param( [Parameter(Mandatory = $true)][array]$Items, [string[]]$Patterns = @() ) $map = @{} for ($index = 0; $index -lt $Items.Count; $index++) { $map[$index] = @() } # 归档项的名字(顶层目录名)。同一个条目里不允许重名,Resolve-BackupEntry 会拦。 $topIndex = @{} for ($index = 0; $index -lt $Items.Count; $index++) { $name = [string]$Items[$index].ArchivePath if (-not $name) { continue } $topIndex[$name.ToLower()] = $index } foreach ($pattern in @($Patterns)) { if ([string]::IsNullOrWhiteSpace($pattern)) { continue } $text = ([string]$pattern).Trim() if ($text.StartsWith('!re:') -or $text.StartsWith('!')) { for ($index = 0; $index -lt $Items.Count; $index++) { $map[$index] += $text } continue } $head = $text $separator = $text.IndexOfAny([char[]]@('\', '/')) $rest = '' if ($separator -ge 0) { $head = $text.Substring(0, $separator) $rest = $text.Substring($separator + 1).Trim([char[]]@('\', '/')) } if ($rest -and $topIndex.ContainsKey($head.ToLower())) { $map[$topIndex[$head.ToLower()]] += $rest continue } for ($index = 0; $index -lt $Items.Count; $index++) { $map[$index] += $text } } return $map } function Merge-BaknretExcludeArgument { <# .SYNOPSIS 合并多个归档项展开出来的排除参数并去重(保序)。 #> param([string[][]]$ArgumentLists = @()) $seen = @{} $merged = @() foreach ($list in @($ArgumentLists)) { foreach ($argument in @($list)) { if ([string]::IsNullOrWhiteSpace($argument)) { continue } if ($seen.ContainsKey($argument)) { continue } $seen[$argument] = $true $merged += $argument } } return @($merged) } # ============================================================================ # 软件名录(SoftwareCatalog.psd1) # ============================================================================ function Resolve-CatalogPath { <# .SYNOPSIS 计算软件名录的绝对路径(优先 .psd1,找不到就退而用 .json)。 #> param([string]$Configured, [string]$Root) $candidates = @() if ($Configured) { $value = $Configured if (-not [System.IO.Path]::IsPathRooted($value)) { $value = Join-Path $Root $value } $candidates += $value } $candidates += (Join-Path $Root 'SoftwareCatalog.psd1') $candidates += (Join-Path $Root 'SoftwareCatalog.json') foreach ($candidate in $candidates) { if (Test-Path -LiteralPath $candidate) { return $candidate } } return $candidates[0] } function Format-CatalogName { <# .SYNOPSIS 把软件名规范化成合法的归档基础名。 .DESCRIPTION 软件名就是归档名,所以这里必须挡住非法文件名字符。 保留 & % +(与路径命名算法的白名单一致)。 #> param([Parameter(Mandatory = $true)][string]$Name) $invalidChars = [System.IO.Path]::GetInvalidFileNameChars() | Where-Object { $_ -notin @('&', '%', '+') } $clean = -join ($Name.Trim().ToCharArray() | ForEach-Object { if ($_ -in $invalidChars) { '_' } else { $_ } }) $clean = $clean -replace ':', '_' return $clean.Trim() } function Test-BaknretMapKey { <# .SYNOPSIS 判断一个数据对象(哈希表或 JSON 对象)里有没有某个键。 #> param($Map, [string]$Key) if ($null -eq $Map) { return $false } if ($Map -is [System.Collections.IDictionary]) { return $Map.Contains($Key) } return @($Map.PSObject.Properties.Name) -contains $Key } function Get-BaknretMapValue { <# .SYNOPSIS 从哈希表或 JSON 对象里按键取值。 #> param($Map, [string]$Key) if ($null -eq $Map) { return $null } if ($Map -is [System.Collections.IDictionary]) { if ($Map.Contains($Key)) { return $Map[$Key] } return $null } if (@($Map.PSObject.Properties.Name) -contains $Key) { return $Map.$Key } return $null } function Get-BaknretMapKeys { <# .SYNOPSIS 列出哈希表或 JSON 对象的全部键。 #> param($Map) if ($null -eq $Map) { return @() } if ($Map -is [System.Collections.IDictionary]) { return @($Map.Keys) } return @($Map.PSObject.Properties.Name) } function Expand-CatalogPathText { <# .SYNOPSIS 展开名录里写的路径:`%环境变量%` 与 `$( ... )` 子表达式。 .DESCRIPTION 名录就是一份受信任的本地 PowerShell 配置,所以 `$( ... )` 直接按 PowerShell 求值, 够写这两类东西: Path = '$(if ($env:SCOOP) { $env:SCOOP } else { Join-Path $env:USERPROFILE "scoop" })\persist' Path = '$(scoop prefix translucenttb)\settings.json' 求值结果按原字符串缓存(`scoop prefix` 要起一个进程,不能每个条目跑一遍)。 括号不配对时原样保留,不抛异常——手写配置要的是可读的告警,不是崩掉。 #> param([AllowEmptyString()][string]$Text) $value = [string]$Text if ([string]::IsNullOrEmpty($value)) { return '' } if ($script:CatalogExpressionCache.ContainsKey($value)) { return $script:CatalogExpressionCache[$value] } $original = $value $guard = 0 while ($guard -lt 32) { $guard++ # 从最后一个 `$(` 开始处理,这样嵌套在外层的表达式最后才展开 $start = $value.LastIndexOf('$(') if ($start -lt 0) { break } $depth = 0 $end = -1 for ($index = $start + 1; $index -lt $value.Length; $index++) { if ($value[$index] -eq '(') { $depth++ } elseif ($value[$index] -eq ')') { $depth-- if ($depth -eq 0) { $end = $index; break } } } if ($end -lt 0) { break } $expression = $value.Substring($start + 2, $end - $start - 2) $replacement = '' try { $evaluated = [scriptblock]::Create($expression).Invoke() if ($null -ne $evaluated) { $replacement = ([string]($evaluated)).Trim() } } catch { Write-Log "名录路径里的表达式求值失败:$expression —— $($_.Exception.Message)" -Level WARN } $value = $value.Substring(0, $start) + $replacement + $value.Substring($end + 1) } $value = [Environment]::ExpandEnvironmentVariables($value) $script:CatalogExpressionCache[$original] = $value return $value } function Import-BaknretDataFile { <# .SYNOPSIS 读取 .psd1 / .json 配置数据。 .DESCRIPTION 先用 Import-PowerShellDataFile(受限语法,不执行任意代码);它对 psd1 里 常见的字符串拼接(`'a,' + 'b'`)会直接报 "Cannot generate a PowerShell object for a ScriptBlock evaluating dynamic expressions", 这种情况下退回 `[scriptblock]::Create(...).Invoke()` 求值。 这个退路是可信的:名录与配置本来就是仓库里的本地文件,跟脚本同级, 而且 Slot 的 Path 里已经允许写 `$( ... )` 子表达式(同样是要执行的)。 #> param([Parameter(Mandatory = $true)][string]$Path) if ($Path.ToLower().EndsWith('.json')) { return (Get-Content -LiteralPath $Path -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop) } try { return Import-PowerShellDataFile -LiteralPath $Path -ErrorAction Stop } catch { $firstLine = ([string]$_.Exception.Message) -split "`r?`n" | Select-Object -First 1 Write-Log "psd1 里有 Import-PowerShellDataFile 不接受的表达式($firstLine),改用 PowerShell 求值:$Path" -Level DEBUG $raw = [System.IO.File]::ReadAllText($Path) return [scriptblock]::Create($raw).Invoke() } } function Get-SoftwareCatalog { <# .SYNOPSIS 载入"软件名 -> Slot 组"名录。 .DESCRIPTION 新结构(SoftwareCatalog.psd1): @{ <软件名> = @{ = @{ Path = '宿主机绝对路径' Exclude = '!*Cache,Default\Extensions' # 可选 Include = 'Modules:D:\extra\ps-modules' # 可选 Encrypt = $true # 可选,默认 $false Description = '这个 Slot 是干什么的' # 可选 } } } Slot 是**归档内的一层目录**:`\<该 Path 的内容>`。一个软件一个归档, 因此同名的目录(例如 scoop 的用户 persist 与全局 persist)只要放在不同 Slot 里就不会撞。 返回按软件名索引的哈希表,每项: Name / Path / Description / Slots / Kind / Missing / Error / Raw Slot 对象:Name / Declared / Resolved / Exists / IsFile / Suffixed / Description / Exclude / Include / Encrypt 读取结果按"文件路径 + 时间戳 + 长度 + 内容 MD5"缓存:一次运行里名录只会真正 读一次(旧实现每解析一个条目就重新 Import 一遍,还会把 `$( ... )` 反复求值)。 #> param( [Parameter(Mandatory = $true)][string]$Path, [int]$MaxDepth = 5, [switch]$NoCache ) $result = @{} if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { return $result } $stamp = $null if (-not $NoCache) { try { $item = Get-Item -LiteralPath $Path -ErrorAction Stop $hash = (Get-FileHash -LiteralPath $Path -Algorithm MD5 -ErrorAction Stop).Hash $stamp = '{0}-{1}-{2}' -f $item.LastWriteTimeUtc.Ticks, $item.Length, $hash if ($script:CatalogCache.ContainsKey($Path) -and $script:CatalogCache[$Path].Stamp -eq $stamp) { return $script:CatalogCache[$Path].Data } } catch { $stamp = $null } } $data = $null try { $data = Import-BaknretDataFile -Path $Path } catch { Write-Log "软件名录读取失败:$Path —— $_" -Level ERROR return $result } # 递归引入其它名录文件(路径相对本文件) $includeValue = Get-BaknretMapValue -Map $data -Key 'Includes' if ($includeValue) { $baseDir = Split-Path -Parent $Path foreach ($include in @($includeValue)) { if (-not $include) { continue } $includePath = [string]$include if (-not [System.IO.Path]::IsPathRooted($includePath)) { $includePath = Join-Path $baseDir $includePath } $included = Get-SoftwareCatalog -Path $includePath -MaxDepth $MaxDepth foreach ($includedName in $included.Keys) { if ($result.ContainsKey($includedName)) { continue } $result[$includedName] = $included[$includedName] } } } foreach ($key in @(Get-BaknretMapKeys -Map $data | Where-Object { $_ -ne 'Includes' })) { $name = Format-CatalogName -Name ([string]$key) if (-not $name) { continue } $raw = Get-BaknretMapValue -Map $data -Key $key if ($raw -isnot [System.Collections.IDictionary] -and $null -ne $raw -and -not ($raw -is [psobject] -and @($raw.PSObject.Properties.Name).Count -gt 0)) { Write-Log "名录条目 '$key' 格式不对:应写成 @{ = @{ Path = '...' } }" -Level ERROR continue } $slots = @() $errors = @() foreach ($slotKey in @(Get-BaknretMapKeys -Map $raw)) { $slotName = ([string]$slotKey).Trim() if (-not $slotName) { continue } $slotRaw = Get-BaknretMapValue -Map $raw -Key $slotKey if ($slotRaw -isnot [System.Collections.IDictionary] -and -not ($slotRaw -is [psobject])) { $errors += "Slot $slotName 的写法不对,应写成 @{ Path = '...' }" continue } $declaredRaw = [string](Get-BaknretMapValue -Map $slotRaw -Key 'Path') if ([string]::IsNullOrWhiteSpace($declaredRaw)) { $errors += "Slot $slotName 缺少 Path" continue } $declared = (Expand-CatalogPathText -Text $declaredRaw).Trim() if ([string]::IsNullOrWhiteSpace($declared)) { $errors += "Slot $slotName 的 Path 展开成空:$declaredRaw" continue } # 逐个候选目录解析。一个 Slot 是归档内的一层目录,只能对应一个目录: # 补全出多个候选(同名目录分散在多处)时必须拆成多个 Slot,否则会混成一棵树。 $candidates = @() if (Test-Path -LiteralPath $declared) { $candidates = @($declared) } else { $parent = Split-Path -Path $declared -Parent $leafName = Split-Path -Path $declared -Leaf if ($parent -and $leafName -and (Test-Path -LiteralPath $parent)) { $candidates = @(Find-ChildDirectoryByName -Parent $parent -Name $leafName -MaxDepth $MaxDepth) } } if ($candidates.Count -gt 1) { $errors += ("Slot {0} 的 Path 匹配到 {1} 个目录:{2};一个 Slot 只能对应一个目录,请拆成多个 Slot" -f ` $slotName, $candidates.Count, ($candidates -join '、')) } $exists = $candidates.Count -ge 1 $resolved = if ($exists) { $candidates[0] } else { $declared } $isFile = $false $suffixed = $false if ($exists) { $suffixed = -not ($resolved -ieq $declared) $resolvedItem = Get-Item -LiteralPath $resolved -Force -ErrorAction SilentlyContinue if ($resolvedItem) { $isFile = -not $resolvedItem.PSIsContainer } } $excludeText = [string](Get-BaknretMapValue -Map $slotRaw -Key 'Exclude') $includeText = [string](Get-BaknretMapValue -Map $slotRaw -Key 'Include') $encryptValue = Get-BaknretMapValue -Map $slotRaw -Key 'Encrypt' $description = [string](Get-BaknretMapValue -Map $slotRaw -Key 'Description') $slots += [pscustomobject]@{ Name = $slotName Declared = $declared Resolved = $resolved Exists = $exists IsFile = $isFile Suffixed = $suffixed Description = $description Exclude = @(ConvertFrom-BaknretPatternList -Values @($excludeText)) Include = @(ConvertFrom-BaknretPatternList -Values @($includeText)) Encrypt = [bool]$encryptValue } } if ($slots.Count -eq 0 -and $errors.Count -eq 0) { continue } # PowerShell 的哈希表不保留书写顺序,而 Slot 的顺序会影响归档内条目顺序与 # "第一个 Slot" 的取值,所以这里按名字排序,保证每次运行完全一致。 $slots = @($slots | Sort-Object -Property Name) $existing = @($slots | Where-Object { $_.Exists }) $missing = @($slots | Where-Object { -not $_.Exists }) $kind = if ($slots.Count -eq 0) { 'Invalid' } elseif ($existing.Count -eq 0) { 'Unresolved' } elseif ($missing.Count -gt 0) { 'Partial' } elseif ($slots.Count -gt 1) { 'Multi' } else { 'Single' } if ($errors.Count -gt 0) { Write-Log ("名录条目 {0} 有问题:{1}" -f $name, ($errors -join ';')) -Level ERROR } elseif ($missing.Count -gt 0) { Write-Log ("名录:{0} 有 {1} 个 Slot 的路径当前不存在:{2}" -f $name, $missing.Count, (($missing | ForEach-Object { $_.Declared }) -join ';')) -Level DEBUG } if ($slots.Count -gt 0) { Write-Log ("名录:{0} -> {1} 个 Slot,其中存在 {2} 个" -f $name, $slots.Count, $existing.Count) -Level DEBUG } if ($result.ContainsKey($name)) { Write-Log ("名录里有两条规范化之后同名的条目:{0}(后者覆盖前者)" -f $name) -Level WARN } $result[$name] = [pscustomobject]@{ Name = $name Path = $(if ($slots.Count -gt 0) { $slots[0].Declared } else { $null }) Description = $(if ($slots.Count -gt 0) { $slots[0].Description } else { $null }) Slots = @($slots) Kind = $kind Missing = @($missing | ForEach-Object { $_.Declared }) Error = $(if ($errors.Count -gt 0) { $errors -join ';' } else { $null }) Raw = $raw } } if ($stamp) { $script:CatalogCache[$Path] = [pscustomobject]@{ Stamp = $stamp; Data = $result } } return $result } function Get-ArchiveTopLevelNames { <# .SYNOPSIS 列出归档内的顶层条目名(用于确认多目录打包时每个目录都真的进去了)。 .DESCRIPTION **刻意不解析 7z 的输出**:读取子进程 stdout 需要创建管道,本机沙箱会直接拒绝 (Access to the path '\\.\pipe\LOCAL\dotnet_...' denied),文件重定向(> file) 同样被拒。所以改成"把归档解到临时目录,再看文件系统上有哪些顶层条目", 只依赖文件系统。代价是多一次解压(只在多目录条目上跑), 好处是这个校验在受限环境里真的会执行,而不是静默退化成空数组。 解压失败或拿不到 7z 时返回空数组,调用方据此跳过顶层名核对。 #> param( [Parameter(Mandatory = $true)][string]$ArchivePath, [Parameter(Mandatory = $true)][string]$SevenZip, [string]$Password ) $staging = Join-Path $env:TEMP ("bnr-inspect-" + [guid]::NewGuid().ToString('N')) $names = @() try { New-Item -ItemType Directory -Path $staging -Force | Out-Null $argument = @('x', '-bso0', '-bsp0', '-y', "-o$staging") if ($Password) { $argument += "-p$Password" } $argument += $ArchivePath $exitCode = Invoke-ExternalCommand -FilePath $SevenZip -ArgumentList $argument if ($exitCode -ne 0) { return @() } # 先把名字读进变量,再在 finally 里删临时目录; # 直接在 return 里管道 Get-ChildItem 会被 finally 抢在前面,拿到空数组。 $names = @(Get-ChildItem -LiteralPath $staging -Force -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Name) } catch { Write-Log "无法清点归档内容(跳过顶层名核对):$($_.Exception.Message)" -Level DEBUG $names = @() } finally { Remove-Item -LiteralPath $staging -Recurse -Force -ErrorAction SilentlyContinue } return $names } function Find-ChildDirectoryByName { <# .SYNOPSIS 在 $Parent 下按精确名或"<名>_<后缀>"/"<名>-<后缀>"形式找目录。 .DESCRIPTION 只做保守的前缀补全:必须以下一个字符是 _ 或 - 为界, 避免把 Legendary 匹配成 LegendarySomething。 #> param( [Parameter(Mandatory = $true)][string]$Parent, [Parameter(Mandatory = $true)][string]$Name, [int]$MaxDepth = 5 ) $escaped = [regex]::Escape($Name) $pattern = "^$escaped(_|-).+" try { return @(Get-ChildItem -LiteralPath $Parent -Directory -Force -ErrorAction SilentlyContinue | Where-Object { $_.Name -ieq $Name -or $_.Name -imatch $pattern } | Sort-Object Name | Select-Object -ExpandProperty FullName) } catch { return @() } } # ============================================================================ # 归档命名与路径还原 # ============================================================================ function Get-ItemArchiveName { <# .SYNOPSIS 决定一个条目的归档基础名(不含扩展名)。 .DESCRIPTION 规则: * 默认用**软件名**(看起来像软件名就查名录;名录里没有则退回可读的目录名); * 条目带 `@pathname` 时用原来的路径命名算法; * 条目本来就写的是字面路径(含分隔符或 %变量%)时也用路径命名算法, 这样现有清单不需要改写就能继续工作。 #> param($Entry, [string]$CatalogPath, [int]$MaxDepth = 5) # @pathname 时用"真实路径"跑路径命名算法。 # 清单里写的可能是软件名,必须先经名录换成真实路径, # 否则 Get-BackupBaseName 会对软件名本身运算,得出错误的名字。 if ($Entry.Flags -contains 'pathname') { $nameSource = $Entry.Path if (-not (Test-LiteralPath -Path $Entry.Path)) { $catalogForPath = Get-SoftwareCatalog -Path $CatalogPath -MaxDepth $MaxDepth if ($catalogForPath.ContainsKey($Entry.Path)) { $nameSource = $catalogForPath[$Entry.Path].Path } } return Get-BackupBaseName -RawPath $nameSource } $looksLikePath = Test-LiteralPath -Path $Entry.Path if (-not $looksLikePath) { $catalog = Get-SoftwareCatalog -Path $CatalogPath -MaxDepth $MaxDepth if ($catalog.ContainsKey($Entry.Path)) { return $catalog[$Entry.Path].Name } Write-Log "名录里没有 '$($Entry.Path)',按目录名处理" -Level WARN return (Format-CatalogName -Name $Entry.Path) } return Get-BackupBaseName -RawPath $Entry.Path } function Get-BaknretArchiveTopName { <# .SYNOPSIS 取归档内相对路径的第一段(顶层名字)。 #> param([AllowEmptyString()][string]$ArchivePath) $clean = ([string]$ArchivePath).Trim().Trim([char[]]@('\', '/')) if (-not $clean) { return '' } $separator = $clean.IndexOfAny([char[]]@('\', '/')) if ($separator -lt 0) { return $clean } return $clean.Substring(0, $separator) } function New-BaknretArchiveItem { <# .SYNOPSIS 构造一个"归档项":宿主机上的一个目录 / 文件,对应归档内的一条路径。 .DESCRIPTION ArchivePath 是**归档内的相对路径**,语义分两种: * 目录项 -> `\<目录内容>`(ArchivePath 是容器) * 文件项 -> `` 就是那个文件本身 这样"是目录还是文件"只看归档就能判断,恢复端不必猜。 Origin 说明这个项是怎么来的(catalog / path / include),运行时会逐条打印, 方便回答"这个目录为什么会在包里"。 #> param( [Parameter(Mandatory = $true)][string]$ArchivePath, [Parameter(Mandatory = $true)][string]$RealPath, [ValidateSet('slot', 'path', 'include')][string]$Kind = 'slot', [string]$Slot = $null, [string]$Description = $null, [string]$Origin = 'catalog', [bool]$Exists = $false, [bool]$IsFile = $false, [string[]]$Exclude = @() ) $clean = ([string]$ArchivePath).Trim().Trim([char[]]@('\', '/')) return [pscustomobject]@{ ArchivePath = $clean TopName = (Get-BaknretArchiveTopName -ArchivePath $clean) RealPath = $RealPath Kind = $Kind Slot = $Slot Description = $Description Origin = $Origin Exists = $Exists IsFile = $IsFile Exclude = @($Exclude) } } function New-BaknretJunction { <# .SYNOPSIS 建一个 junction;失败时抛异常(调用方决定降级还是报错)。 .DESCRIPTION 恢复时用它做"零拷贝落地":把 `<目标父目录>\` 建成指向真实目标目录的 junction,再让 7z 往那里解(写入会穿过 junction 落到真实目录里), 解完立刻拆掉连接点。这样不必"先解到临时目录再整体搬一遍"。 #> param( [Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $true)][string]$Target ) if (Test-Path -LiteralPath $Path) { throw "连接点目标已存在:$Path" } New-Item -ItemType Junction -Path $Path -Target $Target -ErrorAction Stop | Out-Null return $Path } function Remove-BaknretJunction { <# .SYNOPSIS 只删连接点本身,绝不顺着它删到目标目录里去。 #> param([Parameter(Mandatory = $true)][string]$Path) if (-not (Test-Path -LiteralPath $Path)) { return } try { # Directory.Delete(path, recursive: $false) 删的只是重解析点,不碰目标内容 [System.IO.Directory]::Delete($Path, $false) } catch { Remove-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue } } function New-BaknretArchiveStaging { <# .SYNOPSIS 建一个暂存目录,把每个归档项按"归档内的名字"挂进去,供压缩工具直接打包。 .DESCRIPTION 7z 没有"入库时改名"的能力:加进去的名字就是文件系统上的名字。Slot 要成为归档内的一层 目录,就得让它在暂存目录里真的叫那个名字: * 目录项 -> 建 junction(不复制数据,等于零成本改名); * 文件项 -> 先试硬链接(同卷),失败再复制(配置文件都很小)。 返回暂存目录路径;调用方用完必须调 Remove-BaknretArchiveStaging 清理。 建不出连接点时**明确抛错**,绝不悄悄退化成另一种归档布局 —— 布局一变,恢复就对不上。 #> param( [Parameter(Mandatory = $true)][array]$Items, [string]$Root = $null ) if (-not $Root) { $Root = Join-Path $env:TEMP ('bnr-stage-' + [guid]::NewGuid().ToString('N')) } if (-not (Test-Path -LiteralPath $Root)) { New-Item -ItemType Directory -Path $Root -Force | Out-Null } # 半途失败必须在这里自己清干净,不能把责任留给调用方。 # # 原因:调用方拿到的是**返回值**,而抛错时根本没有返回值 —— Backup.ps1 的 finally 里 # `$stagingRoot` 还是 $null,而 Remove-BaknretArchiveStaging 对 $null 是直接 return。 # 结果是已经建好的 junction 与临时目录永久留在 %TEMP%,而那些 junction 指向的是真实 # 数据;临时目录迟早会被某次 Remove-Item -Recurse 扫到,那一下就会走进真实数据。 try { foreach ($item in $Items) { if ([string]::IsNullOrWhiteSpace([string]$item.ArchivePath)) { throw "归档项缺少归档内路径:$($item.RealPath)" } $linkPath = Join-Path $Root $item.ArchivePath $parent = Split-Path -Path $linkPath -Parent if ($parent -and -not (Test-Path -LiteralPath $parent)) { New-Item -ItemType Directory -Path $parent -Force | Out-Null } if (Test-Path -LiteralPath $linkPath) { Remove-BaknretJunction -Path $linkPath } if ($item.IsFile) { try { New-Item -ItemType HardLink -Path $linkPath -Target $item.RealPath -ErrorAction Stop | Out-Null } catch { Write-Log ("暂存:硬链接不可用({0}),改为复制文件 {1}" -f $_.Exception.Message, $item.RealPath) -Level DEBUG Copy-Item -LiteralPath $item.RealPath -Destination $linkPath -Force -ErrorAction Stop } } else { New-Item -ItemType Junction -Path $linkPath -Target $item.RealPath -ErrorAction Stop | Out-Null } Write-Log ("暂存:{0} -> {1}" -f $item.ArchivePath, $item.RealPath) -Level DEBUG } return $Root } catch { try { Remove-BaknretArchiveStaging -Root $Root } catch { # 清理失败不能盖掉真正的失败原因(那才是排查需要的),所以只告警并点名残留路径 Write-Log ("暂存目录自清理失败,需要手工删除:{0} —— {1}" -f $Root, $_.Exception.Message) -Level WARN } throw } } function Remove-BaknretArchiveStaging { <# .SYNOPSIS 安全拆掉暂存目录:先手工摘掉 junction,再删剩下的普通文件 / 目录。 .DESCRIPTION 绝不能直接 `Remove-Item -Recurse` 了事:那会顺着 junction 走进真实数据里。 这里自己走一遍目录树,遇到连接点只删连接点本身。 #> param([string]$Root) if (-not $Root -or -not (Test-Path -LiteralPath $Root)) { return } $pending = New-Object System.Collections.Generic.Stack[string] $pending.Push($Root) while ($pending.Count -gt 0) { $current = $pending.Pop() foreach ($child in @(Get-ChildItem -LiteralPath $current -Force -ErrorAction SilentlyContinue)) { if ($child.LinkType -eq 'Junction' -or $child.LinkType -eq 'SymbolicLink') { Remove-BaknretJunction -Path $child.FullName continue } if ($child.PSIsContainer) { $pending.Push($child.FullName) } } } Remove-Item -LiteralPath $Root -Recurse -Force -ErrorAction SilentlyContinue } function Resolve-BackupEntry { <# .SYNOPSIS 把清单条目解析成"实际要打包什么、归档里长什么样"。 .DESCRIPTION 返回: IsName / BaseName / ArchiveFlavor / Direction CatalogEntry —— 名录条目(软件名写法才有) Items —— 归档项数组(见 New-BaknretArchiveItem) Encrypt —— 该归档是否加密 ExcludePatterns / HasExcludeOverride —— 条目级 `:-` / `@ Exclude` 覆盖 Includes / HasIncludeOverride —— 条目级 `:+` / `@ Include` 覆盖 Error —— 可恢复的问题(例如名录里路径不存在) Blocking —— 必须整条失败的问题(归档内路径冲突等) 归档内部布局: * 软件名条目 -> `\`(文件 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 } } function Write-BackupEntryPlan { <# .SYNOPSIS 在动手打包之前,把"这条会打包哪些目录、归档里长什么样、排除了什么、为什么"打印出来。 .DESCRIPTION 逐项打印:归档内路径、宿主机路径、它是怎么来的(名录 / 手写路径 / 追加)、 当前在不在、是文件还是目录、以及这个 Slot 是干什么的(Description)。 #> param( [Parameter(Mandatory = $true)]$Resolved, [Parameter(Mandatory = $true)][string]$DisplayPath, [string[]]$ListExcludes = @(), [string[]]$CatalogExcludes = @(), [string[]]$ConfigExcludes = @(), [string]$Comment ) $originText = @{ 'catalog' = '软件名录' 'path' = '手写路径' 'include' = '追加项(清单 :+ / 名录 Include)' } $directionText = @{ 'both' = '备份 + 恢复' 'backup' = '仅备份(行首 +)' 'restore' = '仅恢复(行首 -)' } Write-Log ("条目:{0}" -f $DisplayPath) Write-Log (" 归档:{0}.7z;方向:{1};加密:{2}" -f $Resolved.BaseName, $(if ($directionText.ContainsKey($Resolved.Direction)) { $directionText[$Resolved.Direction] } else { $Resolved.Direction }), $(if ($Resolved.Encrypt) { '是' } else { '否' })) if ($Comment) { Write-Log (" 说明:{0}" -f $Comment) } if ($Resolved.Error) { Write-Log (" 提示:{0}" -f $Resolved.Error) -Level WARN } $items = @($Resolved.Items) if ($items.Count -eq 0) { Write-Log ' 归档项:没有解析出任何目录' -Level WARN } for ($index = 0; $index -lt $items.Count; $index++) { $item = $items[$index] $exists = Test-Path -LiteralPath $item.RealPath $origin = if ($item.Origin -and $originText.ContainsKey($item.Origin)) { $originText[$item.Origin] } else { $item.Origin } Write-Log (" 归档项 {0}/{1}:{2} <- {3}" -f ($index + 1), $items.Count, $item.ArchivePath, $item.RealPath) Write-Log (" 来源:{0};{1};{2}" -f $origin, $(if ($exists) { '存在,会打包' } else { '当前不存在,本次跳过' }), $(if ($item.IsFile) { '文件' } else { '目录' })) if ($item.Description) { Write-Log (" 介绍:{0}" -f $item.Description) } if (@($item.Exclude).Count -gt 0) { Write-Log (" 名录里的排除:{0}" -f (@($item.Exclude) -join '、')) } } if ($ListExcludes.Count -gt 0) { Write-Log (" 排除 {0} 条(来自清单的 :- / @ Exclude):{1}" -f $ListExcludes.Count, ($ListExcludes -join '、')) } if ($CatalogExcludes.Count -gt 0) { Write-Log (" 排除 {0} 条(来自名录 Slot 的 Exclude):{1}" -f $CatalogExcludes.Count, ($CatalogExcludes -join '、')) } if ($ConfigExcludes.Count -gt 0) { Write-Log (" 排除 {0} 条(来自 BackupConfig.psd1 的 DefaultExcludes):{1}" -f $ConfigExcludes.Count, ($ConfigExcludes -join '、')) } if ($ListExcludes.Count -eq 0 -and $CatalogExcludes.Count -eq 0 -and $ConfigExcludes.Count -eq 0) { Write-Log ' 排除:无(整包收下)' } } function Get-BackupBaseName { <# .SYNOPSIS 由清单中的原始路径生成归档基础名。 .DESCRIPTION 算法与历史版本保持一致(否则已存在的 20 个归档会全部失联): <末级名>_from_<去掉末级后的各级用 + 连接> 并保留 & % + 三个字符(环境变量写法依赖 %),其余非法字符换 _。 额外做一件事:把 `:` 归一化为 `_`,因此 C:\Foo 与 "C:\Foo" 结果相同。 #> param([Parameter(Mandatory = $true)][string]$RawPath) $normalized = $RawPath.Trim() -replace '[/\\]+', '\' $parts = @($normalized -split '\\' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) if ($parts.Count -eq 0) { Write-Log "无法解析路径:$RawPath" -Level ERROR return $null } $folderName = $parts[-1].Trim() $pathParts = if ($parts.Count -gt 1) { $parts[0..($parts.Count - 2)] } else { @() } $pathPart = ($pathParts | ForEach-Object { $_.Trim() }) -join '+' $baseName = if ([string]::IsNullOrEmpty($pathPart)) { $folderName } else { "${folderName}_from_${pathPart}" } $invalidChars = [System.IO.Path]::GetInvalidFileNameChars() | Where-Object { $_ -notin @('&', '%', '+') } $baseName = -join ($baseName.ToCharArray() | ForEach-Object { if ($_ -in $invalidChars) { '_' } else { $_ } }) $baseName = $baseName -replace ':', '_' Write-Log "生成文件基础名:$baseName" -Level DEBUG return $baseName } function Convert-BackupFileNameToPath { <# .SYNOPSIS 把归档文件名还原成原始路径(用于没有 manifest 时的兜底)。 .DESCRIPTION 只处理 <名>_from_<路径> 形式;`C_` 还原为 `C:`。 命名里本来就含 `+` 或 `_from_` 的真实目录名无法可靠还原, 这类情况应当依赖 manifest.json 而不是文件名。 #> param([Parameter(Mandatory = $true)][string]$FileName) $baseName = [System.IO.Path]::GetFileNameWithoutExtension($FileName) if ($baseName -notmatch '_from_') { return $null } try { $folderPart, $pathPart = $baseName -split '_from_', 2 $parts = @($pathPart -split '\+' | Where-Object { -not [string]::IsNullOrEmpty($_) }) $parts = @($parts | ForEach-Object { if ($_ -match '^([A-Za-z])_$') { "$($matches[1]):" } else { $_ } }) $reconstructed = ($parts -join '\') + '\' + $folderPart Write-Log "逆向解析:$FileName -> $reconstructed" -Level DEBUG return $reconstructed } catch { Write-Log "无法解析备份文件名:$FileName" -Level WARN return $null } } function Get-FolderSummary { <# .SYNOPSIS 统计目录/文件的文件数、总大小与最新修改时间。 .DESCRIPTION LatestModifiedTime 取**包含目录在内**的所有条目的最大值: 目录的 LastWriteTime 会在子项增删时更新,因此删掉文件也能被察觉。 #> param([Parameter(Mandatory = $true)][string]$FolderPath) try { $items = @(Get-ChildItem -LiteralPath $FolderPath -Recurse -Force -ErrorAction SilentlyContinue) $files = @($items | Where-Object { -not $_.PSIsContainer }) return [pscustomobject]@{ FileCount = $files.Count TotalSize = ($files | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum LatestModifiedTime = ($items | Measure-Object -Property LastWriteTime -Maximum -ErrorAction SilentlyContinue).Maximum } } catch { Write-Log "无法读取文件夹摘要:$FolderPath" -Level WARN return [pscustomobject]@{ FileCount = 0 TotalSize = 0 LatestModifiedTime = (Get-Item -LiteralPath $FolderPath -ErrorAction SilentlyContinue).LastWriteTime } } } # ============================================================================ # manifest.json # ============================================================================ function Read-BaknretManifest { <# .SYNOPSIS 读取 manifest.json;不存在或损坏时返回空清单。 .DESCRIPTION items 是按归档基础名索引的对象,方便按条目合并与查找。 损坏时只告警不中断:manifest 只是记录,不该成为备份的阻塞点。 #> param([Parameter(Mandatory = $true)][string]$Path) $empty = [pscustomobject]@{ schemaVersion = 1 tool = 'BakNRet' updatedAt = $null compressor = $null items = [ordered]@{} } if (-not (Test-Path -LiteralPath $Path)) { return $empty } try { $raw = Get-Content -LiteralPath $Path -Raw -Encoding UTF8 -ErrorAction Stop if ([string]::IsNullOrWhiteSpace($raw)) { return $empty } $parsed = $raw | ConvertFrom-Json -ErrorAction Stop $items = [ordered]@{} if ($parsed.PSObject.Properties.Name -contains 'items' -and $parsed.items) { foreach ($property in $parsed.items.PSObject.Properties) { $items[$property.Name] = $property.Value } } return [pscustomobject]@{ schemaVersion = 1 tool = 'BakNRet' updatedAt = $parsed.updatedAt compressor = $parsed.compressor items = $items } } catch { Write-Log "manifest 解析失败(将重新建立):$Path —— $_" -Level WARN return $empty } } function Sync-BaknretManifestArchive { <# .SYNOPSIS 清空 manifest 里"指向了一个不存在的归档"的 archive 字段,返回被清空的条目名。 .DESCRIPTION 维持一条不变式:**manifest 里写了 archive 的记录,磁盘上就一定有那个文件。** 没有这条不变式时会出现两种误导: * 源不存在的条目(missing-source / invalid-path)本来就没有归档,记录里却留着 一个不存在的文件名,Restore 每次都会打一条 "manifest 记录的归档不存在,回退按文件名查找",看着像出了问题其实没有; * 人工删掉了某个归档(例如把它并进了另一个条目)之后,记录还宣称它在那儿。 只清 archive 字段,保留条目本身的历史(source / 成功次数 / 上次恢复时间), 因为"这个软件曾经备份过、现在源不在了"本身就是有用信息。 #> param( [Parameter(Mandatory = $true)]$Manifest, [Parameter(Mandatory = $true)][string]$BackupDir ) $cleared = @() if (-not $Manifest -or -not $Manifest.items) { return , $cleared } foreach ($key in @($Manifest.items.Keys)) { $item = $Manifest.items[$key] if (-not $item) { continue } if (-not ($item.PSObject.Properties.Name -contains 'archive')) { continue } $archive = $item.archive if ([string]::IsNullOrWhiteSpace([string]$archive)) { continue } if (Test-Path -LiteralPath (Join-Path $BackupDir $archive)) { continue } $item.archive = $null $cleared += $key } return , $cleared } function Write-BaknretManifest { <# .SYNOPSIS 原子写入 manifest.json(UTF-8 无 BOM)。 #> param( [Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $true)]$Manifest ) $Manifest.updatedAt = (Get-Date).ToString('o') $json = $Manifest | ConvertTo-Json -Depth 6 $directory = Split-Path -Parent $Path if ($directory -and -not (Test-Path -LiteralPath $directory)) { New-Item -ItemType Directory -Path $directory -Force | Out-Null } $temp = "$Path.tmp" [System.IO.File]::WriteAllText($temp, $json, $script:LogEncoding) if (Test-Path -LiteralPath $Path) { Remove-Item -LiteralPath $Path -Force } Move-Item -LiteralPath $temp -Destination $Path -Force return $Path } # ============================================================================ # 归档原子替换 # ============================================================================ function Move-BaknretArchiveIntoPlace { <# .SYNOPSIS 把临时归档原子地替换到最终路径。 .DESCRIPTION 优先用 File.Move(overwrite)(同卷上是 MoveFileEx + REPLACE_EXISTING, 基本等价于原子替换);不支持时退化为先删后移。 #> param( [Parameter(Mandatory = $true)][string]$TempPath, [Parameter(Mandatory = $true)][string]$DestinationPath ) try { [System.IO.File]::Move($TempPath, $DestinationPath, $true) return } catch { Write-Log "原子替换失败,退化为先删后移:$_" -Level DEBUG } if (Test-Path -LiteralPath $DestinationPath) { Remove-Item -LiteralPath $DestinationPath -Force } Move-Item -LiteralPath $TempPath -Destination $DestinationPath -Force } # ============================================================================ # 安全描述符(NTFS 属主 / ACL) # ============================================================================ # 为什么需要它:归档格式(.7z / .zip / .tar)**不承载 NT 安全描述符** —— # 7-Zip 的 -sni(Store NT security information)官方文档写明"当前版本只能写进 WIM 归档"。 # 于是"备份 → 恢复"之后,每个对象的安全描述符都是新建对象的默认值: # 属主是跑恢复脚本的那个进程,DACL 是从目标父目录继承来的那一套。 # # 对 C:\ProgramData 下的目录这是致命的,它的 ACL 里有: # (A;OICIIO;GA;;;CO) CREATOR OWNER + inherit-only + GENERIC_ALL # 而 CREATOR OWNER(S-1-3-0)不是账户,是**访问检查时才替换的占位符**: # 替换成"被检查对象的属主"。所以只回放 ACE 文本、不恢复属主,等于把 # "谁创建的东西谁有全权"里的那个"谁"换成了跑脚本的账户,原程序反而没权限。 # # 存储格式:每对象一条 SDDL($acl.Sddl 原文)。SDDL 的 SID 是数值形式,CO / OW # 这类占位符原样保留,往返无损;**绝不做账户名解析**——名字解析会把占位符映射成 # 当前用户,或者直接抛 IdentityNotMappedException,那正是"权限落到脚本头上"的另一种成因。 # # 恢复:自顶向下、每个对象一次写 Owner|Group|Access;原本不 protected 的 DACL # 只写显式 ACE,其余交给(已经修好的)父目录重新继承,保住"活继承"的语义。 # 写属主需要 SeRestorePrivilege,而且必须**显式启用**:管理员的过滤令牌里它默认是 # disabled,Set-Acl / SetAccessControl 都不会替你打开(见 Enable-BaknretPrivilege)。 $script:BaknretPrivilegeState = @{} function Enable-BaknretPrivilege { <# .SYNOPSIS 在当前进程令牌里启用指定特权,返回哪些没能启用。 .DESCRIPTION 必须显式启用。MSDN(SetNamedSecurityInfoW)写明: "If the caller does not have the SeRestorePrivilege constant, this SID must be contained in the caller's token, and must have the SE_GROUP_OWNER permission enabled." 也就是说没有它就没法把属主改成别的账户,而失败信息只有一句 "Access is denied"(easily mistaken for a path problem)。 两个坑: * 结构体嵌套赋值(`$tp.Privileges.Luid.LowPart = …`)在 PowerShell 里改的是 装箱副本,改了不生效,所以整段放进 C# 里做; * AdjustTokenPrivileges 返回 true 也可能是 ERROR_NOT_ALL_ASSIGNED(1300), 那代表特权根本不在令牌里,必须当成失败。 返回 [pscustomobject]@{ Enabled; Missing; Failed }(都是名字数组)。 #> param([string[]]$Name = @('SeRestorePrivilege', 'SeBackupPrivilege')) $result = [pscustomobject]@{ Enabled = @() Missing = @() Failed = @() } if (-not ('Baknret.Privileges' -as [type])) { try { Add-Type -Namespace Baknret -Name Privileges -MemberDefinition @' [DllImport("advapi32.dll", SetLastError = true)] static extern bool OpenProcessToken(IntPtr h, int acc, out IntPtr phtok); [DllImport("advapi32.dll", SetLastError = true)] static extern bool LookupPrivilegeValue(string host, string name, out long pluid); [DllImport("advapi32.dll", SetLastError = true)] static extern bool AdjustTokenPrivileges(IntPtr htok, bool disall, ref TOKEN_PRIVILEGES newst, int len, IntPtr prev, IntPtr relen); [DllImport("kernel32.dll")] static extern IntPtr GetCurrentProcess(); [DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr h); [StructLayout(LayoutKind.Sequential)] public struct LUID { public uint LowPart; public int HighPart; } [StructLayout(LayoutKind.Sequential)] public struct LUID_AND_ATTRIBUTES { public LUID Luid; public uint Attributes; } [StructLayout(LayoutKind.Sequential)] public struct TOKEN_PRIVILEGES { public uint PrivilegeCount; public LUID_AND_ATTRIBUTES Privileges; } // 0 = 已启用;1 = 令牌里没有这个特权;2 = 其它失败 public static int Enable(string name) { IntPtr token; if (!OpenProcessToken(GetCurrentProcess(), 0x28, out token)) { return 2; } try { long luid; if (!LookupPrivilegeValue(null, name, out luid)) { return 1; } TOKEN_PRIVILEGES tp = new TOKEN_PRIVILEGES(); tp.PrivilegeCount = 1; tp.Privileges.Luid.LowPart = (uint)(luid & 0xFFFFFFFF); tp.Privileges.Luid.HighPart = (int)(luid >> 32); tp.Privileges.Attributes = 0x2; if (!AdjustTokenPrivileges(token, false, ref tp, 0, IntPtr.Zero, IntPtr.Zero)) { return 2; } if (Marshal.GetLastWin32Error() == 1300) { return 1; } return 0; } finally { CloseHandle(token); } } '@ } catch { Write-Log "特权启用代码编译失败(本次不启用任何特权):$($_.Exception.Message)" -Level WARN $result.Failed = @($Name) return $result } } $enabled = @(); $missing = @(); $failed = @() foreach ($privilege in @($Name)) { $cacheKey = $privilege if ($script:BaknretPrivilegeState.ContainsKey($cacheKey)) { $state = $script:BaknretPrivilegeState[$cacheKey] } else { $state = [Baknret.Privileges]::Enable($privilege) $script:BaknretPrivilegeState[$cacheKey] = $state } switch ($state) { 0 { $enabled += $privilege } 1 { $missing += $privilege } default { $failed += $privilege } } } if ($missing.Count -gt 0) { Write-Log ("这些特权不在当前令牌里(需要管理员或 SYSTEM):{0} —— 属主将无法改成别的账户,只能恢复 DACL" -f ($missing -join '、')) -Level WARN } if ($failed.Count -gt 0) { Write-Log ("这些特权启用失败:{0}" -f ($failed -join '、')) -Level WARN } $result.Enabled = @($enabled) $result.Missing = @($missing) $result.Failed = @($failed) return $result } function ConvertTo-BaknretWildcardPattern { <# .SYNOPSIS 把 7z 风格的通配符(* 与 ?)转成正则片段。 .DESCRIPTION 与 Get-BaknretExcludeArgument 保持一致:模式里的空格先转成 `?`(7z 的 `-x!` 不接受带空格的模式)。`*` 转 `.*`,跨过路径分隔符, 这样锚定模式 `Default\*` 才能命中 `Default\a\b`。 #> param([AllowEmptyString()][string]$Pattern) $text = ([string]$Pattern) -replace ' ', '?' $escaped = [regex]::Escape($text) $escaped = $escaped -replace '\\\*', '.*' $escaped = $escaped -replace '\\\?', '.' return $escaped } function Test-BaknretPathExcluded { <# .SYNOPSIS 判断归档内的一个相对路径是否命中排除模式。 .DESCRIPTION 安全描述符采集走的目录树必须和真正打进归档的那棵树一致,否则会出现 "归档里有、安全描述符里没有"(恢复后那块内容变成新建对象的默认 ACL)。 所以这里与交给 7z 的 -x! / -xr! 语义对齐: * `<相对路径>` 锚定在本归档项的根上(`Default\Cache` 只命中它自己那棵子树) * `!<通配>` 任意层级按**组件名**匹配(`!*Cache` 命中任意一层叫 *Cache 的目录) * `!re:<正则>` 正则:命中组件名或整条相对路径 $RelativePath 用 `\` 分隔,且**不含归档项的根名**。 #> param( [AllowEmptyString()][string]$RelativePath, [string[]]$Patterns = @() ) $relative = ([string]$RelativePath).Trim([char[]]@('\', '/')) if (-not $relative) { return $false } $components = @($relative -split '\\') foreach ($pattern in @($Patterns)) { if ([string]::IsNullOrWhiteSpace($pattern)) { continue } $text = ([string]$pattern).Trim() if ($text.StartsWith('!re:')) { $regexText = $text.Substring(4).Trim() if (-not $regexText) { continue } try { $options = [System.Text.RegularExpressions.RegexOptions]::IgnoreCase if ([regex]::IsMatch($relative, $regexText, $options)) { return $true } foreach ($component in $components) { if ([regex]::IsMatch($component, $regexText, $options)) { return $true } } } catch { Write-Log "排除正则非法,安全描述符采集按'不排除'处理:$regexText —— $($_.Exception.Message)" -Level WARN } continue } if ($text.StartsWith('!')) { $wildcard = $text.Substring(1).Trim() if (-not $wildcard) { continue } $componentPattern = '^(?:' + (ConvertTo-BaknretWildcardPattern -Pattern $wildcard) + ')$' foreach ($component in $components) { if ($component -match $componentPattern) { return $true } } continue } $anchored = ([string]$text).Trim([char[]]@('\', '/')) if (-not $anchored) { continue } $anchoredPattern = '^(?:' + (ConvertTo-BaknretWildcardPattern -Pattern $anchored) + ')$' if ($relative -match $anchoredPattern) { return $true } } return $false } function Get-BaknretAceSignatureList { <# .SYNOPSIS 把 ACE 列表压成可比对的"签名"集合(`类型|SID|掩码`)。 .DESCRIPTION 只用来回答一个问题:"子对象上这条继承来的 ACE,在父目录的 ACL 里找得到出处吗?" 所以**刻意不带继承标志位**:同一条 ACE 传给文件子对象时容器继承位会被去掉 (实测父目录的 (A;OICI;FA;;;SY) 到文件上变成 (A;ID;FA;;;SY)), 带上标志比较会永远不相等。掩码取 AccessMask 整数值,避免枚举把组合权限拆得不一样。 #> param([array]$Rules = @()) $list = @() foreach ($rule in @($Rules)) { if (-not $rule) { continue } $mask = -1 try { $mask = [int]$rule.FileSystemRights } catch { $mask = -1 } $list += ('{0}|{1}|{2}' -f $rule.AccessControlType, $rule.IdentityReference.Value, $mask) } return $list } function Get-BaknretSecuritySddlWithStale { <# .SYNOPSIS 对象与父目录的继承链**不自洽**时,把整套 ACE 冻结成显式副本(并置 protected), 返回改写后的 SDDL;自洽时原样返回 $Acl.Sddl。 .DESCRIPTION 恢复时只重放**显式** ACE,其余交给父目录重新继承 —— 对绝大多数对象这是最忠实的 做法(父目录修好之后继承会长出同样的 ACE,还保住了活继承语义)。 但有一类对象不行:它的 DACL 里留着**陈旧**的继承 ACE —— 父目录早就改过权限, 这条 ACE 已经没有任何出处。真机实测两件事: 1) 把父目录设成 protected 的新 DACL 之后,子对象仍留着从祖父目录继承来的 `(A;ID;FA;;;S-1-5-21-…)`;条数与父目录的可继承条数**正好都是 4**、内容却不同 —— 所以判据必须比 ACE 内容,不能只数条数。 2) Windows 在改写父目录时**不会**替子对象清掉这种已无出处的 ACE。于是 "目标上本来就留着它 + 我又补写一条显式 ACE" = 同一条 ACE 出现两次。 所以这类对象只能整套冻结:显式 ACE + 陈旧 ACE 全部按显式写,并置 protected (protected 才不会被系统再补一遍继承 ACE)。代价是这个对象从此不跟随父目录 —— 但它本来就已经跟父目录脱节了,冻结是唯一"不丢 ACE、也不重复 ACE"的做法。 $ParentSignatures 为 $null 表示"调用方没有父目录上下文"(归档项根、单文件项), 此时不做任何改写。 #> param( [Parameter(Mandatory = $true)]$Acl, [AllowNull()][string[]]$ParentSignatures = $null ) if ($null -eq $ParentSignatures) { return $Acl.Sddl } $sid = [System.Security.Principal.SecurityIdentifier] $inherited = @($Acl.GetAccessRules($false, $true, $sid)) if ($inherited.Count -eq 0) { return $Acl.Sddl } # 自洽 = 继承来的 ACE 每一条都能在父目录的 ACL 里找到出处 $stale = @() foreach ($rule in $inherited) { $signature = @(Get-BaknretAceSignatureList -Rules @($rule))[0] if ($ParentSignatures -notcontains $signature) { $stale += $rule } } if ($stale.Count -eq 0) { return $Acl.Sddl } $rebuilt = $null if ($Acl -is [System.Security.AccessControl.DirectorySecurity]) { $rebuilt = New-Object System.Security.AccessControl.DirectorySecurity } else { $rebuilt = New-Object System.Security.AccessControl.FileSecurity } # 整套(显式 + 继承)都按显式写:内容与备份时逐条一致,不靠继承去"猜"回来 foreach ($rule in @($Acl.GetAccessRules($true, $true, $sid))) { $rebuilt.AddAccessRule($rule) } $sections = [System.Security.AccessControl.AccessControlSections]::Access try { $rebuilt.SetOwner($Acl.GetOwner($sid)) $sections = $sections -bor [System.Security.AccessControl.AccessControlSections]::Owner } catch { } try { $rebuilt.SetGroup($Acl.GetGroup($sid)) $sections = $sections -bor [System.Security.AccessControl.AccessControlSections]::Group } catch { } $rebuilt.SetAccessRuleProtection($true, $false) Write-Log ("{0} 条继承 ACE 已无出处(父目录里找不到),整套 ACE 冻结为显式并置 protected" -f $stale.Count) -Level DEBUG return $rebuilt.GetSecurityDescriptorSddlForm($sections) } function Get-BaknretSecurityRecord { <# .SYNOPSIS 读一个对象的安全描述符,产出可序列化的一条记录。 .DESCRIPTION 返回 [pscustomobject]: p / k 归档内相对路径 / 类型(d 目录、f 文件) s SDDL 原文(含 O: / G: / D:) o / g 属主 / 属组 SID 字符串 e 读不到时的错误(**必须记账**,不能当成"没有特殊权限") Protected / Explicit / Inherited / Inheritable / Analyzed Smart 模式判断"是否与父目录不同"用的分析结果 属主/属组一律取 SID 字符串(GetOwner(SecurityIdentifier).Value): 走 .Owner 会触发账户名解析,孤儿 SID 上会抛异常或很慢,而我们只要数值身份。 读 SD 需要 READ_CONTROL;C:\ProgramData 里确实有 Get-Acl 直接报 "Attempted to perform an unauthorized operation" 的目录,先开 SeBackupPrivilege 能救回大部分,救不回的会带 e 字段落进 sidecar。 #> param( [Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $true)][string]$Key, [ValidateSet('d', 'f')][string]$Kind = 'd', [switch]$IncludeSacl, [AllowNull()][string[]]$ParentSignatures = $null ) $record = [pscustomobject]@{ p = $Key k = $Kind s = $null o = $null g = $null e = $null Protected = $false Explicit = 0 Inherited = 0 Inheritable = 0 InheritedSignatures = @() AllSignatures = @() Analyzed = $false } $acl = $null try { if ($IncludeSacl) { $acl = Get-Acl -LiteralPath $Path -Audit -ErrorAction Stop } else { $acl = Get-Acl -LiteralPath $Path -ErrorAction Stop } } catch { $record.e = $_.Exception.Message return $record } try { # 陈旧继承 ACE 要固化成显式 ACE,否则恢复后会消失(见 Get-BaknretSecuritySddlWithStale) $record.s = Get-BaknretSecuritySddlWithStale -Acl $acl -ParentSignatures $ParentSignatures } catch { $record.e = $_.Exception.Message } if (-not $record.s) { if (-not $record.e) { $record.e = '读不到安全描述符' } return $record } try { $record.o = $acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value } catch { } try { $record.g = $acl.GetGroup([System.Security.Principal.SecurityIdentifier]).Value } catch { } try { $sid = [System.Security.Principal.SecurityIdentifier] $record.Protected = [bool]$acl.AreAccessRulesProtected $explicitRules = @($acl.GetAccessRules($true, $false, $sid)) $inheritedRules = @($acl.GetAccessRules($false, $true, $sid)) $record.Explicit = $explicitRules.Count $record.Inherited = $inheritedRules.Count $record.InheritedSignatures = @(Get-BaknretAceSignatureList -Rules $inheritedRules) $record.AllSignatures = @(Get-BaknretAceSignatureList -Rules @($acl.GetAccessRules($true, $true, $sid))) $inheritable = 0 foreach ($rule in @($acl.GetAccessRules($true, $true, $sid))) { $fsRule = $rule -as [System.Security.AccessControl.FileSystemAccessRule] if ($fsRule -and ($fsRule.InheritanceFlags -ne [System.Security.AccessControl.InheritanceFlags]::None)) { $inheritable++ } } $record.Inheritable = $inheritable $record.Analyzed = $true } catch { # 分析失败时一律当成"需要保留"(Analyzed=$false 会让 keeper 直接保留) $record.Analyzed = $false } return $record } function Test-BaknretSecurityRecordNeeded { <# .SYNOPSIS Smart 模式下判断这条记录是否必须落进 sidecar。 .DESCRIPTION 判据是"恢复时不能被继承自动复现",任何一条成立就得留: * 读不到(e)—— 必须记账,恢复时要能报出来; * DACL 是 protected(断开继承)—— 只靠父目录继承永远复现不出这一套; * 有显式 ACE(Explicit > 0)—— 同上; * NULL DACL(NO_ACCESS_CONTROL)—— 那不是"没有特殊权限",是"人人全权"; * 属主 / 属组与父目录不同 —— CREATOR OWNER 的解析结果就取决于属主; * 继承链路与父目录脱节 —— 条数对不上,或某条继承来的 ACE 在父目录 ACL 里 找不到出处(父目录改过权限、子对象还留着老 ACE);空 DACL 也会在这里露出来。 分析不了(Analyzed=$false)时一律保留:多存永远比少存安全。 #> param( [Parameter(Mandatory = $true)]$Record, [string]$ParentOwner, [string]$ParentGroup, [int]$ParentInheritable = -1, [string[]]$ParentSignatures = @(), [switch]$Force ) if ($Force) { return $true } if ($Record.e) { return $true } if (-not $Record.s) { return $true } if (-not $Record.Analyzed) { return $true } if ($Record.Protected) { return $true } if ($Record.Explicit -gt 0) { return $true } if ($Record.s -match 'NO_ACCESS_CONTROL') { return $true } if ($Record.o -and $ParentOwner -and ($Record.o -ne $ParentOwner)) { return $true } if ($Record.g -and $ParentGroup -and ($Record.g -ne $ParentGroup)) { return $true } # 继承链还接不接得上父目录:先比条数,再比每一条在父目录 ACL 里有没有出处。 # 只比条数会漏判 —— 真机实测过:子对象留着"改权限之前"的老 ACE, # 条数与父目录可继承条数正好相等(都 4 条),内容却完全不同。 if ($ParentInheritable -ge 0 -and $Record.Inherited -ne $ParentInheritable) { return $true } foreach ($signature in @($Record.InheritedSignatures)) { if ($ParentSignatures -notcontains $signature) { return $true } } return $false } function Get-BaknretSecurityRecords { <# .SYNOPSIS 采集一组归档项的安全描述符,键是**归档内相对路径**(`\…`)。 .DESCRIPTION 键用归档内路径而不是宿主机路径:目标机器上 `%UserProfile%` 会变、名录的前缀补全 (legendary -> legendary_2.0.4)也会变,只有归档内相对路径在两端是同一个坐标系。 遍历用显式栈,并且**跳过 reparse point**:PS 5.1 的 Get-ChildItem -Recurse 会 跟着 junction 无限转;scoop 的 `apps\\current` 就是 junction,正撞在这个坑上。 $ScopeMap 由 Split-BaknretPatternScope 产出(项下标 -> 该相对根的模式数组), 所以这里的排除判定与真正交给 7z 的 -x! / -xr! 是同一套规则。 Mode: * Roots —— 只存每个归档项的根(最省,适合"权限只在根上"的场景) * Smart —— 根 + 所有"继承复现不出来"的对象(默认;几万文件的树 sidecar 也只有几百 KB) * Full —— 每一个对象都存(最保险,sidecar 会大到几 MB) 返回 [pscustomobject]@{ Records; Scanned; Kept; Errors }。 #> param( [array]$Items = @(), [hashtable]$ScopeMap = @{}, [ValidateSet('Roots', 'Smart', 'Full')][string]$Mode = 'Smart', [switch]$IncludeSacl ) $records = New-Object System.Collections.Generic.List[object] $scanned = 0 $errorCount = 0 # 读安全描述符要 READ_CONTROL:系统目录里读不到是常态(C:\ProgramData 下就有 # Get-Acl 直接报 "Attempted to perform an unauthorized operation" 的目录)。 # SeBackupPrivilege 启用后系统会把读权限授予任何文件;连它都没有的账户, # 读不到的对象会带 e 字段落进 sidecar,而不是被静默当成"没有特殊权限"。 $privileges = @('SeBackupPrivilege') if ($IncludeSacl) { $privileges += 'SeSecurityPrivilege' } Enable-BaknretPrivilege -Name $privileges | Out-Null for ($index = 0; $index -lt $Items.Count; $index++) { $item = $Items[$index] if (-not $item) { continue } $archiveRoot = [string]$item.ArchivePath $real = [string]$item.RealPath if ([string]::IsNullOrWhiteSpace($archiveRoot) -or [string]::IsNullOrWhiteSpace($real)) { continue } if (-not (Test-Path -LiteralPath $real)) { continue } $patterns = @() if ($ScopeMap -and $ScopeMap.ContainsKey($index)) { $patterns = @($ScopeMap[$index]) } $rootItem = Get-Item -LiteralPath $real -Force -ErrorAction SilentlyContinue if (-not $rootItem) { continue } if (-not $rootItem.PSIsContainer) { $record = Get-BaknretSecurityRecord -Path $real -Key $archiveRoot -Kind 'f' -IncludeSacl:$IncludeSacl $scanned++ if ($record.e) { $errorCount++ } $records.Add($record) continue } $rootRecord = Get-BaknretSecurityRecord -Path $real -Key $archiveRoot -Kind 'd' -IncludeSacl:$IncludeSacl $scanned++ if ($rootRecord.e) { $errorCount++ } $records.Add($rootRecord) if ($Mode -eq 'Roots') { continue } $pending = New-Object System.Collections.Generic.Stack[object] $pending.Push(@{ Dir = $rootItem Rel = '' Owner = $rootRecord.o Group = $rootRecord.g Inheritable = $rootRecord.Inheritable Signatures = $rootRecord.AllSignatures }) while ($pending.Count -gt 0) { $frame = $pending.Pop() foreach ($child in @(Get-ChildItem -LiteralPath $frame.Dir.FullName -Force -ErrorAction SilentlyContinue)) { if ($child.Attributes -band [IO.FileAttributes]::ReparsePoint) { continue } $childRel = if ($frame.Rel) { $frame.Rel + '\' + $child.Name } else { $child.Name } if (Test-BaknretPathExcluded -RelativePath $childRel -Patterns $patterns) { continue } $kind = if ($child.PSIsContainer) { 'd' } else { 'f' } $record = Get-BaknretSecurityRecord -Path $child.FullName -Key ($archiveRoot + '\' + $childRel) ` -Kind $kind -IncludeSacl:$IncludeSacl -ParentSignatures $frame.Signatures $scanned++ if ($record.e) { $errorCount++ } if ($Mode -eq 'Full') { $records.Add($record) } elseif (Test-BaknretSecurityRecordNeeded -Record $record ` -ParentOwner $frame.Owner -ParentGroup $frame.Group ` -ParentInheritable $frame.Inheritable -ParentSignatures $frame.Signatures) { $records.Add($record) } if ($child.PSIsContainer) { $pending.Push(@{ Dir = $child Rel = $childRel Owner = $record.o Group = $record.g Inheritable = $record.Inheritable Signatures = $record.AllSignatures }) } } } } return [pscustomobject]@{ Records = @($records.ToArray()) Scanned = $scanned Kept = $records.Count Errors = $errorCount } } function Write-BaknretAtomicText { <# .SYNOPSIS 原子写一个文本文件(先写 .tmp,再替换)。 #> param( [Parameter(Mandatory = $true)][string]$Path, [AllowEmptyString()][string]$Text = '' ) $directory = Split-Path -Parent $Path if ($directory -and -not (Test-Path -LiteralPath $directory)) { New-Item -ItemType Directory -Path $directory -Force | Out-Null } $temp = "$Path.tmp" [System.IO.File]::WriteAllText($temp, $Text, $script:LogEncoding) Move-BaknretArchiveIntoPlace -TempPath $temp -DestinationPath $Path return $Path } function Save-BaknretSecuritySidecar { <# .SYNOPSIS 把采集结果写成 sidecar(`<归档名>.acl.json`)。 .DESCRIPTION 放在归档旁边而不是塞进归档里:7z 装不下它,塞进去又会污染 Slot 布局 (归档内顶层名是要与 manifest 的 roots/layouts 对账的)。 代价是它得跟归档一起搬,README 里已写明。 用 JSON 数组而不是"路径 -> SDDL"的对象:ConvertFrom-Json 出来的是 PSCustomObject,按深度排序还得自己摊平;数组直接有序。 #> param( [Parameter(Mandatory = $true)][string]$Path, [array]$Records = @(), [string]$Mode = 'Smart', [bool]$IncludeSacl = $false, [int]$Errors = 0, [int]$Scanned = 0 ) $projected = @() foreach ($record in @($Records)) { if (-not $record) { continue } $entry = [ordered]@{ p = [string]$record.p k = [string]$record.k } if ($record.s) { $entry.s = [string]$record.s } if ($record.o) { $entry.o = [string]$record.o } if ($record.g) { $entry.g = [string]$record.g } if ($record.e) { $entry.e = [string]$record.e } $projected += $entry } $payload = [ordered]@{ schemaVersion = 1 tool = 'BakNRet' capturedAt = (Get-Date).ToString('o') mode = $Mode includeSacl = [bool]$IncludeSacl objectCount = $projected.Count scannedCount = $Scanned errorCount = $Errors records = @($projected) } $json = $payload | ConvertTo-Json -Depth 5 return (Write-BaknretAtomicText -Path $Path -Text $json) } function Read-BaknretSecuritySidecar { <# .SYNOPSIS 读 sidecar;不存在或损坏时返回 $null(调用方据此打"该归档不含安全描述符"的告警)。 #> param([Parameter(Mandatory = $true)][string]$Path) if (-not (Test-Path -LiteralPath $Path)) { return $null } try { $raw = Get-Content -LiteralPath $Path -Raw -Encoding UTF8 -ErrorAction Stop if ([string]::IsNullOrWhiteSpace($raw)) { return $null } $parsed = $raw | ConvertFrom-Json -ErrorAction Stop $records = @() if (($parsed.PSObject.Properties.Name -contains 'records') -and $parsed.records) { $records = @($parsed.records) } return [pscustomobject]@{ CapturedAt = $parsed.capturedAt Mode = $parsed.mode IncludeSacl = [bool]$parsed.includeSacl ObjectCount = $parsed.objectCount ErrorCount = $parsed.errorCount Records = @($records) } } catch { Write-Log "安全描述符文件解析失败:$Path —— $($_.Exception.Message)" -Level WARN return $null } } function Convert-BaknretSidMap { <# .SYNOPSIS 按 SID 映射表改写 SDDL 里的 SID(跨机恢复用)。 .DESCRIPTION 只在**完整的 SID 记号**上替换:`S-1-5-21-1-2-3-1001` 是 `S-1-5-21-1-2-3-10012` 的前缀,直接 -replace 会改坏后者, 所以前后加边界断言(前面不能是数字或 -,后面不能是数字)。 #> param( [AllowEmptyString()][string]$Sddl, [hashtable]$SidMap = @{} ) $text = [string]$Sddl if (-not $text -or -not $SidMap -or $SidMap.Count -eq 0) { return $text } foreach ($old in @($SidMap.Keys)) { $newSid = [string]$SidMap[$old] $oldSid = [string]$old if ([string]::IsNullOrWhiteSpace($oldSid) -or [string]::IsNullOrWhiteSpace($newSid)) { continue } $pattern = '(? param( [Parameter(Mandatory = $true)]$Item, [Parameter(Mandatory = $true)][string]$Sddl, [ValidateSet('All', 'OwnerAndAccess', 'AccessOnly')][string]$Scope = 'All' ) $sections = [System.Security.AccessControl.AccessControlSections]::Access if ($Scope -ne 'AccessOnly') { if ($Sddl -match 'O:') { $sections = $sections -bor [System.Security.AccessControl.AccessControlSections]::Owner } if ($Scope -eq 'All' -and $Sddl -match 'G:') { $sections = $sections -bor [System.Security.AccessControl.AccessControlSections]::Group } } if ($Item.PSIsContainer) { $sd = New-Object System.Security.AccessControl.DirectorySecurity } else { $sd = New-Object System.Security.AccessControl.FileSecurity } $sd.SetSecurityDescriptorSddlForm($Sddl, $sections) if (-not $sd.AreAccessRulesProtected) { $sd.SetAccessRuleProtection($false, $false) } if ($PSVersionTable.PSEdition -eq 'Core') { [System.IO.FileSystemAclExtensions]::SetAccessControl($Item, $sd) } else { $Item.SetAccessControl($sd) } } function Restore-BaknretSecurity { <# .SYNOPSIS 把 sidecar 里属于某个归档项的那部分安全描述符,回放到真实目标路径上。 .DESCRIPTION 只处理 `p` 等于/位于 $ArchiveRoot 之下的记录(一项一棵子树,和其它恢复语义一致)。 顺序很重要:**按深度自顶向下**。父目录先写,子对象的继承才会收敛到原样; 反过来做会被父目录的继承覆盖掉。 原文件在归档里没解出来(被排除、或本来就缺失)时跳过,并计入 Skipped。 返回 [pscustomobject]@{ Total; Applied; OwnerFailed; Skipped; Failed; Failures }。 #> param( [Parameter(Mandatory = $true)]$Sidecar, [Parameter(Mandatory = $true)][string]$ArchiveRoot, [Parameter(Mandatory = $true)][string]$TargetPath, [hashtable]$SidMap = @{}, [switch]$WhatIf ) $result = [pscustomobject]@{ Total = 0 Applied = 0 OwnerFailed = 0 Skipped = 0 Failed = 0 Failures = @() } # 写属主需要 SeRestorePrivilege,而且必须**显式启用**:管理员的过滤令牌里它默认是 # disabled,Set-Acl / SetAccessControl 都不会替你打开。没有它,属主会写失败并静默 # 退化成"只恢复 DACL" —— 那恰恰丢掉了这个功能存在的理由(CREATOR OWNER 判给谁)。 Enable-BaknretPrivilege -Name @('SeRestorePrivilege', 'SeBackupPrivilege') | Out-Null if (-not $Sidecar -or -not $Sidecar.Records) { return $result } $root = ([string]$ArchiveRoot).Trim([char[]]@('\', '/')) if ([string]::IsNullOrWhiteSpace($root)) { return $result } $prefix = "$root\" $selected = @() foreach ($record in @($Sidecar.Records)) { if (-not $record) { continue } $key = [string]$record.p if ([string]::IsNullOrWhiteSpace($key)) { continue } $relative = $null if ($key -ieq $root) { $relative = '' } elseif ($key.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) { $relative = $key.Substring($prefix.Length) } else { continue } $selected += [pscustomobject]@{ Relative = $relative; Record = $record } } if ($selected.Count -eq 0) { return $result } $ordered = @($selected | Sort-Object -Property ` @{ Expression = { @(($_.Relative) -split '\\').Count } }, ` @{ Expression = { $_.Relative } }) foreach ($entry in $ordered) { $target = if ($entry.Relative) { Join-Path $TargetPath $entry.Relative } else { $TargetPath } $result.Total++ if ($entry.Record.e -or -not $entry.Record.s) { $result.Skipped++; continue } if (-not (Test-Path -LiteralPath $target)) { $result.Skipped++; continue } $item = Get-Item -LiteralPath $target -Force -ErrorAction SilentlyContinue if (-not $item) { $result.Skipped++; continue } if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { $result.Skipped++; continue } if ($WhatIf) { continue } $sddl = Convert-BaknretSidMap -Sddl ([string]$entry.Record.s) -SidMap $SidMap try { Set-BaknretObjectSecurity -Item $item -Sddl $sddl -Scope All $result.Applied++ } catch { $fullError = $_ try { Set-BaknretObjectSecurity -Item $item -Sddl $sddl -Scope OwnerAndAccess $result.OwnerFailed++ $result.Failures += ("{0}:属组未恢复,属主与 DACL 已恢复({1})" -f $target, $fullError.Exception.Message) } catch { try { Set-BaknretObjectSecurity -Item $item -Sddl $sddl -Scope AccessOnly $result.OwnerFailed++ $result.Failures += ("{0}:属主/属组未恢复({1}),已只恢复 DACL" -f $target, $_.Exception.Message) } catch { $result.Failed++ $result.Failures += ("{0}:{1}" -f $target, $_.Exception.Message) } } } } return $result } # ============================================================================ # 配置 # ============================================================================ function Get-BaknretConfig { <# .SYNOPSIS 读取 BackupConfig.psd1 并与内置默认值合并。 .DESCRIPTION 配置文件缺失不是错误:直接用默认值,让工具开箱可用。 #> param([string]$Path) $defaults = @{ BackupDir = 'Backups' LogDir = 'logs' SnapshotDir = 'Backups\snapshots' SoftwareCatalog = 'SoftwareCatalog.psd1' CatalogMaxDepth = 5 MinFreeSpaceGB = 8 VerifyArchive = $true ComputeHash = $false CompressionLevel = 9 ToolOutput = 'live' # live | quiet Snapshot = @{ Enabled = $false; KeepCount = 3; KeepDays = 30 } Encryption = @{ Enabled = $false; PasswordFile = ''; EncryptHeaders = $true } # 安全描述符(属主 / ACL)的采集与回放。 # Mode Off | Roots | Smart | Full(语义见 Get-BaknretSecurityRecords) # **默认 Full**:这个功能存在的意义就是不丢权限,正确性优先于体积; # Smart 是体积优化(靠继承复现的对象不落盘),已在真机上见过 # 它需要处理的"陈旧继承 ACE",判据偏保守,但终究是启发式。 # IncludeSacl 是否连审计规则(SACL)一起存取,需要 SeSecurityPrivilege # SidMap 跨机恢复时的 SID 映射:@('S-1-5-21-旧-1001' = 'S-1-5-21-新-1001') # FailOnError 安全描述符写盘失败时,是否把这条备份算作失败(默认只告警) Security = @{ Mode = 'Full' IncludeSacl = $false SidMap = @{} FailOnError = $false } DefaultExcludes = @() } if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { return $defaults } try { $loaded = Import-BaknretDataFile -Path $Path } catch { Write-Log "配置文件读取失败(改用默认值):$Path —— $_" -Level WARN return $defaults } foreach ($key in $loaded.Keys) { if ($key -in @('Snapshot', 'Encryption', 'Security') -and $loaded[$key] -is [hashtable]) { $merged = @{} foreach ($subKey in $defaults[$key].Keys) { $merged[$subKey] = $defaults[$key][$subKey] } foreach ($subKey in $loaded[$key].Keys) { $merged[$subKey] = $loaded[$key][$subKey] } $defaults[$key] = $merged } else { $defaults[$key] = $loaded[$key] } } return $defaults } function Get-BaknretPassword { <# .SYNOPSIS 取加密口令:命令行参数 > 环境变量 > 密码文件 > 交互式询问。 .DESCRIPTION 口令**绝不写入仓库**。优先级: 1. -Password(命令行传参,注意会短暂出现在进程列表里) 2. $env:BAKNRET_PASSWORD 3. PasswordFile 的首行(文件必须在仓库之外,脚本只记路径) 4. 交互式询问(仅当 allowPrompt 且当前是交互式会话) 全都拿不到就返回 $null,调用方必须失败退出,绝不能默默写明文归档。 交互式询问用的是 Read-Host -AsSecureString,输入不回显;但它需要真实控制台, 在计划任务/CI 里会把用户晾在那里等输入,所以只在交互式会话里才提示。 #> param( [string]$Password, [string]$PasswordFile, [switch]$AllowPrompt ) if ($Password) { return $Password } if ($env:BAKNRET_PASSWORD) { return $env:BAKNRET_PASSWORD } if ($PasswordFile -and (Test-Path -LiteralPath $PasswordFile)) { $line = Get-Content -LiteralPath $PasswordFile -TotalCount 1 -Encoding UTF8 -ErrorAction SilentlyContinue if ($line) { return $line.Trim() } } if ($AllowPrompt) { # 只有在真的会等人输入时才提示,避免计划任务里静默挂起 $interactive = $true try { $interactive = -not [System.Console]::IsInputRedirected } catch { $interactive = $false } if ($interactive) { Write-Log '需要加密口令,请在弹出的提示里输入(不会回显、不会落盘)' -Level WARN try { $secure = Read-Host -Prompt '请输入加密口令' -AsSecureString $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure) try { return [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) } finally { [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) } } catch { Write-Log "口令输入失败:$_" -Level ERROR return $null } } } return $null } Export-ModuleMember -Function @( 'Set-BaknretDebug', 'Start-BaknretLog', 'Stop-BaknretLog', 'Get-BaknretLogPath', 'Write-Log', 'Test-Administrator', 'Get-BaknretFreeSpaceGB', 'ConvertTo-NativeArgumentString', 'Invoke-ExternalCommand', 'Resolve-CompressionTool', 'Get-Optimized7zArgument', 'Split-BaknretToken', 'Remove-BaknretQuote', 'Test-BaknretMarker', 'ConvertFrom-BaknretPatternList', 'ConvertFrom-BackupListLine', 'Test-LiteralPath', 'Get-BaknretRegexExclude', 'Get-BaknretExcludeArgument', 'Split-BaknretPatternScope', 'Merge-BaknretExcludeArgument', 'Resolve-CatalogPath', 'Get-SoftwareCatalog', 'Find-ChildDirectoryByName', 'Format-CatalogName', 'Expand-CatalogPathText', 'Get-ArchiveTopLevelNames', 'Get-BaknretArchiveTopName', 'New-BaknretArchiveItem', 'New-BaknretJunction', 'Remove-BaknretJunction', 'New-BaknretArchiveStaging', 'Remove-BaknretArchiveStaging', 'Get-ItemArchiveName', 'Resolve-BackupEntry', 'Write-BackupEntryPlan', 'Get-BackupBaseName', 'Convert-BackupFileNameToPath', 'Get-FolderSummary', 'Read-BaknretManifest', 'Write-BaknretManifest', 'Sync-BaknretManifestArchive', 'Move-BaknretArchiveIntoPlace', 'Enable-BaknretPrivilege', 'ConvertTo-BaknretWildcardPattern', 'Test-BaknretPathExcluded', 'Get-BaknretAceSignatureList', 'Get-BaknretSecuritySddlWithStale', 'Get-BaknretSecurityRecord', 'Test-BaknretSecurityRecordNeeded', 'Get-BaknretSecurityRecords', 'Write-BaknretAtomicText', 'Save-BaknretSecuritySidecar', 'Read-BaknretSecuritySidecar', 'Convert-BaknretSidMap', 'Set-BaknretObjectSecurity', 'Restore-BaknretSecurity', 'Get-BaknretConfig', 'Get-BaknretPassword' )