Files
BakNRet/Common.psm1
T
Shuery 045d51ac9c 引入软件名录:清单写软件名,归档名也用软件名
新功能
- 新增 SoftwareCatalog.psd1 —— "软件名 -> 目录"映射表,BackupList.txt 里
  直接写软件名即可,归档名也就是软件名(FooClolor.7z),
  不再是 FooClolor_from_C_+Programs.7z 这种由路径拼出来的名字。
- 三种写法可混用:软件名、字面路径(现有清单无需改写)、软件名 @pathname。
- 名录支持前缀补全(legendary -> legendary_2.0.4,只认 <名>_* / <名>-*)、
  Variants(同名目录在多处)、Includes(分文件维护)。
- tools/Rename-Archives.ps1:存量归档重命名,默认试运行,逐份大小校验并重建 manifest。
- 归档名重复直接报错,不再静默互相覆盖。

两套测试全绿:单元 42 项、端到端 23 项(新增名录命名/解析/迁移用例)。

过程中修掉的缺陷
- Resolve-BackupEntry 里 @pathname 与 Unresolved 分支顺序错误,
  @pathname 会被静默吃掉(改名后仍用软件名)。
- 源目录被删除时解析器丢掉 Sources,导致恢复端把软件名当路径、
  报 "Cannot bind argument to parameter 'Path' because it is an empty string"。
  恢复的语义恰恰是"源不存在就要还原回去",现在 Sources 照旧给出。
- 源存在性检查曾被漏掉,Get-Item 对不存在路径抛异常会中断整轮备份;
  且不能用 Join-Path 探测——目标盘符不存在时它会直接抛异常。
- 计划任务脚本外的 Caller 需要 -DryRun 才能验,已实跑确认。
2026-09-21 20:55:19 +08:00

1227 lines
45 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 路径里只可能是盘符,`::` 不可能出现在真实路径中,
# 因此可以安全地按第一个 `::` 切分,不受引号位置影响。
$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 的 <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 = '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'
)