Files
BakNRet/Common.psm1
T
Shuery e114cae8c8 P0-P3 全量重构:退出码 / 解析修复、manifest 与 7z t 校验、干跑、排除规则、日志、测试与计划任务
P0 正确性
- 退出码:改用 .NET Process 直接启动、让子进程继承控制台,不再用 Start-Process -PassThru
  (在 7.7.0-preview.4 上 ExitCode 恒为 $null,会把成功的压缩判成失败);
  7z / RAR / tar 三条解压分支统一走同一个取退出码的封装。
- BackupList 解析:先按第一个 :: 切段再处理引号(整行被一对引号包住的写法不再把排除表
  吞进路径);排除表同时接受 , 与 ;(旧实现只认 ;,导致排除从未生效);支持 :- / :+ / @flag。
- 补回 .ssh 与孤儿归档:.ssh 进清单;孤儿归档在备份端也做审计并点名;
  带 -Only / -Skip 时不再把未选中的归档误报成孤儿。
- Resolve-BackupEntry 里 $rootName 在赋值前被引用(会读到外层作用域残留值),已提前赋值。

P1 归档可靠性
- 每个条目写进 manifest.json:源、归档、时间、退出码、校验结果、失败原因,
  并区分 warnings(在位归档)与 attemptWarnings(本次尝试)。
- 归档后做 7z t 内容校验,先写 .tmp、校验通过再原子替换(File.Move overwrite)。
- manifest.roots 记录归档内**真实**的顶层条目名(原先记的是软件名,Edge 实际是 "User Data")。

P2 可用性
- Restore 支持 -WhatIf / -DryRun / -VerifyOnly / -Only / -Skip;
  这三种"只看不写"的模式一个字节都不写(原先会写回 manifest.json)。
- Edge 等高缓存条目加排除规则并实测:1781 MB / 27961 项 -> 72 MB / 2294 项;
  书签、密码、Cookies、偏好、历史、IndexedDB、Local Storage 全部保留。
  普通模式是相对归档根目录锚定的,嵌套的那些(如 OneAuth\WebView2 里的 Crashpad)
  改用 ! 组件形式才会命中。
- 日志落盘 logs/<backup|restore>-<时间戳>.log;退出码按失败数返回。
- tools/Register-BackupTask.ps1 注册每日计划任务;tools/Rename-Archives.ps1 迁移旧归档名。
- root= 标记此前静默失效,现在明确告警(该功能尚未实现)。

P3 测试与验证
- tests/BakNRet.Tests.ps1:Pester 5 套件 62 项(含用子进程跑 Backup.ps1 / Restore.ps1
  的端到端与针对上述缺陷的回归)。
- tests/Run-Pester.ps1 + tools/Install-TestDependencies.ps1:把 Pester 装到仓库内 .tools/,
  不动机器上的全局模块(系统自带的 3.4.0 缺 Should -Be)。
- tests/Restore-Drill.ps1:真实归档恢复演练,明确区分"源在备份后变过"与"归档/解压有问题"。
- tests/Run-Tests.ps1(49 项,零依赖)与 tests/Run-E2E.ps1(23 项)继续可用;三套共 134 项全通过。

真实机器验证
- 生产归档 22/22 通过 7z t;-VerifyOnly 不再改动 manifest.json(SHA256 前后一致)。
- 真实恢复演练 12/12 通过,27,670 个文件与活源逐字节一致。
- 修复了生产 scoop-persist.7z:原先只有 90 字节(空归档)而源有 1.3 GB,
  重打包后 233 MB,恢复演练 26981/26981 全部一致。
2026-09-21 23:02:47 +08:00

