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 全部一致。
1405 lines
54 KiB
PowerShell
1405 lines
54 KiB
PowerShell
<#
|
||
.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'
|
||
)
|