修复(P0) - 退出码:改用 .NET Process 继承控制台启动外部命令。Start-Process -PassThru 的 ExitCode 在 PowerShell 7.7.0-preview.4 上恒为 $null,会把成功的压缩判成失败, 并让 "exit 2 -> 删档重试" 的自愈分支永远不可达。 - BackupList.txt 解析:先按第一个 :: 切开再处理引号,修正整行被引号包住时 排除表被吞进路径的问题(该条目此前被静默跳过,其 2.8 GB 归档成了孤儿)。 - 排除分隔符同时接受 , 与 ;:此前解析器只认 ; 而清单里写的是 ,, 等于所有排除规则都没生效。 - 7z 排除参数不再嵌引号,含空格的模式自动转成 ?:旧写法 -x!"路径" 会让引号 成为模式的一部分,导致排除对所有条目都失效。 加固(P1) - 先写临时归档 -> 7z t 校验 -> 原子替换,中断不再污染正式归档。 - 放弃 7z 的更新模式 u:固实压缩下收益极小,却让排除规则改动与已删文件 永远进不了归档。 - 新增 Backups/manifest.json 与 logs/*.log,跳过/失败有据可查。 - 结尾按失败数 exit;恢复支持 -WhatIf / -DryRun / -VerifyOnly / -Only。 - 恢复优先用 manifest 定位归档,并精确比较 BaseName(不再用 -Filter 通配)。 - 修正 tar 分支用 $LASTEXITCODE 判断成功与否的缺陷。 - 有警告(文件被占用)时拒绝用不完整的归档覆盖完整归档,需显式 -AcceptWarnings。 策略与安全(P2) - Edge 条目加排除规则:解压后 4.22 GB 中 3.79 GB 是可再生的缓存/遥测/扩展本体, 保留书签、密码、偏好、历史与站点数据。 - 可选 7z 加密(@encrypt 标记或全局开关),取不到口令时明确失败,绝不写明文。 - 磁盘空间守卫:放不下就跳过该条目,低于阈值告警。 工程化(P3) - 新增 BackupConfig.psd1、README.md、.gitignore。 - tests/Run-Tests.ps1(32 项)与 tests/Run-E2E.ps1(16 项端到端验收)。 - tools/Register-BackupTask.ps1 注册每日计划任务。 - 归档命名算法保持不变,已有归档不会失联。
796 lines
28 KiB
PowerShell
796 lines
28 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 路径里只可能是盘符,`::` 不可能出现在真实路径中,
|
||
# 因此可以安全地按第一个 `::` 切分,不受引号位置影响。
|
||
$separatorIndex = $content.IndexOf('::')
|
||
if ($separatorIndex -ge 0) {
|
||
$pathPart = $content.Substring(0, $separatorIndex)
|
||
$tailPart = $content.Substring($separatorIndex + 2)
|
||
} else {
|
||
$pathPart = $content
|
||
$tailPart = ''
|
||
}
|
||
|
||
# 引号只应包住路径。整行被一对引号包住时(历史写法),
|
||
# 上面的切分已经把排除表摘出去了,此时路径这半只剩开引号、
|
||
# 闭引号留在了 tail 末尾,因此两侧各剥一次,不要求成对。
|
||
$pathPart = $pathPart.Trim()
|
||
if ($pathPart.StartsWith('"')) { $pathPart = $pathPart.Substring(1) }
|
||
if ($pathPart.EndsWith('"')) { $pathPart = $pathPart.Substring(0, $pathPart.Length - 1) }
|
||
$pathPart = $pathPart.Trim()
|
||
if ([string]::IsNullOrEmpty($pathPart)) { return $null }
|
||
|
||
$tailPart = $tailPart.Trim()
|
||
if ($tailPart.EndsWith('"')) { $tailPart = $tailPart.Substring(0, $tailPart.Length - 1).Trim() }
|
||
|
||
# 从尾部摘出 @标记。没有 `::` 时标记直接跟在路径后面
|
||
# (如 `%UserProfile%\.ssh @encrypt`),因此 tail 为空时还要从路径那半再摘一次。
|
||
$tailSplit = Split-TrailingFlags -Text $tailPart
|
||
$flags = @($tailSplit.Flags)
|
||
$tailPart = $tailSplit.Remainder
|
||
|
||
if (-not $tailPart) {
|
||
$pathSplit = Split-TrailingFlags -Text $pathPart
|
||
if ($pathSplit.Flags.Count -gt 0) {
|
||
$flags = @($pathSplit.Flags) + $flags
|
||
$pathPart = $pathSplit.Remainder
|
||
}
|
||
}
|
||
|
||
$excludes = @()
|
||
if ($tailPart) {
|
||
$excludes = @($tailPart -split '[,;]' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
|
||
}
|
||
|
||
return [pscustomobject]@{
|
||
Path = $pathPart
|
||
ExcludePatterns = $excludes
|
||
Flags = $flags
|
||
Raw = $Line
|
||
}
|
||
}
|
||
}
|
||
|
||
function Get-ArchiveExcludeArgument {
|
||
<#
|
||
.SYNOPSIS
|
||
把清单里的排除模式翻译成 7z 的 -x 参数。
|
||
|
||
.DESCRIPTION
|
||
7z 排除语义(已实测确认):
|
||
* `-x!<完整归档内路径>` 匹配对象的完整路径,且**包含归档根目录名**
|
||
(源是 C:\Programs\Foo 时,归档里的路径是 Foo\...),所以必须加前缀;
|
||
* 模式里**不能出现空格**——`-x!root\Code Cache` 匹配不到任何东西,
|
||
正确的写法是 `-xr!Code?Cache` 或 `-xr!*Cache`。因此这里把模式里的
|
||
空格自动换成 `?`(单字符通配符,恰好对应一个空格);
|
||
* 模式里**不能手工加引号**——旧实现写成 -x!"路径",引号会成为模式的
|
||
一部分导致永不匹配;
|
||
* 以 `!` 开头的模式按"任意层级下的组件名"处理,翻译成 `-xr!`。
|
||
#>
|
||
param(
|
||
[Parameter(Mandatory = $true)][string]$ItemName,
|
||
[string[]]$Patterns = @()
|
||
)
|
||
|
||
$result = @()
|
||
foreach ($pattern in $Patterns) {
|
||
if ([string]::IsNullOrWhiteSpace($pattern)) { continue }
|
||
|
||
if ($pattern.StartsWith('!')) {
|
||
$component = $pattern.Substring(1).Trim()
|
||
if (-not $component) { continue }
|
||
$component = $component -replace ' ', '?'
|
||
$result += "-xr!$component"
|
||
continue
|
||
}
|
||
|
||
$full = $pattern.Trim().Trim([char[]]@('\', '/')).TrimEnd([char[]]@('\', '/'))
|
||
if (-not $full) { continue }
|
||
|
||
if (-not $full.StartsWith("$ItemName\", [System.StringComparison]::OrdinalIgnoreCase)) {
|
||
$full = "$ItemName\$full"
|
||
}
|
||
$full = $full -replace ' ', '?'
|
||
$result += "-x!$full"
|
||
}
|
||
|
||
# 必须用逗号包一层:只有一个元素时 PowerShell 会把数组拆成标量,
|
||
# 调用方拿到的就是字符串而不是数组(`$x[0]` 会变成首字符 "-")。
|
||
return ,$result
|
||
}
|
||
|
||
# ============================================================================
|
||
# 归档命名与路径还原
|
||
# ============================================================================
|
||
|
||
function Get-BackupBaseName {
|
||
<#
|
||
.SYNOPSIS
|
||
由清单中的原始路径生成归档基础名。
|
||
|
||
.DESCRIPTION
|
||
算法与历史版本保持一致(否则已存在的 20 个归档会全部失联):
|
||
<末级名>_from_<去掉末级后的各级用 + 连接>
|
||
并保留 & % + 三个字符(环境变量写法依赖 %),其余非法字符换 _。
|
||
|
||
额外做一件事:把 `:` 归一化为 `_`,因此 C:\Foo 与 "C:\Foo" 结果相同。
|
||
#>
|
||
param([Parameter(Mandatory = $true)][string]$RawPath)
|
||
|
||
$normalized = $RawPath.Trim() -replace '[/\\]+', '\'
|
||
$parts = @($normalized -split '\\' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
|
||
|
||
if ($parts.Count -eq 0) {
|
||
Write-Log "无法解析路径:$RawPath" -Level ERROR
|
||
return $null
|
||
}
|
||
|
||
$folderName = $parts[-1].Trim()
|
||
$pathParts = if ($parts.Count -gt 1) { $parts[0..($parts.Count - 2)] } else { @() }
|
||
|
||
$pathPart = ($pathParts | ForEach-Object { $_.Trim() }) -join '+'
|
||
$baseName = if ([string]::IsNullOrEmpty($pathPart)) {
|
||
$folderName
|
||
} else {
|
||
"${folderName}_from_${pathPart}"
|
||
}
|
||
|
||
$invalidChars = [System.IO.Path]::GetInvalidFileNameChars() |
|
||
Where-Object { $_ -notin @('&', '%', '+') }
|
||
|
||
$baseName = -join ($baseName.ToCharArray() | ForEach-Object {
|
||
if ($_ -in $invalidChars) { '_' } else { $_ }
|
||
})
|
||
$baseName = $baseName -replace ':', '_'
|
||
|
||
Write-Log "生成文件基础名:$baseName" -Level DEBUG
|
||
return $baseName
|
||
}
|
||
|
||
function Convert-BackupFileNameToPath {
|
||
<#
|
||
.SYNOPSIS
|
||
把归档文件名还原成原始路径(用于没有 manifest 时的兜底)。
|
||
|
||
.DESCRIPTION
|
||
只处理 <名>_from_<路径> 形式;`C_` 还原为 `C:`。
|
||
命名里本来就含 `+` 或 `_from_` 的真实目录名无法可靠还原,
|
||
这类情况应当依赖 manifest.json 而不是文件名。
|
||
#>
|
||
param([Parameter(Mandatory = $true)][string]$FileName)
|
||
|
||
$baseName = [System.IO.Path]::GetFileNameWithoutExtension($FileName)
|
||
if ($baseName -notmatch '_from_') { return $null }
|
||
|
||
try {
|
||
$folderPart, $pathPart = $baseName -split '_from_', 2
|
||
$parts = @($pathPart -split '\+' | Where-Object { -not [string]::IsNullOrEmpty($_) })
|
||
|
||
$parts = @($parts | ForEach-Object {
|
||
if ($_ -match '^([A-Za-z])_$') { "$($matches[1]):" } else { $_ }
|
||
})
|
||
|
||
$reconstructed = ($parts -join '\') + '\' + $folderPart
|
||
Write-Log "逆向解析:$FileName -> $reconstructed" -Level DEBUG
|
||
return $reconstructed
|
||
} catch {
|
||
Write-Log "无法解析备份文件名:$FileName" -Level WARN
|
||
return $null
|
||
}
|
||
}
|
||
|
||
function Get-FolderSummary {
|
||
<#
|
||
.SYNOPSIS
|
||
统计目录/文件的文件数、总大小与最新修改时间。
|
||
|
||
.DESCRIPTION
|
||
LatestModifiedTime 取**包含目录在内**的所有条目的最大值:
|
||
目录的 LastWriteTime 会在子项增删时更新,因此删掉文件也能被察觉。
|
||
#>
|
||
param([Parameter(Mandatory = $true)][string]$FolderPath)
|
||
|
||
try {
|
||
$items = @(Get-ChildItem -LiteralPath $FolderPath -Recurse -Force -ErrorAction SilentlyContinue)
|
||
$files = @($items | Where-Object { -not $_.PSIsContainer })
|
||
|
||
return [pscustomobject]@{
|
||
FileCount = $files.Count
|
||
TotalSize = ($files | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
|
||
LatestModifiedTime = ($items | Measure-Object -Property LastWriteTime -Maximum -ErrorAction SilentlyContinue).Maximum
|
||
}
|
||
} catch {
|
||
Write-Log "无法读取文件夹摘要:$FolderPath" -Level WARN
|
||
return [pscustomobject]@{
|
||
FileCount = 0
|
||
TotalSize = 0
|
||
LatestModifiedTime = (Get-Item -LiteralPath $FolderPath -ErrorAction SilentlyContinue).LastWriteTime
|
||
}
|
||
}
|
||
}
|
||
|
||
# ============================================================================
|
||
# manifest.json
|
||
# ============================================================================
|
||
|
||
function Read-BaknretManifest {
|
||
<#
|
||
.SYNOPSIS
|
||
读取 manifest.json;不存在或损坏时返回空清单。
|
||
|
||
.DESCRIPTION
|
||
items 是按归档基础名索引的对象,方便按条目合并与查找。
|
||
损坏时只告警不中断:manifest 只是记录,不该成为备份的阻塞点。
|
||
#>
|
||
param([Parameter(Mandatory = $true)][string]$Path)
|
||
|
||
$empty = [pscustomobject]@{
|
||
schemaVersion = 1
|
||
tool = 'BakNRet'
|
||
updatedAt = $null
|
||
compressor = $null
|
||
items = [ordered]@{}
|
||
}
|
||
|
||
if (-not (Test-Path -LiteralPath $Path)) { return $empty }
|
||
|
||
try {
|
||
$raw = Get-Content -LiteralPath $Path -Raw -Encoding UTF8 -ErrorAction Stop
|
||
if ([string]::IsNullOrWhiteSpace($raw)) { return $empty }
|
||
|
||
$parsed = $raw | ConvertFrom-Json -ErrorAction Stop
|
||
$items = [ordered]@{}
|
||
if ($parsed.PSObject.Properties.Name -contains 'items' -and $parsed.items) {
|
||
foreach ($property in $parsed.items.PSObject.Properties) {
|
||
$items[$property.Name] = $property.Value
|
||
}
|
||
}
|
||
|
||
return [pscustomobject]@{
|
||
schemaVersion = 1
|
||
tool = 'BakNRet'
|
||
updatedAt = $parsed.updatedAt
|
||
compressor = $parsed.compressor
|
||
items = $items
|
||
}
|
||
} catch {
|
||
Write-Log "manifest 解析失败(将重新建立):$Path —— $_" -Level WARN
|
||
return $empty
|
||
}
|
||
}
|
||
|
||
function Write-BaknretManifest {
|
||
<#
|
||
.SYNOPSIS
|
||
原子写入 manifest.json(UTF-8 无 BOM)。
|
||
#>
|
||
param(
|
||
[Parameter(Mandatory = $true)][string]$Path,
|
||
[Parameter(Mandatory = $true)]$Manifest
|
||
)
|
||
|
||
$Manifest.updatedAt = (Get-Date).ToString('o')
|
||
$json = $Manifest | ConvertTo-Json -Depth 6
|
||
|
||
$directory = Split-Path -Parent $Path
|
||
if ($directory -and -not (Test-Path -LiteralPath $directory)) {
|
||
New-Item -ItemType Directory -Path $directory -Force | Out-Null
|
||
}
|
||
|
||
$temp = "$Path.tmp"
|
||
[System.IO.File]::WriteAllText($temp, $json, $script:LogEncoding)
|
||
|
||
if (Test-Path -LiteralPath $Path) {
|
||
Remove-Item -LiteralPath $Path -Force
|
||
}
|
||
Move-Item -LiteralPath $temp -Destination $Path -Force
|
||
return $Path
|
||
}
|
||
|
||
# ============================================================================
|
||
# 归档原子替换
|
||
# ============================================================================
|
||
|
||
function Move-BaknretArchiveIntoPlace {
|
||
<#
|
||
.SYNOPSIS
|
||
把临时归档原子地替换到最终路径。
|
||
|
||
.DESCRIPTION
|
||
优先用 File.Move(overwrite)(同卷上是 MoveFileEx + REPLACE_EXISTING,
|
||
基本等价于原子替换);不支持时退化为先删后移。
|
||
#>
|
||
param(
|
||
[Parameter(Mandatory = $true)][string]$TempPath,
|
||
[Parameter(Mandatory = $true)][string]$DestinationPath
|
||
)
|
||
|
||
try {
|
||
[System.IO.File]::Move($TempPath, $DestinationPath, $true)
|
||
return
|
||
} catch {
|
||
Write-Log "原子替换失败,退化为先删后移:$_" -Level DEBUG
|
||
}
|
||
|
||
if (Test-Path -LiteralPath $DestinationPath) {
|
||
Remove-Item -LiteralPath $DestinationPath -Force
|
||
}
|
||
Move-Item -LiteralPath $TempPath -Destination $DestinationPath -Force
|
||
}
|
||
|
||
# ============================================================================
|
||
# 配置
|
||
# ============================================================================
|
||
|
||
function Get-BaknretConfig {
|
||
<#
|
||
.SYNOPSIS
|
||
读取 BackupConfig.psd1 并与内置默认值合并。
|
||
|
||
.DESCRIPTION
|
||
配置文件缺失不是错误:直接用默认值,让工具开箱可用。
|
||
#>
|
||
param([string]$Path)
|
||
|
||
$defaults = @{
|
||
BackupDir = 'Backups'
|
||
LogDir = 'logs'
|
||
SnapshotDir = 'Backups\snapshots'
|
||
MinFreeSpaceGB = 8
|
||
VerifyArchive = $true
|
||
ComputeHash = $false
|
||
CompressionLevel = 9
|
||
ToolOutput = 'live' # live | quiet
|
||
Snapshot = @{ Enabled = $false; KeepCount = 3; KeepDays = 30 }
|
||
Encryption = @{ Enabled = $false; PasswordFile = ''; EncryptHeaders = $true }
|
||
DefaultExcludes = @()
|
||
}
|
||
|
||
if (-not $Path -or -not (Test-Path -LiteralPath $Path)) {
|
||
return $defaults
|
||
}
|
||
|
||
try {
|
||
$loaded = Import-PowerShellDataFile -LiteralPath $Path -ErrorAction Stop
|
||
} catch {
|
||
Write-Log "配置文件读取失败(改用默认值):$Path —— $_" -Level WARN
|
||
return $defaults
|
||
}
|
||
|
||
foreach ($key in $loaded.Keys) {
|
||
if ($key -in @('Snapshot', 'Encryption') -and $loaded[$key] -is [hashtable]) {
|
||
$merged = @{}
|
||
foreach ($subKey in $defaults[$key].Keys) { $merged[$subKey] = $defaults[$key][$subKey] }
|
||
foreach ($subKey in $loaded[$key].Keys) { $merged[$subKey] = $loaded[$key][$subKey] }
|
||
$defaults[$key] = $merged
|
||
} else {
|
||
$defaults[$key] = $loaded[$key]
|
||
}
|
||
}
|
||
|
||
return $defaults
|
||
}
|
||
|
||
function Get-BaknretPassword {
|
||
<#
|
||
.SYNOPSIS
|
||
从环境变量 BAKNRET_PASSWORD 或密码文件取加密口令;取不到返回 $null。
|
||
|
||
.DESCRIPTION
|
||
口令**不写入仓库**。若清单要求加密但取不到口令,调用方必须失败退出,
|
||
绝不能默默写出明文归档。
|
||
#>
|
||
param([string]$PasswordFile)
|
||
|
||
if ($env:BAKNRET_PASSWORD) { return $env:BAKNRET_PASSWORD }
|
||
|
||
if ($PasswordFile -and (Test-Path -LiteralPath $PasswordFile)) {
|
||
$line = Get-Content -LiteralPath $PasswordFile -TotalCount 1 -Encoding UTF8 -ErrorAction SilentlyContinue
|
||
if ($line) { return $line.Trim() }
|
||
}
|
||
|
||
return $null
|
||
}
|
||
|
||
Export-ModuleMember -Function @(
|
||
'Set-BaknretDebug', 'Start-BaknretLog', 'Stop-BaknretLog', 'Get-BaknretLogPath', 'Write-Log',
|
||
'Test-Administrator', 'Get-BaknretFreeSpaceGB',
|
||
'ConvertTo-NativeArgumentString', 'Invoke-ExternalCommand', 'Resolve-CompressionTool', 'Get-Optimized7zArgument',
|
||
'ConvertFrom-BackupListLine', 'Get-ArchiveExcludeArgument',
|
||
'Get-BackupBaseName', 'Convert-BackupFileNameToPath', 'Get-FolderSummary',
|
||
'Read-BaknretManifest', 'Write-BaknretManifest', 'Move-BaknretArchiveIntoPlace',
|
||
'Get-BaknretConfig', 'Get-BaknretPassword'
|
||
)
|