1405 lines
54 KiB
PowerShell
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<#
.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
把后续日志同时写入 <Directory>/<Prefix>-<时间戳>.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 路径里只可能是盘符,
# 而 `::` `:+` `:-` 都不可能出现在真实路径里,所以切分不受引号位置影响):
# :: 排除模式(历史写法,等价于 :-)
# :+ 追加一个目录(等价于名录里的 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
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
$dirList = @()
if ($entry -is [System.Collections.IDictionary]) {
# Dirs = 一个软件包含的多个目录(推荐写法)
# Variants = 同名目录出现在多个位置(旧写法,等价于 Dirs,保留兼容)
if ($entry.Contains('Dirs')) { $dirList = @($entry['Dirs']) }
elseif ($entry.Contains('Variants')) { $dirList = @($entry['Variants']) }
if ($entry.Contains('Path')) { $rawPath = [string]$entry['Path'] }
} else {
$rawPath = [string]$entry
}
if (-not $rawPath -and $dirList.Count -eq 0) { continue }
$resolved = if ($rawPath) { [Environment]::ExpandEnvironmentVariables($rawPath) } else { $null }
# 多目录:逐个解析(每个都可以用前缀补全),任一解析不出来就整体 Unresolved
if ($dirList.Count -gt 0) {
$paths = @()
$unresolved = @()
foreach ($item in $dirList) {
$candidate = [Environment]::ExpandEnvironmentVariables([string]$item)
if (Test-Path -LiteralPath $candidate) {
$paths += $candidate
continue
}
$parent = Split-Path -Path $candidate -Parent
$leaf = Split-Path -Path $candidate -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) {
$paths += $found[0]
Write-Log "名录:$name 的 $candidate -> $($found[0])(按前缀补全)" -Level DEBUG
} else {
$unresolved += $candidate
}
}
$kind = if ($paths.Count -eq 0) { 'Unresolved' } elseif ($unresolved.Count -gt 0) { 'Partial' } else { 'Multi' }
if ($unresolved.Count -gt 0) {
Write-Log ("名录:{0} 有 {1} 个目录找不到:{2}" -f $name, $unresolved.Count, ($unresolved -join ';')) -Level WARN
}
$result[$name] = [pscustomobject]@{
Name = $name
Path = $rawPath
ResolvedPath = $(if ($paths.Count -gt 0) { $paths[0] } else { $resolved })
Variants = $paths
Dirs = $paths
Missing = $unresolved
Kind = $kind
Raw = $entry
}
Write-Log "名录:$name -> $($paths.Count) 个目录" -Level DEBUG
continue
}
if (Test-Path -LiteralPath $resolved) {
$kind = 'Single'
} else {
# 名录里写的是父目录,实际目录带版本号之类后缀(如 legendary 的 <name>_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 = 'Multi' } 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)
Dirs = @($found)
Missing = @()
Kind = $kind
Raw = $entry
Suffixed = $true
}
Write-Log "名录:$name -> $resolved(按前缀补全)" -Level DEBUG
continue
}
}
$result[$name] = [pscustomobject]@{
Name = $name
Path = $rawPath
ResolvedPath = $resolved
Variants = @()
Dirs = $(if ($kind -eq 'Single') { @($resolved) } else { @() })
Missing = @()
Kind = $kind
Raw = $entry
}
}
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 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) })
# 必须在下面任何一个 return 之前算出来:名录里没有这个名字时也要走
# "路径不存在" 分支,那条分支引用 $rootName。原先它写在后面,靠 PowerShell
# 的隐式 $null 侥幸不报错,但会把**外层作用域**残留的 $rootName 带进来。
$rootName = if ($rootNames.Count -gt 0) { $rootNames[0] } else { $baseName }
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]
# @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 -in @('Multi', 'Partial')) {
# 一个软件包含多个目录:每个目录各占归档里的一个根目录层。
# 归档内层用**源目录自己的名字**(不用软件名),这样恢复时
# 每个目录都能各自找到父目录与末级名,互不干扰。
foreach ($dir in $catalogEntry.Dirs) {
$dirParent = Split-Path -Path $dir -Parent
$dirLeaf = Split-Path -Path $dir -Leaf
if (-not $dirParent -or -not $dirLeaf) { continue }
$sources += [pscustomobject]@{
RootName = $null
ParentDir = $dirParent
RelativePaths = @($dirLeaf)
SourcePath = $dir
}
}
} 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)"
}
}
# 清单里的 `:+ <路径>` 追加项,叠加在名录的目录之后
if ($Entry.AddedPaths -and $Entry.AddedPaths.Count -gt 0) {
foreach ($added in $Entry.AddedPaths) {
$addedPath = [Environment]::ExpandEnvironmentVariables($added)
$addedParent = Split-Path -Path $addedPath -Parent
$addedLeaf = Split-Path -Path $addedPath -Leaf
if (-not $addedParent -or -not $addedLeaf) { continue }
$sources += [pscustomobject]@{
RootName = $null
ParentDir = $addedParent
RelativePaths = @($addedLeaf)
SourcePath = $addedPath
}
}
}
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
取加密口令:命令行参数 > 环境变量 > 密码文件 > 交互式询问。
.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', 'Get-BackupBaseName', 'Convert-BackupFileNameToPath',
'Get-FolderSummary',
'Read-BaknretManifest', 'Write-BaknretManifest', 'Move-BaknretArchiveIntoPlace',
'Get-BaknretConfig', 'Get-BaknretPassword'
)