<# .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 if (-not $tailPart) { $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 ExcludePatterns = $excludes Flags = $flags Raw = $Line } } } 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 } # ============================================================================ # 归档命名与路径还原 # ============================================================================ 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' 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', 'Get-BackupBaseName', 'Convert-BackupFileNameToPath', 'Get-FolderSummary', 'Read-BaknretManifest', 'Write-BaknretManifest', 'Move-BaknretArchiveIntoPlace', 'Get-BaknretConfig', 'Get-BaknretPassword' )