<# .SYNOPSIS BakNRet —— 备份 / 恢复脚本的公共功能模块。 .DESCRIPTION 提供日志(控制台 + 落盘)、外部命令调用(可取得真实退出码)、 BackupList.txt 语法解析、归档命名与逆向解析、目录摘要、manifest 读写、 磁盘剩余空间查询等公共能力。 兼容 Windows PowerShell 5.1 与 PowerShell 7.x: * 不使用 ?? / 三元运算符 / Join-String / -AsHashtable 等 6.0+ 语法; * 不使用 ProcessStartInfo.ArgumentList(5.1 上不存在),改为自行构造命令行。 模块内出现的备份清单语法(BackupList.txt 每一行): <路径> [ :: <排除模式>[,<排除模式>...] ] [ @<标记>[,<标记>...] ] 路径可以用双引号包起来(引号只包路径)。分隔符统一以 `::` 为界, 因为 `:` 在 Windows 路径里只可能作为盘符出现,`::` 不可能出现在真实路径中。 排除模式分隔符同时接受 `,` 和 `;`(历史文件两种都出现过)。 #> $script:LogConfig = @{ TimeFormat = 'yyyy-MM-dd HH:mm:ss' EnableDebug = $false FilePath = $null } $script:LogEncoding = [System.Text.UTF8Encoding]::new($false) # ============================================================================ # 日志 # ============================================================================ 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 压缩参数(字典大小、线程数、快速字节数)。 #> param( [Parameter(Mandatory = $true)][string]$SourcePath, [int]$Level = 9 ) $item = Get-Item -LiteralPath $SourcePath -ErrorAction Stop $totalSize = 0 $fileCount = 0 if ($item.PSIsContainer) { $files = Get-ChildItem -LiteralPath $SourcePath -File -Recurse -ErrorAction SilentlyContinue $fileCount = @($files).Count $totalSize = ($files | Measure-Object -Property Length -Sum).Sum } else { $fileCount = 1 $totalSize = $item.Length } if ($null -eq $totalSize) { $totalSize = 0 } $totalSizeMB = [math]::Round($totalSize / 1MB, 2) Write-Log ("分析路径 '{0}':{1} 个文件,总大小 {2} MB" -f $SourcePath, $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-TrailingFlags { <# .SYNOPSIS 从文本尾部摘出 `@标记`,返回剩余文本与标记数组。 .DESCRIPTION 只有在行首或空白之后的 `@token` 才算标记,避免误伤路径里本来就带 @ 的目录名。 标记可以连续出现(`@a @b`),也可以写成 `@a,b`。 #> param([AllowEmptyString()][string]$Text) $flags = @() $remainder = ([string]$Text).Trim() while ($remainder -match '(?:^|\s)@([^\s]+)\s*$') { $token = $matches[1] $flags = @($token -split '[,;]' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + $flags $remainder = $remainder.Substring(0, $remainder.Length - $matches[0].Length).Trim() } return [pscustomobject]@{ Remainder = $remainder; Flags = $flags } } function ConvertFrom-BackupListLine { <# .SYNOPSIS 解析 BackupList.txt 的一行。 .DESCRIPTION 返回 $null 表示注释 / 空行。正常返回包含: Path —— 未展开环境变量的原始路径(归档命名依赖它保持可移植) ExcludePatterns —— 排除模式数组 Flags —— @ 标记数组(如 encrypt) Raw —— 原始行 与旧实现的区别(旧写法在这些地方静默出错,导致排除规则从未生效): 1. 先按第一个 `::` 切开,再处理引号。旧实现用 ^"([^"]+)"\s*(.*)$ 贪婪匹配, `"路径 :: 排除表"` 这种整行加引号的写法会把排除表吞进路径里。 2. 排除模式分隔符同时接受 `,` 与 `;`;旧解析器只认 `;`,而 BackupList.txt 里写的是 `,`,于是整串被当成一个模式,等于没有排除。 #> 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 } } # 行内记号(都用到 `:`,因为 `:` 在 Windows 路径里只可能是盘符, # 而 `::` `:+` `:-` 都不可能出现在真实路径里,所以切分不受引号位置影响): # :: 排除模式(历史写法,等价于 :-) # :+ 追加一个目录(等价于名录里的 Dirs 数组) # :- 排除模式 # 记号可以出现多次、顺序任意:`Foo :+ D:\a D:\b :- logs\ !*Cache` # 第一段(第一个记号之前)是主路径/软件名。 $segments = [System.Collections.Generic.List[object]]::new() $cursor = 0 $currentKind = 'main' $currentText = '' $contentLength = $content.Length while ($cursor -lt $contentLength) { $colonIndex = $content.IndexOf(':', $cursor) if ($colonIndex -lt 0) { $currentText += $content.Substring($cursor) break } $currentText += $content.Substring($cursor, $colonIndex - $cursor) # 记号必须是 `:` 后紧跟 `:` `+` `-` 之一 if ($colonIndex + 1 -lt $contentLength -and $content[$colonIndex + 1] -in @(':', '+', '-')) { $kind = switch ($content[$colonIndex + 1]) { '+' { 'add' } '-' { 'exclude' } default { 'exclude' } # `::` 等同排除 } $segments.Add([pscustomobject]@{ Kind = $currentKind; Text = $currentText.Trim() }) $currentKind = $kind $currentText = '' $cursor = $colonIndex + 2 } else { # 单个 `:`(盘符)属于内容 $currentText += ':' $cursor = $colonIndex + 1 } } $segments.Add([pscustomobject]@{ Kind = $currentKind; Text = $currentText.Trim() }) $pathPart = '' $addedPaths = @() $excludes = @() $flags = @() # 标记(@encrypt 等)可能挂在任意段的末尾,所以每一段都先摘标记: # 历史写法 `C:\x :: a,b @encrypt` 里标记就是跟在排除表后面的。 foreach ($segment in $segments) { if ([string]::IsNullOrWhiteSpace($segment.Text)) { continue } $split = Split-TrailingFlags -Text $segment.Text if ($split.Flags.Count -gt 0) { $flags = @($flags) + @($split.Flags) } $body = $split.Remainder if ([string]::IsNullOrWhiteSpace($body)) { continue } switch ($segment.Kind) { 'main' { $pathPart = $body } 'add' { $addedPaths += $body } 'exclude' { $excludes += @($body -split '[,;]' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) } } } # 引号只应包住路径。整行被一对引号包住时(历史写法), # 切分后主路径这半只剩开引号、闭引号留在了尾段,因此两侧各剥一次,不要求成对。 $pathPart = $pathPart.Trim() if ($pathPart.StartsWith('"')) { $pathPart = $pathPart.Substring(1) } if ($pathPart.EndsWith('"')) { $pathPart = $pathPart.Substring(0, $pathPart.Length - 1) } $pathPart = $pathPart.Trim() if ([string]::IsNullOrEmpty($pathPart)) { return $null } # 尾段可能残留闭引号(历史 `"路径 :: 排除表"` 写法) $excludes = @($excludes | ForEach-Object { $_.TrimEnd('"').Trim() } | Where-Object { $_ }) return [pscustomobject]@{ Path = $pathPart # 目录名或文件名,需要靠 SoftwareCatalog 换成真实路径; # 带分隔符或 %变量% 的写法按字面路径处理(并给出警告)。 IsName = (-not (Test-LiteralPath -Path $pathPart)) AddedPaths = $addedPaths ExcludePatterns = $excludes Flags = $flags 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-ArchiveExcludeArgument { <# .SYNOPSIS 把清单里的排除模式翻译成 7z 的 -x 参数。 .DESCRIPTION 7z 排除语义(已实测确认): * `-x!<完整归档内路径>` 匹配对象的完整路径,且**包含归档根目录名** (源是 C:\Programs\Foo 时,归档里的路径是 Foo\...),所以必须加前缀; * 模式里**不能出现空格**——`-x!root\Code Cache` 匹配不到任何东西, 正确的写法是 `-xr!Code?Cache` 或 `-xr!*Cache`。因此这里把模式里的 空格自动换成 `?`(单字符通配符,恰好对应一个空格); * 模式里**不能手工加引号**——旧实现写成 -x!"路径",引号会成为模式的 一部分导致永不匹配; * 以 `!` 开头的模式按"任意层级下的组件名"处理,翻译成 `-xr!`。 #> param( [Parameter(Mandatory = $true)][string]$ItemName, [string[]]$Patterns = @() ) $result = @() foreach ($pattern in $Patterns) { if ([string]::IsNullOrWhiteSpace($pattern)) { continue } if ($pattern.StartsWith('!')) { $component = $pattern.Substring(1).Trim() if (-not $component) { continue } $component = $component -replace ' ', '?' $result += "-xr!$component" continue } $full = $pattern.Trim().Trim([char[]]@('\', '/')).TrimEnd([char[]]@('\', '/')) if (-not $full) { continue } if (-not $full.StartsWith("$ItemName\", [System.StringComparison]::OrdinalIgnoreCase)) { $full = "$ItemName\$full" } $full = $full -replace ' ', '?' $result += "-x!$full" } # 必须用逗号包一层:只有一个元素时 PowerShell 会把数组拆成标量, # 调用方拿到的就是字符串而不是数组(`$x[0]` 会变成首字符 "-")。 return ,$result } # ============================================================================ # 软件名录(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 Get-SoftwareCatalog { <# .SYNOPSIS 载入"软件名 -> 目录"名录。 .DESCRIPTION 返回按名字索引的哈希表,每项是 @{ Name; Path; ResolvedPath; Kind; Raw }。 Kind 取值:Single(一个目录)| Variant(有 variants 的同名目录)| Unresolved(没找到目录)。 名录文件可以是 .psd1 或 .json——默认用 .psd1,因为路径这种东西很需要写注释。 .psd1 里可以用 `Includes` 键引入其它名录文件,多个游戏/多个盘的目录可以分文件维护。 #> param( [Parameter(Mandatory = $true)][string]$Path, [int]$MaxDepth = 5 ) $result = @{} if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { return $result } $data = $null try { if ($Path.ToLower().EndsWith('.json')) { $data = Get-Content -LiteralPath $Path -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop } else { $data = Import-PowerShellDataFile -LiteralPath $Path -ErrorAction Stop } } catch { Write-Log "软件名录读取失败:$Path —— $_" -Level ERROR return $result } # 递归引入其它名录文件 if ($data -is [System.Collections.IDictionary] -and $data.Contains('Includes')) { $includeList = @($data['Includes']) $baseDir = Split-Path -Parent $Path foreach ($include in $includeList) { 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] } } } # 顶层除 Includes 外的每个键都是一个软件名 $keys = @() if ($data -is [System.Collections.IDictionary]) { $keys = @($data.Keys | Where-Object { $_ -ne 'Includes' }) } else { $keys = @($data.PSObject.Properties.Name | Where-Object { $_ -ne 'Includes' }) } foreach ($key in $keys) { $name = Format-CatalogName -Name ([string]$key) if (-not $name) { continue } $entry = if ($data -is [System.Collections.IDictionary]) { $data[$key] } else { $data.$key } # 一个软件可以对应**多个目录**,写成数组;数组元素两种都认: # * 对象(推荐):@{ Path = '<目录>'; Description = '<这个目录是干什么的>' } # * 纯字符串: '<目录>' # 也兼容字典写法:@{ Dirs = @(...) } / @{ Variants = @(...) } / @{ Path = '<目录>' } $rawPath = $null $candidates = @() $entryDescription = $null if ($entry -is [System.Collections.IDictionary]) { foreach ($key in 'Description', 'Note', 'Desc') { if ($entry.Contains($key)) { $entryDescription = [string]$entry[$key]; break } } if ($entry.Contains('Dirs')) { $candidates = @($entry['Dirs']) } elseif ($entry.Contains('Variants')) { $candidates = @($entry['Variants']) } if ($entry.Contains('Path')) { $rawPath = [string]$entry['Path'] } } elseif ($entry -is [string]) { $rawPath = $entry } elseif ($entry -is [System.Collections.IEnumerable]) { $candidates = @($entry) } elseif ($null -ne $entry) { $rawPath = [string]$entry } # 单目录写法:包成对象,好让"目录说明"跟目录一起走下去 if ($candidates.Count -eq 0 -and $rawPath) { $candidates = @([pscustomobject]@{ Path = $rawPath; Description = $entryDescription }) } if ($candidates.Count -eq 0) { continue } # 逐个候选目录解析。**声明了几个就记几个**,找不到的也留着: # 备份端按存在性跳过它们,恢复端要靠它们把内容还原回原位。 $items = @() foreach ($candidate in $candidates) { $candidatePath = $null $candidateDescription = $null if ($candidate -is [string]) { $candidatePath = $candidate } elseif ($candidate -is [System.Collections.IDictionary]) { foreach ($key in 'Path', 'Dir', 'Directory') { if ($candidate.Contains($key)) { $candidatePath = [string]$candidate[$key]; break } } foreach ($key in 'Description', 'Note', 'Desc', 'Reason', 'Why') { if ($candidate.Contains($key)) { $candidateDescription = [string]$candidate[$key]; break } } } elseif ($null -ne $candidate) { # JSON 里是对象、.psd1 里一般是哈希表,两种都认 $names = @($candidate.PSObject.Properties.Name) foreach ($key in 'Path', 'Dir', 'Directory') { if ($names -contains $key) { $candidatePath = [string]$candidate.$key; break } } foreach ($key in 'Description', 'Note', 'Desc', 'Reason', 'Why') { if ($names -contains $key) { $candidateDescription = [string]$candidate.$key; break } } } if ([string]::IsNullOrWhiteSpace($candidatePath)) { continue } $declared = ([Environment]::ExpandEnvironmentVariables([string]$candidatePath)).Trim() if (-not $declared) { continue } if (Test-Path -LiteralPath $declared) { $items += [pscustomobject]@{ Declared = $declared; Resolved = $declared; Exists = $true; Suffixed = $false; Description = $candidateDescription } continue } # 名录里写的是父目录,实际目录带版本号之类后缀(如 legendary 的 _2.0.4) $parent = Split-Path -Path $declared -Parent $leaf = Split-Path -Path $declared -Leaf $found = $null if ($parent -and $leaf -and (Test-Path -LiteralPath $parent)) { $found = @(Find-ChildDirectoryByName -Parent $parent -Name $leaf -MaxDepth $MaxDepth) } if ($found -and $found.Count -gt 0) { foreach ($match in $found) { $items += [pscustomobject]@{ Declared = $declared; Resolved = $match; Exists = $true; Suffixed = $true; Description = $candidateDescription } } Write-Log "名录:$name 的 $declared -> $($found -join '、')(按前缀补全)" -Level DEBUG } else { # 找不到也留着:恢复时这正是"要把数据放回去"的那个位置 $items += [pscustomobject]@{ Declared = $declared; Resolved = $declared; Exists = $false; Suffixed = $false; Description = $candidateDescription } } } if ($items.Count -eq 0) { continue } $existing = @($items | Where-Object { $_.Exists } | ForEach-Object { $_.Resolved }) $missing = @($items | Where-Object { -not $_.Exists } | ForEach-Object { $_.Declared }) $declaredList = @($items | ForEach-Object { $_.Declared }) $kind = if ($existing.Count -eq 0) { 'Unresolved' } elseif ($missing.Count -gt 0) { 'Partial' } elseif ($existing.Count -gt 1) { 'Multi' } else { 'Single' } if ($missing.Count -gt 0) { # 多目录条目里少了一个目录值得告警(整包少了一块); # 单目录条目少目录是常规情况(软件没装),备份端会明确说"跳过: X,源路径不存在", # 这里降成 DEBUG,免得每个条目都刷两遍同样的警告。 $missingText = "名录:{0} 有 {1} 个目录找不到:{2}" -f $name, $missing.Count, ($missing -join ';') if ($items.Count -gt 1) { Write-Log $missingText -Level WARN } else { Write-Log $missingText -Level DEBUG } } Write-Log ("名录:{0} -> 声明 {1} 个目录,其中存在 {2} 个" -f $name, $items.Count, $existing.Count) -Level DEBUG $result[$name] = [pscustomobject]@{ Name = $name # Path 保留"名录里写的那个字符串"。数组写法没有唯一字符串,取第一个候选项。 Path = $(if ($rawPath) { $rawPath } else { $declaredList[0] }) ResolvedPath = $(if ($existing.Count -gt 0) { $existing[0] } else { $missing[0] }) Variants = $existing Dirs = $existing Declared = $declaredList Items = $items Missing = $missing Description = $entryDescription Kind = $kind Raw = $entry Suffixed = [bool](@($items | Where-Object { $_.Suffixed }).Count) } } 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 New-BackupSourceItem { <# .SYNOPSIS 把一条路径整理成"要打包的一个源",并带上给人看的说明。 .DESCRIPTION 返回 @{ RootName; ParentDir; RelativePaths; SourcePath; Description; Origin }, 拆不出父目录或末级名时返回 $null(相对路径、盘符根目录之类)。 归档里的布局是"以 ParentDir 为工作目录、把 RelativePaths 加进去", 所以 RelativePaths 既是**文件系统上的名字**,也是**归档里的顶层名字**。 7z 命令行没有"入库时改名"的能力,这两个名字只能是同一个 —— 因此 Resolve-BackupEntry 会拦下"同一条目里两个同名目录"的情况。 Origin 说明这个源是怎么来的(catalog / path / append-catalog / append-path), 运行时会打印出来,方便回答"这个目录为什么会被备份"。 #> param( [string]$Path, $RootName = $null, $Description = $null, [string]$Origin = 'catalog' ) if ([string]::IsNullOrWhiteSpace($Path)) { return $null } $parent = Split-Path -Path $Path -Parent $leaf = Split-Path -Path $Path -Leaf if (-not $parent -or -not $leaf) { return $null } return [pscustomobject]@{ RootName = $RootName ParentDir = $parent RelativePaths = @($leaf) SourcePath = $Path Description = $Description Origin = $Origin } } function Resolve-BackupEntry { <# .SYNOPSIS 把清单条目解析成"实际要备份什么"。 .DESCRIPTION 返回 @{ IsName; BaseName; Sources; Source; ArchiveFlavor },其中: * IsName —— 这一行写的是软件名还是字面路径 * BaseName —— 归档基础名 * Sources —— 要备份的项目列表(一个根目录名 -> 该根目录下的一组相对路径) * ArchiveFlavor —— 'name'(归档根目录叫软件名)或 'path'(叫源目录名) 归档内部布局: * 软件名条目 -> 根目录用软件名,内容为 `<源目录名>\...` (这样恢复时能知道文件原来属于哪个目录) * 字面路径条目 -> 整条目直接写进归档,保持与历史归档完全一致的布局, 否则现有归档一旦被重打,恢复就会失败。 #> param( $Entry, [string]$CatalogPath, [int]$MaxDepth = 5 ) $isName = -not (Test-LiteralPath -Path $Entry.Path) # @pathname 强制按"字面路径条目"处理:归档名用路径算法, # 但清单里写的是软件名,真实路径仍要经名录解析。 $forcePathFlavor = ($Entry.Flags -contains 'pathname') $baseName = Get-ItemArchiveName -Entry $Entry -CatalogPath $CatalogPath -MaxDepth $MaxDepth $rootNames = @($Entry.Flags | Where-Object { $_ -like 'root=*' } | ForEach-Object { $_.Substring(5) }) # 必须在下面任何一个分支之前算出来:名录里没有这个名字时也要用它, # 否则会读到调用方作用域里残留的 $rootName(PowerShell 是动态作用域)。 $rootName = if ($rootNames.Count -gt 0) { $rootNames[0] } else { $baseName } $catalogEntry = $null $sources = @() $archiveFlavor = 'name' $errorText = $null if (-not $isName) { # ---- 写法二:用户手写的目录 / 文件(含 \ / 或 % 就按路径处理)---- $archiveFlavor = 'path' $source = New-BackupSourceItem -Path ([Environment]::ExpandEnvironmentVariables($Entry.Path)) -Origin 'path' if ($source) { $sources += $source } } else { # ---- 写法一:软件名录里的软件名 ---- $catalog = Get-SoftwareCatalog -Path $CatalogPath -MaxDepth $MaxDepth if (-not $catalog.ContainsKey($Entry.Path)) { $errorText = "软件名录里没有 '$($Entry.Path)'" } else { $catalogEntry = $catalog[$Entry.Path] # 单目录条目的 RootName 沿用软件名(历史行为);多目录留空 $catalogRootName = if ($catalogEntry.Kind -eq 'Single') { $rootName } else { $null } if ($forcePathFlavor) { # @pathname:归档名走路径算法,包内布局按源目录名 $archiveFlavor = 'path' $source = New-BackupSourceItem -Path $catalogEntry.ResolvedPath -Origin 'catalog' if ($source) { $sources += $source } if ($catalogEntry.Kind -eq 'Unresolved') { $errorText = "名录里的路径不存在:$($catalogEntry.Path)" } } else { # 名录里声明了几个目录就产出几个源 —— **当前不存在的那几个也要留着**: # 恢复时正是要靠它们把内容还原回原位;备份端按存在性自己跳过。 foreach ($item in $catalogEntry.Items) { $source = New-BackupSourceItem -Path $item.Resolved -RootName $catalogRootName ` -Description $item.Description -Origin 'catalog' if ($source) { $sources += $source } } if ($catalogEntry.Kind -eq 'Unresolved') { $errorText = "名录里的路径不存在:$($catalogEntry.Path)" } elseif ($catalogEntry.Kind -eq 'Partial') { $errorText = ("名录里有 {0} 个目录当前不存在,备份会跳过它们:{1}" -f ` $catalogEntry.Missing.Count, ($catalogEntry.Missing -join ';')) } } } } # ------------------------------------------------------------------ # 追加段:`:+ <路径 或 软件名>`,**两种写法都生效** # 以前这一节只接在"软件名且能解析出目录"的那条路径后面,手写路径的 :+ 会被整段丢掉。 # ------------------------------------------------------------------ foreach ($added in @($Entry.AddedPaths)) { if ([string]::IsNullOrWhiteSpace([string]$added)) { continue } $addedText = ([string]$added).Trim() $addedPath = [Environment]::ExpandEnvironmentVariables($addedText) if (-not (Test-LiteralPath -Path $addedPath)) { # 写得像软件名:按名录展开成它的全部目录 $addedCatalog = Get-SoftwareCatalog -Path $CatalogPath -MaxDepth $MaxDepth if ($addedCatalog.ContainsKey($addedPath)) { foreach ($item in $addedCatalog[$addedPath].Items) { $source = New-BackupSourceItem -Path $item.Resolved -Description $item.Description -Origin 'append-catalog' if ($source) { $sources += $source } } continue } Write-Log "追加项 '$addedText' 既不是字面路径,也不在软件名录里,已忽略" -Level WARN continue } $source = New-BackupSourceItem -Path $addedPath -Origin 'append-path' if ($source) { $sources += $source } else { Write-Log "追加项无法拆出父目录与末级名,已忽略:$addedText" -Level WARN } } # ------------------------------------------------------------------ # 归档内顶层同名冲突拦截 # 7z 加进来的路径,在归档里就是**文件系统上的那个名字**(命令行没有"入库改名"的能力)。 # 所以同一个条目里出现两个同名目录(例如两个 persist)时,它们在包内会混成一棵树, # 解出来两边的内容都是错的。宁可明确报错,也不要静默搅在一起。 # ------------------------------------------------------------------ $seenTop = @{} $collisions = @() foreach ($source in $sources) { $top = @($source.RelativePaths)[0] if (-not $top) { continue } if ($seenTop.ContainsKey($top)) { $collisions += ("'{0}'({1} 与 {2})" -f $top, $seenTop[$top], $source.SourcePath) } else { $seenTop[$top] = $source.SourcePath } } $blocking = $null if ($collisions.Count -gt 0) { $blocking = ("归档内顶层同名,无法区分:{0}。7z 不能把同一个源在包内改名,它们会在归档里混成一棵树;" + "请把它们拆成两个独立条目(各自一个归档)。") -f ($collisions -join ';') } return [pscustomobject]@{ IsName = [bool]$isName CatalogEntry = $catalogEntry BaseName = $baseName ArchiveFlavor = $archiveFlavor RootName = $(if ($isName -and -not $forcePathFlavor) { $rootName } else { $null }) Sources = @($sources) Source = $Entry.Path Error = $errorText Blocking = $blocking } } function Write-BackupEntryPlan { <# .SYNOPSIS 在动手打包之前,把"这个条目会打包哪些目录、排除了什么、为什么"打印出来。 .DESCRIPTION 目录说明来自 SoftwareCatalog;排除 / 追加的**来源**来自清单,逐项打印: * 每个目录一行:路径、它是怎么来的(名录 / 手写路径 / :+ 追加)、 当前在不在、以及这个目录是干什么的(Description); * 排除模式按来源分组打印:清单的 :- 段、BackupConfig.psd1 的 DefaultExcludes; * 清单行尾的 `# 说明` 作为这条目的整体说明打印出来。 #> param( [Parameter(Mandatory = $true)]$Resolved, [Parameter(Mandatory = $true)][string]$DisplayPath, [string[]]$ListExcludes = @(), [string[]]$ConfigExcludes = @(), [string]$Comment ) $originText = @{ 'catalog' = '软件名录' 'path' = '手写路径' 'append-catalog' = '清单 :+ 追加(按软件名录展开)' 'append-path' = '清单 :+ 追加(字面路径)' } Write-Log ("条目:{0}" -f $DisplayPath) Write-Log (" 归档:{0}" -f $Resolved.BaseName) if ($Comment) { Write-Log (" 说明:{0}" -f $Comment) } if ($Resolved.Error) { Write-Log (" 提示:{0}" -f $Resolved.Error) -Level WARN } $sources = @($Resolved.Sources) if ($sources.Count -eq 0) { Write-Log ' 目录:没有解析出任何目录' -Level WARN } for ($index = 0; $index -lt $sources.Count; $index++) { $source = $sources[$index] $exists = Test-Path -LiteralPath $source.SourcePath $origin = if ($source.Origin -and $originText.ContainsKey($source.Origin)) { $originText[$source.Origin] } else { $source.Origin } Write-Log (" 目录 {0}/{1}:{2}" -f ($index + 1), $sources.Count, $source.SourcePath) Write-Log (" 来源:{0};{1}" -f $origin, $(if ($exists) { '存在,会打包' } else { '当前不存在,本次跳过' })) if ($source.Description) { Write-Log (" 介绍:{0}" -f $source.Description) } } if ($ListExcludes.Count -gt 0) { Write-Log (" 排除 {0} 条(来自清单的 :- 段):{1}" -f $ListExcludes.Count, ($ListExcludes -join '、')) } if ($ConfigExcludes.Count -gt 0) { Write-Log (" 排除 {0} 条(来自 BackupConfig.psd1 的 DefaultExcludes):{1}" -f $ConfigExcludes.Count, ($ConfigExcludes -join '、')) } if ($ListExcludes.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 } # ============================================================================ # 配置 # ============================================================================ 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 } DefaultExcludes = @() } if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { return $defaults } try { $loaded = Import-PowerShellDataFile -LiteralPath $Path -ErrorAction Stop } catch { Write-Log "配置文件读取失败(改用默认值):$Path —— $_" -Level WARN return $defaults } foreach ($key in $loaded.Keys) { if ($key -in @('Snapshot', 'Encryption') -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', 'ConvertFrom-BackupListLine', 'Get-ArchiveExcludeArgument', 'Test-LiteralPath', 'Resolve-CatalogPath', 'Get-SoftwareCatalog', 'Find-ChildDirectoryByName', 'Format-CatalogName', 'Get-ArchiveTopLevelNames', 'Get-ItemArchiveName', 'Resolve-BackupEntry', 'Write-BackupEntryPlan', 'Get-BackupBaseName', 'Convert-BackupFileNameToPath', 'Get-FolderSummary', 'Read-BaknretManifest', 'Write-BaknretManifest', 'Sync-BaknretManifestArchive', 'Move-BaknretArchiveIntoPlace', 'Get-BaknretConfig', 'Get-BaknretPassword' )