Files
BakNRet/Common.psm1
T
Shuery dbc0c00554 重构为可核对、可恢复的备份工具(P0-P3)
修复(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 注册每日计划任务。
- 归档命名算法保持不变,已有归档不会失联。
2026-09-21 20:10:18 +08:00

796 lines
28 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
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'
)