<# .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 } # `:` 在 Windows 路径里只可能是盘符,`::` 不可能出现在真实路径中, # 因此可以安全地按第一个 `::` 切分,不受引号位置影响。 $separatorIndex = $content.IndexOf('::') if ($separatorIndex -ge 0) { $pathPart = $content.Substring(0, $separatorIndex) $tailPart = $content.Substring($separatorIndex + 2) } else { $pathPart = $content $tailPart = '' } # 引号只应包住路径。整行被一对引号包住时(历史写法), # 上面的切分已经把排除表摘出去了,此时路径这半只剩开引号、 # 闭引号留在了 tail 末尾,因此两侧各剥一次,不要求成对。 $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 } $tailPart = $tailPart.Trim() if ($tailPart.EndsWith('"')) { $tailPart = $tailPart.Substring(0, $tailPart.Length - 1).Trim() } # 从尾部摘出 @标记。没有 `::` 时标记直接跟在路径后面 # (如 `%UserProfile%\.ssh @encrypt`),因此 tail 为空时还要从路径那半再摘一次。 # 标记可能出现在任一侧,所以两边都要摘。 $tailSplit = Split-TrailingFlags -Text $tailPart $flags = @($tailSplit.Flags) $tailPart = $tailSplit.Remainder $pathSplit = Split-TrailingFlags -Text $pathPart if ($pathSplit.Flags.Count -gt 0) { $flags = @($pathSplit.Flags) + $flags $pathPart = $pathSplit.Remainder } $excludes = @() if ($tailPart) { $excludes = @($tailPart -split '[,;]' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) } return [pscustomobject]@{ Path = $pathPart # 目录名或文件名,需要靠 SoftwareCatalog 换成真实路径; # 带分隔符或 %变量% 的写法按字面路径处理(并给出警告)。 IsName = (-not (Test-LiteralPath -Path $pathPart)) ExcludePatterns = $excludes Flags = $flags 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 } $rawPath = $null $variants = $null if ($entry -is [System.Collections.IDictionary]) { if ($entry.Contains('Path')) { $rawPath = [string]$entry['Path'] } if ($entry.Contains('Variants')) { $variants = @($entry['Variants']) } } else { $rawPath = [string]$entry } if (-not $rawPath) { continue } $resolved = [Environment]::ExpandEnvironmentVariables($rawPath) if ($variants -and $variants.Count -gt 0) { # 同名目录出现在多个位置:不猜,所有位置都作为归档根目录。 $resolvedVariants = @($variants | ForEach-Object { [Environment]::ExpandEnvironmentVariables([string]$_) }) $result[$name] = [pscustomobject]@{ Name = $name Path = $rawPath ResolvedPath = $resolved Variants = $resolvedVariants Kind = 'Variant' Raw = $entry } Write-Log "名录:$name 有 $($resolvedVariants.Count) 个候选位置" -Level DEBUG continue } if (Test-Path -LiteralPath $resolved) { $kind = 'Single' } else { # 名录里写的是父目录,实际目录带版本号之类后缀(如 legendary 的 _2.0.4) $parent = Split-Path -Path $resolved -Parent $leaf = Split-Path -Path $resolved -Leaf $found = $null if ($parent -and (Test-Path -LiteralPath $parent)) { $found = @(Find-ChildDirectoryByName -Parent $parent -Name $leaf -MaxDepth $MaxDepth) if ($found.Count -gt 1) { $kind = 'Variant' } elseif ($found.Count -eq 1) { $kind = 'Single' } else { $kind = 'Unresolved' } } else { $kind = 'Unresolved' } if ($found -and $found.Count -gt 0) { $resolved = $found[0] $result[$name] = [pscustomobject]@{ Name = $name Path = $rawPath ResolvedPath = $resolved Variants = @($found) Kind = $kind Raw = $entry Suffixed = $true } Write-Log "名录:$name -> $resolved(按前缀补全)" -Level DEBUG continue } } $result[$name] = [pscustomobject]@{ Name = $name Path = $rawPath ResolvedPath = $resolved Variants = @() Kind = $kind Raw = $entry } } return $result } 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 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) }) if (-not $isName) { $sourcePath = [Environment]::ExpandEnvironmentVariables($Entry.Path) return [pscustomobject]@{ IsName = $false CatalogEntry = $null BaseName = $baseName ArchiveFlavor = 'path' RootName = $null Sources = @([pscustomobject]@{ RootName = $null; ParentDir = (Split-Path -Path $sourcePath -Parent); RelativePaths = @((Split-Path -Path $sourcePath -Leaf)); SourcePath = $sourcePath }) Source = $Entry.Path } } $catalog = Get-SoftwareCatalog -Path $CatalogPath -MaxDepth $MaxDepth if (-not $catalog.ContainsKey($Entry.Path)) { return [pscustomobject]@{ IsName = $true CatalogEntry = $null BaseName = $baseName ArchiveFlavor = 'name' RootName = $rootName Sources = @() Source = $Entry.Path Error = "软件名录里没有 '$($Entry.Path)'" } } $catalogEntry = $catalog[$Entry.Path] $rootName = if ($rootNames.Count -gt 0) { $rootNames[0] } else { $baseName } # @pathname 必须排在 Unresolved 分支之前:否则路径不存在的条目会先被 # 当作普通软件名条目返回 Flavor='name',把 @pathname 覆盖悄悄吃掉。 if ($forcePathFlavor) { $flavorPath = $catalogEntry.ResolvedPath $flavorParent = Split-Path -Path $flavorPath -Parent $flavorLeaf = Split-Path -Path $flavorPath -Leaf $flavorSource = @() if ($flavorParent -and $flavorLeaf) { $flavorSource = @([pscustomobject]@{ RootName = $null ParentDir = $flavorParent RelativePaths = @($flavorLeaf) SourcePath = $flavorPath }) } return [pscustomobject]@{ IsName = $true CatalogEntry = $catalogEntry BaseName = $baseName ArchiveFlavor = 'path' RootName = $null Sources = $flavorSource Source = $Entry.Path Error = $(if ($catalogEntry.Kind -eq 'Unresolved') { "名录里的路径不存在:$($catalogEntry.Path)" } else { $null }) } } # 名录里的路径当前不存在。备份时这是"跳过",但**恢复时这正是要恢复的场景**, # 所以 SourcePath 仍然给出来(恢复端会照它把内容还原回原位), # 只把 Error 标出来让备份端跳过。 if ($catalogEntry.Kind -eq 'Unresolved') { $missingPath = $catalogEntry.ResolvedPath $missingParent = Split-Path -Path $missingPath -Parent $missingLeaf = Split-Path -Path $missingPath -Leaf $missingSource = @() if ($missingParent -and $missingLeaf) { $missingSource = @([pscustomobject]@{ RootName = $rootName ParentDir = $missingParent RelativePaths = @($missingLeaf) SourcePath = $missingPath }) } return [pscustomobject]@{ IsName = $true CatalogEntry = $catalogEntry BaseName = $baseName ArchiveFlavor = 'name' RootName = $rootName Sources = $missingSource Source = $Entry.Path Error = "名录里的路径不存在:$($catalogEntry.Path)" } } # @pathname 已在前面统一处理(必须排在 Unresolved 之前),这里不再重复。 $sources = @() if ($catalogEntry.Kind -eq 'Variant') { $parent = Split-Path -Path $catalogEntry.ResolvedPath -Parent $relatives = @($catalogEntry.Variants | ForEach-Object { Split-Path -Path $_ -Leaf }) $sources = @([pscustomobject]@{ RootName = $rootName ParentDir = $parent RelativePaths = $relatives SourcePath = $parent }) } elseif ($catalogEntry.Kind -eq 'Single') { $sources = @([pscustomobject]@{ RootName = $rootName ParentDir = (Split-Path -Path $catalogEntry.ResolvedPath -Parent) RelativePaths = @((Split-Path -Path $catalogEntry.ResolvedPath -Leaf)) SourcePath = $catalogEntry.ResolvedPath }) } else { return [pscustomobject]@{ IsName = $true CatalogEntry = $catalogEntry BaseName = $baseName ArchiveFlavor = 'name' RootName = $rootName Sources = @() Source = $Entry.Path Error = "名录里的路径不存在:$($catalogEntry.Path)" } } return [pscustomobject]@{ IsName = $true CatalogEntry = $catalogEntry BaseName = $baseName ArchiveFlavor = 'name' RootName = $rootName Sources = $sources Source = $Entry.Path } } 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 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 从环境变量 BAKNRET_PASSWORD 或密码文件取加密口令;取不到返回 $null。 .DESCRIPTION 口令**不写入仓库**。若清单要求加密但取不到口令,调用方必须失败退出, 绝不能默默写出明文归档。 #> param([string]$PasswordFile) 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() } } 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-ItemArchiveName', 'Resolve-BackupEntry', 'Get-BackupBaseName', 'Convert-BackupFileNameToPath', 'Get-FolderSummary', 'Read-BaknretManifest', 'Write-BaknretManifest', 'Move-BaknretArchiveIntoPlace', 'Get-BaknretConfig', 'Get-BaknretPassword' )