重构为可核对、可恢复的备份工具(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 注册每日计划任务。
- 归档命名算法保持不变,已有归档不会失联。
This commit is contained in:
Shuery committed 2026-09-21 20:10:18 +08:00
commit dbc0c00554
11 files changed
+2934

No files matched your search

+795
View File
@@ -0,0 +1,795 @@
<#
.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'
)