重构为可核对、可恢复的备份工具(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:
commit
dbc0c00554
11 files changed
+2934
No files matched your search
+16
@@ -0,0 +1,16 @@
|
|||||||
|
# 归档本体不进版本库(数 GB,且是随时可重建的产物)
|
||||||
|
Backups/
|
||||||
|
|
||||||
|
# 运行日志
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# 压缩过程中的临时归档(正常情况下不会残留;中断时可能留下,便于排查)
|
||||||
|
*.tmp.7z
|
||||||
|
*.tmp.zip
|
||||||
|
*.tmp.rar
|
||||||
|
|
||||||
|
# 编辑器 / 系统杂项
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
Thumbs.db
|
||||||
|
desktop.ini
|
||||||
+528
@@ -0,0 +1,528 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
按 BackupList.txt 执行备份。
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
与旧版相比的核心变化:
|
||||||
|
|
||||||
|
1. 退出码可靠 —— 不再用 Start-Process -PassThru(在 PowerShell 7.7.0-preview.4 上
|
||||||
|
ExitCode 恒为 $null,会把成功的压缩判成失败),改用 Invoke-ExternalCommand。
|
||||||
|
2. 先写临时归档 → 校验 → 原子替换。中断或断电只会留下 .tmp 文件,
|
||||||
|
不会污染正式归档;也不会再出现"半个归档被下次增量续写"的情况。
|
||||||
|
3. 不再使用 7z 的 u(更新)模式。7z 默认是固实压缩,u 本来就要重压大部分数据,
|
||||||
|
收益极小,却让排除规则和删除操作永远无法生效(旧归档里会一直留着已删文件)。
|
||||||
|
现在每次都从零打包,于是"排除规则改动"和"源里删掉的文件"都能真正反映到归档。
|
||||||
|
4. 每个条目写进 manifest.json:源、归档、时间、退出码、校验结果、失败原因。
|
||||||
|
跳过和失败从此有据可查,而不是只剩一行滚过去的控制台告警。
|
||||||
|
5. 结尾按失败数 exit,并写日志文件,计划任务能正确判断成败。
|
||||||
|
6. 磁盘空间守卫:放不下就拒绝该条目,低于阈值则告警。
|
||||||
|
#>
|
||||||
|
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter()]
|
||||||
|
[string]$BackupListPath = (Join-Path $PSScriptRoot 'BackupList.txt'),
|
||||||
|
|
||||||
|
[Parameter()]
|
||||||
|
[string]$BackupDir,
|
||||||
|
|
||||||
|
[Parameter()]
|
||||||
|
[string]$ConfigPath = (Join-Path $PSScriptRoot 'BackupConfig.psd1'),
|
||||||
|
|
||||||
|
[Parameter()]
|
||||||
|
[string]$KeyFile,
|
||||||
|
|
||||||
|
# 只处理匹配这些通配符的条目(匹配原始路径或归档基础名)
|
||||||
|
[Parameter()]
|
||||||
|
[string[]]$Only = @(),
|
||||||
|
|
||||||
|
# 跳过匹配这些通配符的条目
|
||||||
|
[Parameter()]
|
||||||
|
[string[]]$Skip = @(),
|
||||||
|
|
||||||
|
# 忽略"源未更新"判断,强制重新打包
|
||||||
|
[Parameter()]
|
||||||
|
[switch]$Force,
|
||||||
|
|
||||||
|
# 成功后在 snapshots 目录留一份带时间戳的副本
|
||||||
|
[Parameter()]
|
||||||
|
[switch]$Snapshot,
|
||||||
|
|
||||||
|
# 额外计算归档的 SHA256 写入 manifest(大归档会更慢)
|
||||||
|
[Parameter()]
|
||||||
|
[switch]$Hash,
|
||||||
|
|
||||||
|
# 抑制压缩工具的实时输出(日志与 manifest 不受影响)
|
||||||
|
[Parameter()]
|
||||||
|
[switch]$QuietTool,
|
||||||
|
|
||||||
|
# 允许用"有警告"的不完整归档覆盖已有的完整归档(默认拒绝)
|
||||||
|
[Parameter()]
|
||||||
|
[switch]$AcceptWarnings,
|
||||||
|
|
||||||
|
# 只打印将要做什么,不实际写入
|
||||||
|
[Parameter()]
|
||||||
|
[switch]$DryRun
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 载入依赖
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
$modulePath = Join-Path $PSScriptRoot 'Common.psm1'
|
||||||
|
if (-not (Test-Path -LiteralPath $modulePath)) {
|
||||||
|
Write-Error "找不到依赖模块:$modulePath,请确保所有文件在同一目录。"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Import-Module $modulePath -Force
|
||||||
|
|
||||||
|
if ($PSBoundParameters.ContainsKey('Verbose')) { Set-BaknretDebug }
|
||||||
|
|
||||||
|
$script:Config = Get-BaknretConfig -Path $ConfigPath
|
||||||
|
|
||||||
|
function Resolve-ConfigPath {
|
||||||
|
param([string]$Path, [string]$Default)
|
||||||
|
$value = if ($Path) { $Path } else { $Default }
|
||||||
|
if (-not [System.IO.Path]::IsPathRooted($value)) {
|
||||||
|
$value = Join-Path $PSScriptRoot $value
|
||||||
|
}
|
||||||
|
return $value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $BackupDir) { $BackupDir = Resolve-ConfigPath -Path $null -Default $script:Config.BackupDir }
|
||||||
|
$logDir = Resolve-ConfigPath -Path $null -Default $script:Config.LogDir
|
||||||
|
$snapshotDir = Resolve-ConfigPath -Path $null -Default $script:Config.SnapshotDir
|
||||||
|
$manifestPath = Join-Path $BackupDir 'manifest.json'
|
||||||
|
|
||||||
|
$logPath = Start-BaknretLog -Directory $logDir -Prefix 'backup'
|
||||||
|
Write-Log "日志文件:$logPath"
|
||||||
|
Write-Log "备份目录:$BackupDir"
|
||||||
|
|
||||||
|
if (-not (Test-Administrator)) {
|
||||||
|
Write-Log '建议以管理员身份运行以获取完整的目录访问权限' -Level WARN
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 准备
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $BackupDir)) {
|
||||||
|
New-Item -ItemType Directory -Path $BackupDir -Force | Out-Null
|
||||||
|
Write-Log "创建备份目录: $BackupDir" -Level DEBUG
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $BackupListPath)) {
|
||||||
|
$template = "# BackupList.txt`n# 语法: <路径> [ :: <排除模式>[,<排除模式>...] ] [ @<标记> ]`n# 示例: %UserProfile%\.ssh`n"
|
||||||
|
[System.IO.File]::WriteAllText($BackupListPath, $template, [System.Text.UTF8Encoding]::new($false))
|
||||||
|
Write-Log '模板 BackupList.txt 已创建,请编辑后重试。' -Level INFO
|
||||||
|
Stop-BaknretLog
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
$tool = Resolve-CompressionTool
|
||||||
|
if (-not $tool) {
|
||||||
|
Write-Log '没有找到可用的压缩工具。' -Level ERROR
|
||||||
|
Stop-BaknretLog
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$toolVersion = try {
|
||||||
|
$info = (Get-Item -LiteralPath $tool.Command -ErrorAction Stop).VersionInfo
|
||||||
|
if ($info.ProductVersion) { $info.ProductVersion } elseif ($info.FileVersion) { $info.FileVersion } else { $null }
|
||||||
|
} catch { $null }
|
||||||
|
Write-Log ("压缩工具:{0}{1}" -f $tool.Name, $(if ($toolVersion) { "($toolVersion)" } else { '' }))
|
||||||
|
|
||||||
|
$manifest = Read-BaknretManifest -Path $manifestPath
|
||||||
|
$manifest.compressor = [pscustomobject]@{ name = $tool.Name; command = $tool.Command; extension = $tool.Extension; version = $toolVersion }
|
||||||
|
|
||||||
|
$passwordFile = if ($KeyFile) { $KeyFile } else { $script:Config.Encryption.PasswordFile }
|
||||||
|
$password = Get-BaknretPassword -PasswordFile $passwordFile
|
||||||
|
$encryptAll = [bool]$script:Config.Encryption.Enabled
|
||||||
|
$showToolOutput = (-not $QuietTool) -and ($script:Config.ToolOutput -ne 'quiet')
|
||||||
|
$toolQuietArgument = if ($showToolOutput) { @() } else { @('-bso0', '-bsp0') }
|
||||||
|
|
||||||
|
$lines = Get-Content -LiteralPath $BackupListPath
|
||||||
|
$processed = 0; $skipped = 0; $failed = 0; $planned = 0
|
||||||
|
$failures = @()
|
||||||
|
$freeSpaceGB = Get-BaknretFreeSpaceGB -Path $BackupDir
|
||||||
|
if ($freeSpaceGB -ge 0) {
|
||||||
|
Write-Log ("备份目录所在卷剩余空间:{0} GB" -f $freeSpaceGB)
|
||||||
|
if ($freeSpaceGB -lt $script:Config.MinFreeSpaceGB) {
|
||||||
|
Write-Log ("剩余空间低于阈值 {0} GB,大条目可能失败" -f $script:Config.MinFreeSpaceGB) -Level WARN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-ItemSelected {
|
||||||
|
param([string]$DisplayPath, [string]$BaseName)
|
||||||
|
if ($Only.Count -gt 0) {
|
||||||
|
$matched = $false
|
||||||
|
foreach ($pattern in $Only) {
|
||||||
|
if ($DisplayPath -like $pattern -or $BaseName -like $pattern) { $matched = $true; break }
|
||||||
|
}
|
||||||
|
if (-not $matched) { return $false }
|
||||||
|
}
|
||||||
|
foreach ($pattern in $Skip) {
|
||||||
|
if ($DisplayPath -like $pattern -or $BaseName -like $pattern) { return $false }
|
||||||
|
}
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
|
function New-ItemRecord {
|
||||||
|
param([string]$BaseName, [string]$Source, [string]$ResolvedSource, [string]$Phase)
|
||||||
|
return [ordered]@{
|
||||||
|
baseName = $BaseName
|
||||||
|
source = $Source
|
||||||
|
resolvedSource = $ResolvedSource
|
||||||
|
archive = $null
|
||||||
|
action = $null
|
||||||
|
reason = $null
|
||||||
|
phase = $Phase
|
||||||
|
attemptedAt = (Get-Date).ToString('o')
|
||||||
|
finishedAt = $null
|
||||||
|
durationSec = $null
|
||||||
|
exitCode = $null
|
||||||
|
verified = $false
|
||||||
|
warnings = $false
|
||||||
|
attemptWarnings = $false
|
||||||
|
encrypted = $false
|
||||||
|
sourceFiles = $null
|
||||||
|
sourceBytes = $null
|
||||||
|
archiveBytes = $null
|
||||||
|
sha256 = $null
|
||||||
|
lastSuccessAt = $null
|
||||||
|
successCount = 0
|
||||||
|
failCount = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Save-ItemRecord {
|
||||||
|
param($Record, [string]$Action, [string]$Reason, [bool]$ArchiveWarnings = $false)
|
||||||
|
|
||||||
|
$previous = $null
|
||||||
|
if ($manifest.items.Contains($Record.baseName)) { $previous = $manifest.items[$Record.baseName] }
|
||||||
|
|
||||||
|
$Record.action = $Action
|
||||||
|
$Record.reason = $Reason
|
||||||
|
$Record.finishedAt = (Get-Date).ToString('o')
|
||||||
|
|
||||||
|
# warnings 描述的是"当前在位的归档",不是"这次尝试"。
|
||||||
|
# 只有真正换掉了归档才更新它;否则沿用上一条记录,
|
||||||
|
# 否则"因为不完整而保留旧归档"之后,下一次就失去保护了。
|
||||||
|
if ($Action -eq 'backed-up') {
|
||||||
|
$Record.warnings = $ArchiveWarnings
|
||||||
|
} elseif ($previous -and ($previous.PSObject.Properties.Name -contains 'warnings')) {
|
||||||
|
$Record.warnings = [bool]$previous.warnings
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($previous) {
|
||||||
|
if ($previous.PSObject.Properties.Name -contains 'lastSuccessAt') { $Record.lastSuccessAt = $previous.lastSuccessAt }
|
||||||
|
if ($previous.PSObject.Properties.Name -contains 'successCount') { $Record.successCount = [int]$previous.successCount }
|
||||||
|
if ($previous.PSObject.Properties.Name -contains 'failCount') { $Record.failCount = [int]$previous.failCount }
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Action -eq 'backed-up') {
|
||||||
|
$Record.lastSuccessAt = $Record.finishedAt
|
||||||
|
$Record.successCount = [int]$Record.successCount + 1
|
||||||
|
} elseif ($Action -eq 'failed') {
|
||||||
|
$Record.failCount = [int]$Record.failCount + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$manifest.items[$Record.baseName] = $Record
|
||||||
|
return $Record
|
||||||
|
}
|
||||||
|
|
||||||
|
# 压缩 + 校验 + 原子替换;返回 @{ Ok; ExitCode; Warnings; Reason }
|
||||||
|
function Invoke-BackupItem {
|
||||||
|
param(
|
||||||
|
[string]$SourcePath,
|
||||||
|
[string]$ItemName,
|
||||||
|
[string]$ParentDir,
|
||||||
|
[string]$FinalPath,
|
||||||
|
[string[]]$ExcludePatterns,
|
||||||
|
[switch]$UseEncryption,
|
||||||
|
[switch]$ProtectPrevious,
|
||||||
|
[switch]$AcceptWarnings
|
||||||
|
)
|
||||||
|
|
||||||
|
$tempPath = "$FinalPath.tmp$($tool.Extension)"
|
||||||
|
if (Test-Path -LiteralPath $tempPath) { Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue }
|
||||||
|
|
||||||
|
$excludeArgument = Get-ArchiveExcludeArgument -ItemName $ItemName -Patterns $ExcludePatterns
|
||||||
|
if ($excludeArgument.Count -gt 0) {
|
||||||
|
Write-Log ("排除 {0} 项:{1}" -f $excludeArgument.Count, ($excludeArgument -join ' ')) -Level DEBUG
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ($tool.Name -eq '7z') {
|
||||||
|
$optimized = Get-Optimized7zArgument -SourcePath $SourcePath -Level $script:Config.CompressionLevel
|
||||||
|
$argument = @($optimized.Argument) + $toolQuietArgument + $excludeArgument
|
||||||
|
|
||||||
|
if ($UseEncryption) {
|
||||||
|
if (-not $password) {
|
||||||
|
return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '需要加密但取不到口令(设置 BAKNRET_PASSWORD 或用 -KeyFile 指定密码文件)' }
|
||||||
|
}
|
||||||
|
$argument += "-p$password"
|
||||||
|
if ($script:Config.Encryption.EncryptHeaders) { $argument += '-mhe=on' }
|
||||||
|
}
|
||||||
|
|
||||||
|
$argument += $tempPath
|
||||||
|
$argument += $ItemName
|
||||||
|
|
||||||
|
$exitCode = Invoke-ExternalCommand -FilePath $tool.Command -ArgumentList $argument -WorkingDirectory $ParentDir
|
||||||
|
# 7z: 0 成功;1 警告(有文件读不到或跳过);2 及以上为失败
|
||||||
|
if ($exitCode -ne 0 -and $exitCode -ne 1) {
|
||||||
|
return [pscustomobject]@{ Ok = $false; ExitCode = $exitCode; Warnings = $false; Reason = "压缩工具退出码 $exitCode" }
|
||||||
|
}
|
||||||
|
$warnings = ($exitCode -eq 1)
|
||||||
|
}
|
||||||
|
elseif ($tool.Name -eq 'RAR') {
|
||||||
|
$argument = @('a', '-m5', '-idp', '-idn') + $toolQuietArgument + $excludeArgument
|
||||||
|
if ($UseEncryption) {
|
||||||
|
if (-not $password) {
|
||||||
|
return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '需要加密但取不到口令' }
|
||||||
|
}
|
||||||
|
$argument += "-p$password"
|
||||||
|
}
|
||||||
|
$argument += $tempPath
|
||||||
|
$argument += $ItemName
|
||||||
|
|
||||||
|
$exitCode = Invoke-ExternalCommand -FilePath $tool.Command -ArgumentList $argument -WorkingDirectory $ParentDir
|
||||||
|
if ($exitCode -ne 0) {
|
||||||
|
return [pscustomobject]@{ Ok = $false; ExitCode = $exitCode; Warnings = $false; Reason = "压缩工具退出码 $exitCode" }
|
||||||
|
}
|
||||||
|
$warnings = $false
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
if ($UseEncryption) {
|
||||||
|
return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '内置 ZIP 不支持加密,请改用 7z 或去掉 encrypt 标记' }
|
||||||
|
}
|
||||||
|
Push-Location $ParentDir
|
||||||
|
try {
|
||||||
|
Compress-Archive -Path $ItemName -DestinationPath $tempPath -CompressionLevel Optimal -Force
|
||||||
|
} finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
|
$warnings = $false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $tempPath)) {
|
||||||
|
return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = '压缩结束但没有生成临时归档' }
|
||||||
|
}
|
||||||
|
|
||||||
|
# 校验:确认归档可读且内容 CRC 正确
|
||||||
|
if ($script:Config.VerifyArchive -and $tool.Name -eq '7z') {
|
||||||
|
$verifyArgument = @('t', '-bso0', '-bsp0')
|
||||||
|
if ($UseEncryption -and $password) { $verifyArgument += "-p$password" }
|
||||||
|
$verifyArgument += $tempPath
|
||||||
|
|
||||||
|
$verifyCode = Invoke-ExternalCommand -FilePath $tool.Command -ArgumentList $verifyArgument -WorkingDirectory $ParentDir
|
||||||
|
if ($verifyCode -ne 0) {
|
||||||
|
Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue
|
||||||
|
return [pscustomobject]@{ Ok = $false; ExitCode = $verifyCode; Warnings = $false; Reason = "归档校验失败(7z t 退出码 $verifyCode),已丢弃临时文件" }
|
||||||
|
}
|
||||||
|
Write-Log '归档校验通过(7z t)' -Level DEBUG
|
||||||
|
}
|
||||||
|
|
||||||
|
# 关键保护:压缩工具报了警告(通常是有文件被占用读不到)时,
|
||||||
|
# 新归档是**不完整**的。用不完整归档覆盖已有的完整归档 = 静默丢数据。
|
||||||
|
# 实测:Edge 运行时备份,118 个文件读不到,其中包含 Login Data(密码)、
|
||||||
|
# Cookies、History、Web Data —— 恰恰是最不可再生的那部分。
|
||||||
|
if ($warnings -and $ProtectPrevious -and -not $AcceptWarnings) {
|
||||||
|
Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue
|
||||||
|
return [pscustomobject]@{
|
||||||
|
Ok = $false
|
||||||
|
ExitCode = $exitCode
|
||||||
|
Warnings = $true
|
||||||
|
Reason = '压缩工具报告有文件被占用而读不到,新归档不完整。为避免覆盖现有的完整归档已保留旧归档;请关闭占用该目录的程序后重跑,或确认可以接受后用 -AcceptWarnings 强制覆盖'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Move-BaknretArchiveIntoPlace -TempPath $tempPath -DestinationPath $FinalPath
|
||||||
|
return [pscustomobject]@{ Ok = $true; ExitCode = 0; Warnings = $warnings; Reason = $null }
|
||||||
|
} catch {
|
||||||
|
if (Test-Path -LiteralPath $tempPath) {
|
||||||
|
Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = "$_" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 主流程
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
foreach ($line in $lines) {
|
||||||
|
$item = ConvertFrom-BackupListLine -Line $line
|
||||||
|
if (-not $item) { continue }
|
||||||
|
|
||||||
|
$displayPath = $item.Path
|
||||||
|
$sourcePath = [Environment]::ExpandEnvironmentVariables($item.Path)
|
||||||
|
$baseName = Get-BackupBaseName -RawPath $item.Path
|
||||||
|
|
||||||
|
if (-not $baseName) {
|
||||||
|
$record = New-ItemRecord -BaseName ('raw:' + $displayPath) -Source $displayPath -ResolvedSource $sourcePath -Phase 'parse'
|
||||||
|
Save-ItemRecord -Record $record -Action 'failed' -Reason '无法从路径生成归档名' | Out-Null
|
||||||
|
$failed++; $failures += $displayPath
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-ItemSelected -DisplayPath $displayPath -BaseName $baseName)) {
|
||||||
|
Write-Log "跳过(未选中): $displayPath" -Level DEBUG
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
$record = New-ItemRecord -BaseName $baseName -Source $displayPath -ResolvedSource $sourcePath -Phase 'backup'
|
||||||
|
$record.archive = $baseName + $tool.Extension
|
||||||
|
$finalPath = Join-Path $BackupDir $record.archive
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $sourcePath)) {
|
||||||
|
Write-Log "跳过: $displayPath,路径不存在" -Level WARN
|
||||||
|
Save-ItemRecord -Record $record -Action 'missing-source' -Reason '源路径不存在' | Out-Null
|
||||||
|
$skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
$parentDir = Split-Path -Path $sourcePath -Parent
|
||||||
|
$itemName = Split-Path -Path $sourcePath -Leaf
|
||||||
|
if (-not $parentDir -or -not $itemName) {
|
||||||
|
Write-Log "跳过: $displayPath,无法处理根目录" -Level WARN
|
||||||
|
Save-ItemRecord -Record $record -Action 'invalid-path' -Reason '无法拆出父目录或末级名' | Out-Null
|
||||||
|
$skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
$summary = Get-FolderSummary -FolderPath $sourcePath
|
||||||
|
$record.sourceFiles = $summary.FileCount
|
||||||
|
$record.sourceBytes = $summary.TotalSize
|
||||||
|
|
||||||
|
$archiveExists = Test-Path -LiteralPath $finalPath
|
||||||
|
$archiveItem = if ($archiveExists) { Get-Item -LiteralPath $finalPath } else { $null }
|
||||||
|
|
||||||
|
Write-Log ("开始备份: {0}({1} 个文件,{2} MB)" -f $displayPath, $summary.FileCount, [math]::Round(($summary.TotalSize / 1MB), 2))
|
||||||
|
|
||||||
|
# 空目录时 Get-FolderSummary 拿不到任何条目,回退到源自身的修改时间
|
||||||
|
$sourceLatest = $summary.LatestModifiedTime
|
||||||
|
if (-not $sourceLatest) {
|
||||||
|
$sourceLatest = (Get-Item -LiteralPath $sourcePath -Force).LastWriteTime
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $Force -and $archiveItem -and $sourceLatest -and $sourceLatest -le $archiveItem.LastWriteTime) {
|
||||||
|
Write-Log "跳过: $displayPath,源目录未更新" -Level INFO
|
||||||
|
$record.archiveBytes = $archiveItem.Length
|
||||||
|
Save-ItemRecord -Record $record -Action 'skip-unchanged' -Reason ('源最新修改时间 {0} 不晚于归档时间 {1}' -f $sourceLatest, $archiveItem.LastWriteTime) | Out-Null
|
||||||
|
$skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
# 空间守卫:临时归档与正式归档会同时存在,因此按"新归档预估大小"要求剩余空间
|
||||||
|
$estimatedGB = $summary.TotalSize / 1GB
|
||||||
|
if ($archiveItem) {
|
||||||
|
$archiveGB = $archiveItem.Length / 1GB
|
||||||
|
$estimatedGB = [math]::Min($estimatedGB, $archiveGB * 1.3)
|
||||||
|
}
|
||||||
|
$freeSpaceGB = Get-BaknretFreeSpaceGB -Path $BackupDir
|
||||||
|
if ($freeSpaceGB -ge 0 -and $estimatedGB -gt 0 -and $freeSpaceGB -lt $estimatedGB) {
|
||||||
|
$reason = ('剩余空间 {0} GB 不足以写入预估 {1} GB 的新归档' -f $freeSpaceGB, [math]::Round($estimatedGB, 2))
|
||||||
|
Write-Log "失败: $displayPath,$reason" -Level ERROR
|
||||||
|
Save-ItemRecord -Record $record -Action 'failed' -Reason $reason | Out-Null
|
||||||
|
$failed++; $failures += $displayPath
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($DryRun) {
|
||||||
|
Write-Log ("[试运行] 将打包 {0} -> {1}" -f $sourcePath, $finalPath) -Level INFO
|
||||||
|
$record.reason = '试运行,未执行压缩'
|
||||||
|
Save-ItemRecord -Record $record -Action 'planned' -Reason '试运行,未执行压缩' | Out-Null
|
||||||
|
$planned++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
$useEncryption = $encryptAll -or ($item.Flags -contains 'encrypt')
|
||||||
|
$record.encrypted = [bool]$useEncryption
|
||||||
|
$startedAt = Get-Date
|
||||||
|
$record.attemptedAt = $startedAt.ToString('o')
|
||||||
|
|
||||||
|
# 配置里的全局排除 + 本条目的排除
|
||||||
|
$effectiveExcludes = @($script:Config.DefaultExcludes) + @($item.ExcludePatterns)
|
||||||
|
|
||||||
|
# 只有在"现有归档是完整的"时才值得保护它。没有 manifest 记录
|
||||||
|
# (本次重构之前留下的归档)时按完整处理——宁可保守。
|
||||||
|
$protectPrevious = [bool]$archiveExists
|
||||||
|
if ($archiveExists -and $manifest.items.Contains($baseName)) {
|
||||||
|
$previousRecord = $manifest.items[$baseName]
|
||||||
|
if (($previousRecord.PSObject.Properties.Name -contains 'warnings') -and $previousRecord.warnings) {
|
||||||
|
$protectPrevious = $false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = Invoke-BackupItem -SourcePath $sourcePath -ItemName $itemName -ParentDir $parentDir `
|
||||||
|
-FinalPath $finalPath -ExcludePatterns $effectiveExcludes -UseEncryption:$useEncryption `
|
||||||
|
-ProtectPrevious:$protectPrevious -AcceptWarnings:$AcceptWarnings
|
||||||
|
|
||||||
|
$record.exitCode = $result.ExitCode
|
||||||
|
$record.attemptWarnings = [bool]$result.Warnings
|
||||||
|
$record.verified = [bool]$result.Ok
|
||||||
|
$record.durationSec = [math]::Round(((Get-Date) - $startedAt).TotalSeconds, 1)
|
||||||
|
|
||||||
|
if (-not $result.Ok) {
|
||||||
|
Write-Log "备份失败: $displayPath,$($result.Reason)" -Level ERROR
|
||||||
|
Save-ItemRecord -Record $record -Action 'failed' -Reason $result.Reason | Out-Null
|
||||||
|
$failed++; $failures += $displayPath
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
$written = Get-Item -LiteralPath $finalPath
|
||||||
|
$record.archiveBytes = $written.Length
|
||||||
|
if ($result.Warnings) {
|
||||||
|
Write-Log "备份成功(压缩工具报告了警告,可能有文件被占用而没打进归档): $displayPath" -Level WARN
|
||||||
|
Write-Log ' 该归档在 manifest 里标记为 warnings=true;如果以后现有归档是完整的,会拒绝被它覆盖' -Level WARN
|
||||||
|
} else {
|
||||||
|
Write-Log "备份成功: $baseName" -Level INFO
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Hash -or $script:Config.ComputeHash) {
|
||||||
|
$record.sha256 = (Get-FileHash -LiteralPath $finalPath -Algorithm SHA256).Hash
|
||||||
|
Write-Log "SHA256: $($record.sha256)" -Level DEBUG
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Snapshot -or $script:Config.Snapshot.Enabled) {
|
||||||
|
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
|
||||||
|
$target = Join-Path (Join-Path $snapshotDir $stamp) $record.archive
|
||||||
|
$targetDir = Split-Path -Parent $target
|
||||||
|
if (-not (Test-Path -LiteralPath $targetDir)) { New-Item -ItemType Directory -Path $targetDir -Force | Out-Null }
|
||||||
|
Copy-Item -LiteralPath $finalPath -Destination $target -Force
|
||||||
|
Write-Log "已留存快照: $target" -Level INFO
|
||||||
|
}
|
||||||
|
|
||||||
|
Save-ItemRecord -Record $record -Action 'backed-up' -Reason $null -ArchiveWarnings $result.Warnings | Out-Null
|
||||||
|
$processed++
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 收尾
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
if ($DryRun) {
|
||||||
|
Write-Log '试运行:manifest 与归档都不会被写入' -Level INFO
|
||||||
|
} else {
|
||||||
|
Write-BaknretManifest -Path $manifestPath -Manifest $manifest | Out-Null
|
||||||
|
Write-Log "manifest 已更新:$manifestPath" -Level DEBUG
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($failures.Count -gt 0) {
|
||||||
|
Write-Log '失败条目:' -Level ERROR
|
||||||
|
foreach ($failure in $failures) { Write-Log " - $failure" -Level ERROR }
|
||||||
|
}
|
||||||
|
|
||||||
|
$summaryText = "备份完成。成功: $processed, 跳过: $skipped, 失败: $failed"
|
||||||
|
if ($DryRun) { $summaryText += ", 试运行计划: $planned" }
|
||||||
|
Write-Log $summaryText -Level INFO
|
||||||
|
|
||||||
|
$logPath = Get-BaknretLogPath
|
||||||
|
if ($logPath) { Write-Log "日志已写入:$logPath" -Level INFO }
|
||||||
|
Stop-BaknretLog
|
||||||
|
|
||||||
|
if ($failed -gt 0) { exit 1 }
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<#
|
||||||
|
BakNRet 配置文件。
|
||||||
|
|
||||||
|
优先级:命令行参数 > 本文件 > 代码内置默认值。
|
||||||
|
本文件缺失或某键缺失都直接用默认值,不会报错。
|
||||||
|
#>
|
||||||
|
@{
|
||||||
|
# 归档存放目录(相对路径按脚本所在目录解析)
|
||||||
|
BackupDir = 'Backups'
|
||||||
|
|
||||||
|
# 每次运行的日志目录(backup-<时间戳>.log / restore-<时间戳>.log)
|
||||||
|
LogDir = 'logs'
|
||||||
|
|
||||||
|
# 使用 -Snapshot 时留存带时间戳的副本
|
||||||
|
SnapshotDir = 'Backups\snapshots'
|
||||||
|
|
||||||
|
# 低于这个剩余空间(GB)就告警;真正放不下某个条目时会直接跳过该条目
|
||||||
|
MinFreeSpaceGB = 5
|
||||||
|
|
||||||
|
# 归档写完后用 `7z t` 校验内容 CRC,失败则丢弃临时文件
|
||||||
|
VerifyArchive = $true
|
||||||
|
|
||||||
|
# 是否默认给所有归档计算 SHA256(大归档会明显变慢)
|
||||||
|
ComputeHash = $false
|
||||||
|
|
||||||
|
# 7z 压缩级别 0-9
|
||||||
|
CompressionLevel = 9
|
||||||
|
|
||||||
|
# 压缩工具的实时输出:live = 直接显示(默认);quiet = 抑制,只留日志与 manifest
|
||||||
|
ToolOutput = 'live'
|
||||||
|
|
||||||
|
# 是否默认给所有条目留存快照
|
||||||
|
Snapshot = @{
|
||||||
|
Enabled = $false
|
||||||
|
KeepCount = 3
|
||||||
|
KeepDays = 30
|
||||||
|
}
|
||||||
|
|
||||||
|
# 加密。默认关闭:一旦开启而口令丢失,备份就再也解不开。
|
||||||
|
# 口令来源:环境变量 BAKNRET_PASSWORD,或 PasswordFile 指向的文件首行(用 -KeyFile 覆盖)。
|
||||||
|
# 开启方式见 README「加密」一节。注意 7z 只接受命令行口令,
|
||||||
|
# 口令在本机进程列表里短暂可见,这是 7z 本身的限制。
|
||||||
|
Encryption = @{
|
||||||
|
Enabled = $false
|
||||||
|
PasswordFile = ''
|
||||||
|
EncryptHeaders = $true
|
||||||
|
}
|
||||||
|
|
||||||
|
# 所有条目都生效的排除模式,语法同 BackupList.txt(! 开头 = 任意层级匹配组件名)
|
||||||
|
DefaultExcludes = @(
|
||||||
|
'!Thumbs.db'
|
||||||
|
'!desktop.ini'
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# BackupList.txt —— 备份 / 恢复共用清单
|
||||||
|
#
|
||||||
|
# 语法:
|
||||||
|
# <路径> [ :: <排除模式>[,<排除模式>...] ] [ @<标记> ]
|
||||||
|
#
|
||||||
|
# 路径 :支持 %环境变量%;可用双引号包裹(引号只包路径);/ 与 \ 等价。
|
||||||
|
# 排除模式 :相对归档根目录(也就是源目录的末级名)。
|
||||||
|
# 以 ! 开头表示"任意层级下匹配这个组件名",翻译成 7z 的 -xr!。
|
||||||
|
# 不要自己写引号;7z 的模式不支持空格,工具会自动把空格转成 ?。
|
||||||
|
# 标记 :encrypt = 用 7z 加密这个归档(口令来自 BAKNRET_PASSWORD 或 -KeyFile)。
|
||||||
|
#
|
||||||
|
# 注意:
|
||||||
|
# * 归档名由 <末级名>_from_<上级路径用 + 连接> 生成。改动路径会生成新归档名,
|
||||||
|
# 旧归档不会被自动迁移,恢复时请用 manifest.json 或保留原写法。
|
||||||
|
# * 源路径不存在的条目会明确报告为「跳过: 路径不存在」,不再静默忽略。
|
||||||
|
# * 对已经不存在、但归档还在的条目,保留在这里可以继续恢复出历史数据。
|
||||||
|
|
||||||
|
# ---- 用户配置 / 开发环境 ----
|
||||||
|
%UserProfile%/.config/legendary
|
||||||
|
%UserProfile%/.config/scoop
|
||||||
|
%UserProfile%/.ssh
|
||||||
|
# 想加密 .ssh(内含私钥)就改成下面这行,并先配置好口令,见 README「加密」一节:
|
||||||
|
# %UserProfile%/.ssh @encrypt
|
||||||
|
%UserProfile%\Documents\CodeSpace :: Shuery-Shuai\ImmortalWrt-BPI-R4-Firmware\immortalwrt\
|
||||||
|
%UserProfile%\Documents\PowerShell
|
||||||
|
%UserProfile%\Documents\WindowsPowerShell
|
||||||
|
%UserProfile%/scoop/persist
|
||||||
|
|
||||||
|
# ---- 应用数据 ----
|
||||||
|
%AppData%\AutoDarkMode
|
||||||
|
%AppData%/com.example/Kazumi
|
||||||
|
%AppData%/com.example/piliplus
|
||||||
|
%AppData%\fnm
|
||||||
|
%AppData%/twinkle-tray
|
||||||
|
|
||||||
|
# ---- 浏览器:排除可再生的缓存、遥测与扩展本体 ----
|
||||||
|
# 解压后 4.22 GB / 25030 个文件里,下面这组排除会留下约 431 MB / 2164 个文件,
|
||||||
|
# 排除掉的 3.79 GB 全部可以重新生成:缓存、组件缓存、Service Worker、
|
||||||
|
# 扩展本体(可从商店重装)、遥测与优化数据。
|
||||||
|
# 书签/密码/偏好/历史,以及站点数据(IndexedDB / Local Storage)都保留。
|
||||||
|
# 想再省 230 MB,可以把 Default\IndexedDB、Default\Local Storage、
|
||||||
|
# Default\Session Storage、Default\blob_storage、Default\WebStorage 也加进排除表。
|
||||||
|
# !*Cache 表示"任意层级下以 Cache 结尾的目录",能一次覆盖 Cache / Code Cache /
|
||||||
|
# GPUCache / DawnCache / GrShaderCache / ShaderCache 等一批。模式里的空格会被
|
||||||
|
# 自动转成 ?(7z 的排除模式不支持空格)。
|
||||||
|
%LocalAppData%\Microsoft\Edge\User Data :: !*Cache,component_crx_cache,Default\Service Worker,Default\Extensions,Default\ExtensionActivityEdge,ProvenanceData,optimization_guide,Crashpad,BrowserMetrics,Snapshots,Edge Sidebar,Edge Shopping
|
||||||
|
%LocalAppData%\Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json
|
||||||
|
|
||||||
|
# ---- 系统 ----
|
||||||
|
%ProgramData%\Microsoft\Windows\Start Menu\Programs\Startup
|
||||||
|
|
||||||
|
# ---- C:\Programs ----
|
||||||
|
C:\Programs\BaiduNetdisk
|
||||||
|
C:\Programs\FooClolor
|
||||||
|
C:\Programs\March7thAssistant :: 3rdparty\WebBrowser\UserProfile\Integrated,March7thAssistant\logs\
|
||||||
|
C:\Programs\MiFlash
|
||||||
|
C:\Programs\MiFlash_Unlock
|
||||||
|
C:\Programs\QuarkCloudDrive
|
||||||
|
C:\Programs\ScoopApps\apps\translucenttb\current\settings.json
|
||||||
|
C:\Programs\ScoopApps\persist :: persist\ariang-native\UserData\DawnCache,persist\ariang-native\UserData\GPUCache,persist\ariang-native\UserData\Local Storage,persist\ariang-native\UserData\Session Storage
|
||||||
|
|
||||||
|
# ---- 其它盘 ----
|
||||||
|
D:\UserData\Documents\Aria
|
||||||
|
D:\UserData\Documents\CodeSpace
|
||||||
|
|
||||||
|
E:\CodeSpace
|
||||||
+795
@@ -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'
|
||||||
|
)
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
# BakNRet
|
||||||
|
|
||||||
|
把 `BackupList.txt` 里列出的目录 / 文件用 **7-Zip** 打包进 `Backups/`,并且能用 `Restore.ps1` 原样恢复的 Windows 备份工具。
|
||||||
|
|
||||||
|
- 只依赖 PowerShell(5.1 或 7.x)与 7-Zip,无需安装模块。
|
||||||
|
- 每个归档写完后做 `7z t` 内容校验,**先写临时文件、校验通过再原子替换**。
|
||||||
|
- 每次运行产出可核对的 `Backups/manifest.json` 与 `logs/*.log`。
|
||||||
|
- 退出码可靠:有失败就返回 `1`,计划任务能正确判断成败。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 1. 先试运行:只打印计划,不写任何文件
|
||||||
|
.\Backup.ps1 -DryRun
|
||||||
|
|
||||||
|
# 2. 正式备份
|
||||||
|
.\Backup.ps1
|
||||||
|
|
||||||
|
# 3. 强制重打(忽略"源未更新"判断)
|
||||||
|
.\Backup.ps1 -Force
|
||||||
|
|
||||||
|
# 3b. 确认可以接受"有文件被占用而没打进归档"时,允许覆盖完整归档
|
||||||
|
.\Backup.ps1 -Force -AcceptWarnings
|
||||||
|
|
||||||
|
# 4. 只备份 / 只恢复某几项(通配符匹配路径或归档名)
|
||||||
|
.\Backup.ps1 -Only '*.ssh','C:\Programs\FooClolor'
|
||||||
|
.\Restore.ps1 -Only '*Edge*' -Force
|
||||||
|
|
||||||
|
# 5. 恢复前先看计划(恢复会覆盖真实目录,务必先看一眼)
|
||||||
|
.\Restore.ps1 -DryRun
|
||||||
|
|
||||||
|
# 6. 只校验所有归档完整性,不解压(只读,安全)
|
||||||
|
.\Restore.ps1 -VerifyOnly
|
||||||
|
```
|
||||||
|
|
||||||
|
## 文件说明
|
||||||
|
|
||||||
|
| 路径 | 作用 |
|
||||||
|
| --- | --- |
|
||||||
|
| `BackupList.txt` | 备份 / 恢复共用的清单,唯一的"要备份什么"来源 |
|
||||||
|
| `BackupConfig.psd1` | 目录、空间阈值、校验、加密等配置 |
|
||||||
|
| `Backup.ps1` | 备份入口 |
|
||||||
|
| `Restore.ps1` | 恢复入口 |
|
||||||
|
| `Common.psm1` | 公共模块(日志、外部命令、解析、manifest) |
|
||||||
|
| `Backups/` | 归档与 `manifest.json`(已 gitignore) |
|
||||||
|
| `logs/` | 每次运行的日志(已 gitignore) |
|
||||||
|
| `tests/` | 单元测试与端到端验收 |
|
||||||
|
| `tools/Register-BackupTask.ps1` | 注册 / 移除计划任务 |
|
||||||
|
|
||||||
|
## BackupList.txt 语法
|
||||||
|
|
||||||
|
```text
|
||||||
|
<路径> [ :: <排除模式>[,<排除模式>...] ] [ @<标记> ]
|
||||||
|
```
|
||||||
|
|
||||||
|
| 部分 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| 路径 | 支持 `%环境变量%`;可用双引号包裹(**引号只包路径**);`/` 与 `\` 等价;`#` 开头是注释 |
|
||||||
|
| 排除模式 | 相对归档根目录(源目录的末级名)。分隔符 `,` 与 `;` 都可以 |
|
||||||
|
| `!` 前缀 | 表示"任意层级下匹配这个名字",翻译成 7z 的 `-xr!`,例:`!*Cache` |
|
||||||
|
| 标记 | `encrypt` = 用 7z 加密该归档,见下文「加密」 |
|
||||||
|
|
||||||
|
几条已经踩过的坑(工具会处理,写的时候知道就行):
|
||||||
|
|
||||||
|
- **模式里不要写引号。** `-x!"路径"` 会让引号成为模式的一部分,结果是**永不匹配**。
|
||||||
|
- **模式里的空格会被自动转成 `?`。** 7z 的排除模式不支持空格:`Default\Code Cache` 匹配不到任何东西,`Default\Code?Cache` 才可以。
|
||||||
|
- **以第一个 `::` 为界切分。** `:` 在 Windows 路径里只可能是盘符,`::` 不会出现在真实路径里,所以整行被一对引号包住的历史写法(`"路径 :: 排除表"`)也能正确解析。
|
||||||
|
- **改名即换归档。** 归档名由路径生成:`<末级名>_from_<上级路径用 + 连接>`。改动路径会生成新归档,旧归档需靠 `manifest.json` 找回。
|
||||||
|
|
||||||
|
## 恢复语义
|
||||||
|
|
||||||
|
- 用 `7z x` 解压到目标的**父目录**,覆盖同名文件。
|
||||||
|
- **不做镜像同步**:目标目录里多出来的文件不会被删除。想得到"完全等于归档"的目录,请先清空目标。
|
||||||
|
- 目标目录比归档新时**默认跳过**,需要覆盖就加 `-Force`。
|
||||||
|
- `-WhatIf` / `-DryRun` 只打印计划;`-VerifyOnly` 只跑 `7z t`。
|
||||||
|
- **排除规则只在下一份归档里生效**:已经生成的归档不会因为改了排除表而"变干净",需要重跑备份才会生成新归档。
|
||||||
|
|
||||||
|
## manifest.json
|
||||||
|
|
||||||
|
`Backups/manifest.json` 以归档基础名为键记录每个条目:
|
||||||
|
|
||||||
|
| 字段 | 含义 |
|
||||||
|
| --- | --- |
|
||||||
|
| `source` / `resolvedSource` | 清单里的原始路径(未展开环境变量)/ 实际路径 |
|
||||||
|
| `archive` | 归档文件名 |
|
||||||
|
| `action` | `backed-up` / `skip-unchanged` / `missing-source` / `invalid-path` / `failed` / `planned` |
|
||||||
|
| `reason` | 跳过或失败的原因 |
|
||||||
|
| `exitCode` / `verified` / `warnings` | 压缩工具退出码、是否通过 `7z t`、**当前在位归档**是否有警告 |
|
||||||
|
| `attemptWarnings` | **本次尝试**是否报了警告(与 `warnings` 区分:保留旧归档时前者为 true、后者仍为 false) |
|
||||||
|
| `sourceFiles` / `sourceBytes` / `archiveBytes` | 源文件数、源大小、归档大小 |
|
||||||
|
| `startedAt` / `finishedAt` / `durationSec` | 时间与耗时 |
|
||||||
|
| `lastSuccessAt` / `successCount` / `failCount` / `lastRestoreAt` | 历史 |
|
||||||
|
| `encrypted` | 是否为加密归档(恢复时据此判断是否需要口令) |
|
||||||
|
|
||||||
|
`Restore.ps1` **优先用 manifest 定位归档**,查不到才退回"从文件名反推路径"。
|
||||||
|
如果 `BackupList.txt` 丢了,`Restore.ps1` 会优先用 manifest 里的 `source` 自动重建。
|
||||||
|
|
||||||
|
## 日志
|
||||||
|
|
||||||
|
`logs/backup-<时间戳>.log` / `logs/restore-<时间戳>.log`,与控制台内容一致。
|
||||||
|
压缩工具自身的实时输出直接进控制台,不进日志(见「设计取舍」)。
|
||||||
|
|
||||||
|
## 配置(BackupConfig.psd1)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
@{
|
||||||
|
BackupDir = 'Backups' # 相对路径按脚本所在目录解析
|
||||||
|
LogDir = 'logs'
|
||||||
|
SnapshotDir = 'Backups\snapshots'
|
||||||
|
MinFreeSpaceGB = 5 # 低于此值告警;真放不下某个条目则跳过该条目
|
||||||
|
VerifyArchive = $true # 归档后跑 7z t
|
||||||
|
ComputeHash = $false # 是否额外算 SHA256(大归档很慢)
|
||||||
|
CompressionLevel = 9
|
||||||
|
ToolOutput = 'live' # live | quiet
|
||||||
|
Snapshot = @{ Enabled = $false; KeepCount = 3; KeepDays = 30 }
|
||||||
|
Encryption = @{ Enabled = $false; PasswordFile = ''; EncryptHeaders = $true }
|
||||||
|
DefaultExcludes = @('!Thumbs.db', '!desktop.ini')
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
优先级:**命令行参数 > `BackupConfig.psd1` > 代码内置默认值**。也可以用 `-ConfigPath` 指定其它配置文件。
|
||||||
|
|
||||||
|
## 加密
|
||||||
|
|
||||||
|
加密是**按需开启**的,默认关闭——一旦开启而口令丢失,备份就再也解不开。
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 方式一:只为个别条目加密(.ssh 里是私钥,最典型)
|
||||||
|
# 在 BackupList.txt 里写成:
|
||||||
|
# %UserProfile%/.ssh @encrypt
|
||||||
|
|
||||||
|
# 方式二:全部加密,改配置
|
||||||
|
# Encryption = @{ Enabled = $true; PasswordFile = 'D:\secret\baknret.key' }
|
||||||
|
|
||||||
|
# 口令来源(二者取其一)
|
||||||
|
$env:BAKNRET_PASSWORD = '...' # 或
|
||||||
|
.\Backup.ps1 -KeyFile 'D:\secret\baknret.key' # 文件首行即口令
|
||||||
|
```
|
||||||
|
|
||||||
|
要求加密但取不到口令时,该条目会**明确失败**,绝不会退化成明文归档。
|
||||||
|
恢复加密归档时同理:取不到口令就直接失败,不会让 7z 停在控制台等待输入(在计划任务里那会静默挂起)。
|
||||||
|
|
||||||
|
> ⚠️ 7-Zip 只接受命令行口令,口令在本机进程列表里会短暂可见。这是 7z 本身的限制,请自行权衡。加密保护的是"归档落在盘上之后"。
|
||||||
|
|
||||||
|
## 计划任务
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 注册:每天 21:30 备份(默认用最高权限运行,因为部分目录需要管理员)
|
||||||
|
.\tools\Register-BackupTask.ps1 -At '21:30'
|
||||||
|
|
||||||
|
# 只看将要注册什么
|
||||||
|
.\tools\Register-BackupTask.ps1 -At '21:30' -DryRun
|
||||||
|
|
||||||
|
# 移除
|
||||||
|
.\tools\Register-BackupTask.ps1 -Remove
|
||||||
|
```
|
||||||
|
|
||||||
|
任务会调用 `Backup.ps1`,脚本自身写日志并按失败数返回退出码,所以「上次运行结果」在任务计划程序里是可读的。
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 单元测试:解析、命名、排除参数翻译、命令行拼接、manifest、配置 + 真实 7z 集成
|
||||||
|
.\tests\Run-Tests.ps1
|
||||||
|
|
||||||
|
# 端到端验收:备份 -> 验证排除 -> 删源 -> 恢复 -> 逐字节对拍(全程在临时目录)
|
||||||
|
.\tests\Run-E2E.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
零依赖,不需要 Pester(本机只有 3.4.0,`Should -Be` 会直接语法错误)。
|
||||||
|
|
||||||
|
## 相对旧版修了什么
|
||||||
|
|
||||||
|
| 问题 | 旧行为 | 现行为 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `Start-Process -PassThru` 的 `ExitCode` 在 PowerShell 7.7.0-preview.4 上恒为 `$null` | 压缩明明成功(`Everything is Ok`)却报"压缩失败",`exit 2 → 删档重试` 的自愈分支永远不可达 | 用 `.NET Process` 继承控制台启动,退出码可靠 |
|
||||||
|
| 排除模式写成 `-x!"路径"` | 引号成为模式的一部分,**排除对所有条目都失效** | 不再嵌引号;含空格自动转 `?`,`!` 前缀走 `-xr!` |
|
||||||
|
| 解析器用 `;` 分隔,清单里写的是 `,` | 整串被当成一个模式,等于没有排除 | `,` 与 `;` 都支持 |
|
||||||
|
| `^"([^"]+)"` 贪婪匹配 | 整行加引号的写法把排除表吞进路径 → 该条目被静默跳过,且 2.8 GB 归档成了找不到的孤儿 | 先按 `::` 切分再处理引号 |
|
||||||
|
| 直接更新已有归档(7z `u`) | 固实归档下收益极小,且排除规则与"源里已删的文件"永远反映不到归档里 | 临时文件 → `7z t` 校验 → 原子替换 |
|
||||||
|
| 没有校验、没有记录 | 中断留下的半个归档会被下次 `u` 续写;跳过/失败只有一行滚过去的 WARN | 校验 + 原子替换 + `manifest.json` + 日志文件 |
|
||||||
|
| 结尾不 `exit` | 全部失败也返回 0,计划任务永远显示成功 | 有失败返回 1 |
|
||||||
|
| 恢复用 `-Filter "$baseName.*"` | 含 `[` `]` 的路径会失配 | 精确比较 `BaseName`,且优先查 manifest |
|
||||||
|
| tar 分支 `$LASTEXITCODE -ne 0 -and $proc.ExitCode -ne 0` | `$LASTEXITCODE` 是上一条原生命令的残留值,恰为 0 时把解压失败吞掉 | 三条分支统一走同一个取退出码的封装 |
|
||||||
|
| 恢复没有干跑 | 直接覆盖 `E:\CodeSpace`、Edge User Data 这类真实目录 | `-WhatIf` / `-DryRun` / `-VerifyOnly` / `-Only` |
|
||||||
|
| 没有 manifest、没有测试、没有 README、不是 git 仓库 | — | 都有 |
|
||||||
|
|
||||||
|
## 设计取舍(有意为之,不是遗漏)
|
||||||
|
|
||||||
|
- **放弃 7z 的更新模式(`u`)。** 7z 默认固实压缩,`u` 本来就要重压大部分数据,收益很小,却让"排除规则改动"和"源里删掉的文件"永远进不了归档。现在每次都从零打包,代价是改动的条目会全量重压,换来的是归档与清单语义一致。
|
||||||
|
- **不捕获压缩工具的输出。** 结构化记录交给日志与 `manifest.json`;捕获子进程 stdio 需要额外管道,在受限环境里会直接失败,而实时进度对交互式使用更有用。需要安静就跑 `-QuietTool`。
|
||||||
|
- **有警告(退出码 1)时不覆盖完整的归档。** 被占用的文件(最典型的是正在运行的 Edge / 浏览器)会让 7z 返回 1,此时新归档是**不完整**的。实测:Edge 运行时打包,118 个文件读不到,其中包含 `Login Data`(密码)、`Cookies`、`History`、`Web Data` —— 恰恰是最不可再生的那部分。所以只要在位的归档是完整的(manifest `warnings=false`),脚本就**保留它、报失败、退出码 1**,不会用残缺归档把它换掉。确认可以接受再显式加 `-AcceptWarnings`。
|
||||||
|
- **源路径不存在只算"跳过",不算失败。** 清单里留着已不存在的路径(例如换过盘的 `E:\CodeSpace`)是正常的,它会以 `missing-source` 记进 manifest。失败只统计真正打不开的条目。
|
||||||
|
- **`DefaultExcludes` 只影响打包,不影响恢复。**
|
||||||
|
|
||||||
|
## 已知限制
|
||||||
|
|
||||||
|
- 归档名与路径强耦合:改清单里的路径写法会生成新归档名。恢复时 manifest 能兜底,但**别轻易改已备份条目的路径写法**。
|
||||||
|
- 路径里本来就含 `+` 或 `_from_` 时,仅靠文件名无法可靠反推路径,此时依赖 `manifest.json`。
|
||||||
|
- `-Snapshot` 目前是"复制一份带时间戳的副本",不做自动轮转清理(`KeepCount` / `KeepDays` 已保留在配置里,清理逻辑尚未实现)。
|
||||||
|
- 加密归档的常规备份/恢复不依赖 `RAR`;`RAR` 与内置 `ZIP` 分支仅作降级,未做加密支持(ZIP 明确拒绝加密请求)。
|
||||||
+411
@@ -0,0 +1,411 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
按 BackupList.txt 执行恢复。
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
与旧版相比的核心变化:
|
||||||
|
|
||||||
|
1. 归档查找以 manifest.json 为准(按归档基础名索引),拿不到才退回
|
||||||
|
"从文件名反推路径"。旧版只靠文件名反推,且用 -Filter "$baseName.*" 通配匹配,
|
||||||
|
一旦解析出偏差,归档就变成谁都找不到的孤儿。
|
||||||
|
2. 退出码可靠:三条解压分支(7z / RAR / tar)统一走 Invoke-ExternalCommand。
|
||||||
|
旧版 tar 分支写成 `$LASTEXITCODE -ne 0 -and $proc.ExitCode -ne 0`,
|
||||||
|
而 $LASTEXITCODE 是上一条原生命令的残留值,跟 Start-Process 无关,
|
||||||
|
恰为 0 时会把解压失败吞掉并报成功。
|
||||||
|
3. 支持 -WhatIf / -DryRun:恢复是会覆盖 E:\CodeSpace、Edge User Data 这种
|
||||||
|
真实目录的破坏性操作,必须能先看清单再决定。
|
||||||
|
4. 支持 -Only / -Skip 只恢复指定条目,-VerifyOnly 只校验不写盘。
|
||||||
|
5. 结尾按失败数 exit。
|
||||||
|
#>
|
||||||
|
|
||||||
|
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
|
||||||
|
param(
|
||||||
|
[Parameter()]
|
||||||
|
[string]$BackupListPath = (Join-Path $PSScriptRoot 'BackupList.txt'),
|
||||||
|
|
||||||
|
[Parameter()]
|
||||||
|
[string]$BackupDir,
|
||||||
|
|
||||||
|
[Parameter()]
|
||||||
|
[string]$ConfigPath = (Join-Path $PSScriptRoot 'BackupConfig.psd1'),
|
||||||
|
|
||||||
|
[Parameter()]
|
||||||
|
[string]$KeyFile,
|
||||||
|
|
||||||
|
[Parameter()]
|
||||||
|
[string[]]$Only = @(),
|
||||||
|
|
||||||
|
[Parameter()]
|
||||||
|
[string[]]$Skip = @(),
|
||||||
|
|
||||||
|
# 忽略"目标比归档新"的保护,强制解压
|
||||||
|
[Parameter()]
|
||||||
|
[switch]$Force,
|
||||||
|
|
||||||
|
# 只打印计划,不解压(等价于 -WhatIf)
|
||||||
|
[Parameter()]
|
||||||
|
[switch]$DryRun,
|
||||||
|
|
||||||
|
# 只对归档做 7z t 校验,不解压
|
||||||
|
[Parameter()]
|
||||||
|
[switch]$VerifyOnly
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
if ($DryRun) { $WhatIfPreference = $true }
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 载入依赖
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
$modulePath = Join-Path $PSScriptRoot 'Common.psm1'
|
||||||
|
if (-not (Test-Path -LiteralPath $modulePath)) {
|
||||||
|
Write-Error "找不到依赖模块:$modulePath,请确保所有文件在同一目录。"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
Import-Module $modulePath -Force
|
||||||
|
|
||||||
|
if ($PSBoundParameters.ContainsKey('Verbose')) { Set-BaknretDebug }
|
||||||
|
|
||||||
|
$script:Config = Get-BaknretConfig -Path $ConfigPath
|
||||||
|
$SupportedFormats = @('.7z', '.rar', '.zip', '.tar')
|
||||||
|
|
||||||
|
function Resolve-ConfigPath {
|
||||||
|
param([string]$Path, [string]$Default)
|
||||||
|
$value = if ($Path) { $Path } else { $Default }
|
||||||
|
if (-not [System.IO.Path]::IsPathRooted($value)) {
|
||||||
|
$value = Join-Path $PSScriptRoot $value
|
||||||
|
}
|
||||||
|
return $value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $BackupDir) { $BackupDir = Resolve-ConfigPath -Path $null -Default $script:Config.BackupDir }
|
||||||
|
$logDir = Resolve-ConfigPath -Path $null -Default $script:Config.LogDir
|
||||||
|
$manifestPath = Join-Path $BackupDir 'manifest.json'
|
||||||
|
|
||||||
|
$logPath = Start-BaknretLog -Directory $logDir -Prefix 'restore'
|
||||||
|
Write-Log "日志文件:$logPath"
|
||||||
|
Write-Log "备份目录:$BackupDir"
|
||||||
|
if ($WhatIfPreference) { Write-Log '试运行模式(-WhatIf / -DryRun):不会写入任何文件' -Level WARN }
|
||||||
|
|
||||||
|
if (-not (Test-Administrator)) {
|
||||||
|
Write-Log '建议以管理员身份运行以获取完整的目录访问权限' -Level WARN
|
||||||
|
}
|
||||||
|
|
||||||
|
$passwordFile = if ($KeyFile) { $KeyFile } else { $script:Config.Encryption.PasswordFile }
|
||||||
|
$password = Get-BaknretPassword -PasswordFile $passwordFile
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 归档查找
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
function Find-ArchiveByBaseName {
|
||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
按归档基础名精确定位归档文件。
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
旧版用 Get-ChildItem -Filter "$baseName.*",-Filter 会做通配符解释,
|
||||||
|
路径里含 `[` `]` 时会失配;这里改为精确比较 BaseName。
|
||||||
|
#>
|
||||||
|
param([string]$BaseName)
|
||||||
|
|
||||||
|
$candidate = Get-ChildItem -LiteralPath $BackupDir -File -Force -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object { $_.BaseName -eq $BaseName -and $_.Extension.ToLower() -in $SupportedFormats } |
|
||||||
|
Select-Object -First 1
|
||||||
|
return $candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-ArchiveForEntry {
|
||||||
|
param($Entry, $Manifest)
|
||||||
|
|
||||||
|
if ($Manifest -and $Manifest.items.Contains($Entry.baseName)) {
|
||||||
|
$record = $Manifest.items[$Entry.baseName]
|
||||||
|
$archiveName = $null
|
||||||
|
if ($record.PSObject.Properties.Name -contains 'archive') { $archiveName = $record.archive }
|
||||||
|
if ($archiveName) {
|
||||||
|
$path = Join-Path $BackupDir $archiveName
|
||||||
|
if (Test-Path -LiteralPath $path) {
|
||||||
|
return [pscustomobject]@{ File = (Get-Item -LiteralPath $path); Source = 'manifest'; Record = $record }
|
||||||
|
}
|
||||||
|
Write-Log "manifest 记录的归档不存在,回退按文件名查找:$archiveName" -Level WARN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$fallback = Find-ArchiveByBaseName -BaseName $Entry.baseName
|
||||||
|
if ($fallback) {
|
||||||
|
$record = $null
|
||||||
|
if ($Manifest -and $Manifest.items.Contains($Entry.baseName)) { $record = $Manifest.items[$Entry.baseName] }
|
||||||
|
return [pscustomobject]@{ File = $fallback; Source = 'filename'; Record = $record }
|
||||||
|
}
|
||||||
|
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-Extraction {
|
||||||
|
param([object]$ArchiveFile, [string]$DestinationPath)
|
||||||
|
|
||||||
|
$extension = $ArchiveFile.Extension.ToLower()
|
||||||
|
$destParent = Split-Path -Path $DestinationPath -Parent
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $destParent)) {
|
||||||
|
New-Item -ItemType Directory -Path $destParent -Force | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
$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
|
||||||
|
$argument = @('x', '-bsp2', '-y', "-o$destParent")
|
||||||
|
if ($password) { $argument += "-p$password" }
|
||||||
|
$argument += $ArchiveFile.FullName
|
||||||
|
|
||||||
|
$exitCode = Invoke-ExternalCommand -FilePath $sevenZip -ArgumentList $argument
|
||||||
|
if ($exitCode -ne 0) { throw "7z 解压失败(退出码:$exitCode)" }
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
|
switch ($extension) {
|
||||||
|
'.rar' {
|
||||||
|
$rarExe = Get-Command rar, unrar -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source
|
||||||
|
if (-not $rarExe) { throw '未找到 RAR 工具' }
|
||||||
|
Write-Log '使用 RAR 解压' -Level DEBUG
|
||||||
|
$argument = @('x', '-idp', '-idn', '-y', $ArchiveFile.FullName, "$destParent\")
|
||||||
|
$exitCode = Invoke-ExternalCommand -FilePath $rarExe -ArgumentList $argument
|
||||||
|
if ($exitCode -ne 0) { throw "RAR 解压失败(退出码:$exitCode)" }
|
||||||
|
}
|
||||||
|
'.zip' {
|
||||||
|
Write-Log '使用内置 ZIP 解压' -Level DEBUG
|
||||||
|
Expand-Archive -LiteralPath $ArchiveFile.FullName -DestinationPath $destParent -Force
|
||||||
|
}
|
||||||
|
'.tar' {
|
||||||
|
$tarExe = Get-Command tar -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source
|
||||||
|
if (-not $tarExe) { throw '未找到 TAR 工具' }
|
||||||
|
Write-Log '使用 TAR 解压' -Level DEBUG
|
||||||
|
$exitCode = Invoke-ExternalCommand -FilePath $tarExe -ArgumentList @('-xf', $ArchiveFile.FullName, '-C', $destParent)
|
||||||
|
if ($exitCode -ne 0) { throw "TAR 解压失败(退出码:$exitCode)" }
|
||||||
|
}
|
||||||
|
default { throw "不支持的文件格式:$extension" }
|
||||||
|
}
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 准备
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $BackupDir)) {
|
||||||
|
Write-Log "备份目录不存在: $BackupDir" -Level ERROR
|
||||||
|
Stop-BaknretLog
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$manifest = Read-BaknretManifest -Path $manifestPath
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $BackupListPath)) {
|
||||||
|
Write-Log '未找到配置文件,正在从备份内容生成...' -Level INFO
|
||||||
|
|
||||||
|
$paths = @()
|
||||||
|
|
||||||
|
if ($manifest.items.Count -gt 0) {
|
||||||
|
foreach ($key in $manifest.items.Keys) {
|
||||||
|
$record = $manifest.items[$key]
|
||||||
|
if ($record.PSObject.Properties.Name -contains 'source' -and $record.source) {
|
||||||
|
$paths += $record.source
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($paths.Count -eq 0) {
|
||||||
|
$backupFiles = Get-ChildItem -LiteralPath $BackupDir -File -Force -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object { $_.Extension.ToLower() -in $SupportedFormats -and $_.BaseName -match '_from_' }
|
||||||
|
foreach ($file in $backupFiles) {
|
||||||
|
$original = Convert-BackupFileNameToPath -FileName $file.Name
|
||||||
|
if ($original) { $paths += $original }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$paths = @($paths | Sort-Object -Unique)
|
||||||
|
if ($paths.Count -eq 0) {
|
||||||
|
Write-Log '无法从备份内容还原出任何路径。' -Level ERROR
|
||||||
|
Stop-BaknretLog
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$content = "# BackupList.txt(自动生成,排除规则需要手工补回)`n" + (($paths -join [Environment]::NewLine) + [Environment]::NewLine)
|
||||||
|
[System.IO.File]::WriteAllText($BackupListPath, $content, [System.Text.UTF8Encoding]::new($false))
|
||||||
|
Write-Log "已生成配置,包含 $($paths.Count) 个项目,请检查后重新运行" -Level INFO
|
||||||
|
Stop-BaknretLog
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-EntrySelected {
|
||||||
|
param([string]$DisplayPath, [string]$BaseName)
|
||||||
|
if ($Only.Count -gt 0) {
|
||||||
|
$matched = $false
|
||||||
|
foreach ($pattern in $Only) {
|
||||||
|
if ($DisplayPath -like $pattern -or $BaseName -like $pattern) { $matched = $true; break }
|
||||||
|
}
|
||||||
|
if (-not $matched) { return $false }
|
||||||
|
}
|
||||||
|
foreach ($pattern in $Skip) {
|
||||||
|
if ($DisplayPath -like $pattern -or $BaseName -like $pattern) { return $false }
|
||||||
|
}
|
||||||
|
return $true
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 主流程
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
$lines = Get-Content -LiteralPath $BackupListPath -ErrorAction Stop
|
||||||
|
$stats = @{ restored = 0; skipped = 0; failed = 0; verified = 0; planned = 0 }
|
||||||
|
$failures = @()
|
||||||
|
$referencedArchives = @()
|
||||||
|
|
||||||
|
Write-Log '开始执行恢复' -Level INFO
|
||||||
|
|
||||||
|
foreach ($line in $lines) {
|
||||||
|
$item = ConvertFrom-BackupListLine -Line $line
|
||||||
|
if (-not $item) { continue }
|
||||||
|
|
||||||
|
$displayPath = $item.Path
|
||||||
|
$destPath = [Environment]::ExpandEnvironmentVariables($displayPath)
|
||||||
|
$baseName = Get-BackupBaseName -RawPath $displayPath
|
||||||
|
|
||||||
|
if (-not $baseName) { $stats.skipped++; continue }
|
||||||
|
if (-not (Test-EntrySelected -DisplayPath $displayPath -BaseName $baseName)) { continue }
|
||||||
|
|
||||||
|
$found = Get-ArchiveForEntry -Entry ([pscustomobject]@{ baseName = $baseName }) -Manifest $manifest
|
||||||
|
if (-not $found) {
|
||||||
|
Write-Log "跳过: $displayPath,未找到归档 $baseName" -Level WARN
|
||||||
|
$stats.skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
$archiveFile = $found.File
|
||||||
|
$referencedArchives += $archiveFile.BaseName
|
||||||
|
|
||||||
|
# 加密归档在取不到口令时必须直接失败:7z 在没有 -p 时会在控制台等输入,
|
||||||
|
# 在计划任务里会静默挂起,比报错更糟。
|
||||||
|
$isEncrypted = $false
|
||||||
|
if ($found.Record -and ($found.Record.PSObject.Properties.Name -contains 'encrypted')) {
|
||||||
|
$isEncrypted = [bool]$found.Record.encrypted
|
||||||
|
}
|
||||||
|
if ($isEncrypted -and -not $password) {
|
||||||
|
Write-Log "失败: $displayPath,归档已加密但取不到口令(设置 BAKNRET_PASSWORD 或用 -KeyFile 指定密码文件)" -Level ERROR
|
||||||
|
$stats.failed++
|
||||||
|
$failures += $displayPath
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($VerifyOnly) {
|
||||||
|
if ($archiveFile.Extension.ToLower() -ne '.7z') {
|
||||||
|
Write-Log "跳过校验(非 7z): $($archiveFile.Name)" -Level DEBUG
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
$verifyCode = Invoke-ExternalCommand -FilePath (Get-Command 7z | Select-Object -First 1 -ExpandProperty Source) `
|
||||||
|
-ArgumentList @('t', '-bso0', '-bsp0', $archiveFile.FullName)
|
||||||
|
if ($verifyCode -eq 0) {
|
||||||
|
Write-Log "校验通过: $($archiveFile.Name)" -Level INFO
|
||||||
|
$stats.verified++
|
||||||
|
} else {
|
||||||
|
Write-Log "校验失败: $($archiveFile.Name)(退出码 $verifyCode)" -Level ERROR
|
||||||
|
$stats.failed++
|
||||||
|
$failures += $displayPath
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Log "准备恢复: $displayPath <- $($archiveFile.Name)(来源:$($found.Source))" -Level INFO
|
||||||
|
|
||||||
|
if ((Test-Path -LiteralPath $destPath) -and -not $Force) {
|
||||||
|
try {
|
||||||
|
$destSummary = Get-FolderSummary -FolderPath $destPath
|
||||||
|
$archiveTime = $archiveFile.LastWriteTime
|
||||||
|
if ($destSummary.LatestModifiedTime -and $destSummary.LatestModifiedTime -gt $archiveTime) {
|
||||||
|
Write-Log "跳过: $displayPath,目标目录比归档新(用 -Force 覆盖)" -Level WARN
|
||||||
|
$stats.skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Log "目标目录摘要读取失败,继续恢复:$_" -Level DEBUG
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $PSCmdlet.ShouldProcess($destPath, "从 $($archiveFile.Name) 解压")) {
|
||||||
|
Write-Log "[试运行] 将解压 $($archiveFile.Name) -> $(Split-Path -Path $destPath -Parent)" -Level INFO
|
||||||
|
$stats.planned++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (Invoke-Extraction -ArchiveFile $archiveFile -DestinationPath $destPath) {
|
||||||
|
$stats.restored++
|
||||||
|
Write-Log "恢复成功: $baseName" -Level INFO
|
||||||
|
|
||||||
|
if ($manifest.items.Contains($baseName)) {
|
||||||
|
$record = $manifest.items[$baseName]
|
||||||
|
if ($record -is [System.Collections.IDictionary]) {
|
||||||
|
$record['lastRestoreAt'] = (Get-Date).ToString('o')
|
||||||
|
} else {
|
||||||
|
$record | Add-Member -NotePropertyName lastRestoreAt -NotePropertyValue ((Get-Date).ToString('o')) -Force
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$stats.failed++
|
||||||
|
$failures += $displayPath
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Log "恢复失败: $displayPath,$_" -Level ERROR
|
||||||
|
$stats.failed++
|
||||||
|
$failures += $displayPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 收尾:报告孤儿归档
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
if (-not $VerifyOnly) {
|
||||||
|
$orphans = @(Get-ChildItem -LiteralPath $BackupDir -File -Force -ErrorAction SilentlyContinue |
|
||||||
|
Where-Object { $_.Extension.ToLower() -in $SupportedFormats -and $_.BaseName -notin $referencedArchives })
|
||||||
|
|
||||||
|
if ($orphans.Count -gt 0) {
|
||||||
|
Write-Log '以下归档没有任何清单条目指向(恢复不到,注意别误删):' -Level WARN
|
||||||
|
foreach ($orphan in $orphans) {
|
||||||
|
Write-Log (" - {0}({1} MB,{2})" -f $orphan.Name, [math]::Round($orphan.Length / 1MB, 2), $orphan.LastWriteTime) -Level WARN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Write-BaknretManifest -Path $manifestPath -Manifest $manifest | Out-Null
|
||||||
|
} catch {
|
||||||
|
Write-Log "manifest 写回失败(不影响本次恢复):$_" -Level WARN
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($failures.Count -gt 0) {
|
||||||
|
Write-Log '失败条目:' -Level ERROR
|
||||||
|
foreach ($failure in $failures) { Write-Log " - $failure" -Level ERROR }
|
||||||
|
}
|
||||||
|
|
||||||
|
$summaryText = "恢复完成 - 成功:$($stats.restored),跳过:$($stats.skipped),失败:$($stats.failed)"
|
||||||
|
if ($VerifyOnly) { $summaryText = "校验完成 - 通过:$($stats.verified),失败:$($stats.failed)" }
|
||||||
|
if ($stats.planned -gt 0) { $summaryText += ",试运行计划:$($stats.planned)" }
|
||||||
|
Write-Log $summaryText -Level INFO
|
||||||
|
|
||||||
|
$logPath = Get-BaknretLogPath
|
||||||
|
if ($logPath) { Write-Log "日志已写入:$logPath" -Level INFO }
|
||||||
|
Stop-BaknretLog
|
||||||
|
|
||||||
|
if ($stats.failed -gt 0) { exit 1 }
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
BakNRet 端到端验收:真实备份 -> 校验排除 -> 删源 -> 恢复 -> 逐字节对拍。
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
单元测试只验证函数行为,这个脚本验证整条链路真的能用:
|
||||||
|
1. 造一个含可排除内容的源目录(目录名故意带空格,顺带验证命令行引用);
|
||||||
|
2. 跑 Backup.ps1,断言退出码为 0、归档生成、manifest 记录正确;
|
||||||
|
3. 解压归档,断言被排除的内容确实不在里面;
|
||||||
|
4. 删掉源目录,跑 Restore.ps1,断言文件逐字节还原、被排除的内容没有被还原;
|
||||||
|
5. 断言 Backup -DryRun 与 Restore -DryRun 都不写盘;
|
||||||
|
6. 源路径不存在时记为 missing-source,而不是静默忽略。
|
||||||
|
|
||||||
|
全程只在临时目录里操作,不会碰到真实备份。
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
pwsh -File .\tests\Run-E2E.ps1
|
||||||
|
#>
|
||||||
|
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$WorkRoot,
|
||||||
|
[switch]$KeepWorkRoot
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
Import-Module (Join-Path $PSScriptRoot 'TestHelpers.psm1') -Force
|
||||||
|
|
||||||
|
$projectRoot = Split-Path -Parent $PSScriptRoot
|
||||||
|
$backupScript = Join-Path $projectRoot 'Backup.ps1'
|
||||||
|
$restoreScript = Join-Path $projectRoot 'Restore.ps1'
|
||||||
|
Import-Module (Join-Path $projectRoot 'Common.psm1') -Force
|
||||||
|
|
||||||
|
Reset-TestResult
|
||||||
|
|
||||||
|
if (-not $WorkRoot) {
|
||||||
|
$WorkRoot = Join-Path $env:TEMP ('bnr-' + [guid]::NewGuid().ToString('N').Substring(0, 6))
|
||||||
|
}
|
||||||
|
|
||||||
|
$sourceParent = Join-Path $WorkRoot 'src'
|
||||||
|
$source = Join-Path $sourceParent 'My Code Space' # 名字带空格,专门压一下命令行引用
|
||||||
|
$backupDir = Join-Path $WorkRoot 'Backups'
|
||||||
|
$listPath = Join-Path $WorkRoot 'list.txt'
|
||||||
|
$dryBackupDir = Join-Path $WorkRoot 'Backups-dry'
|
||||||
|
$verifyDir = Join-Path $WorkRoot 'verify'
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host '== 端到端:备份 -> 排除 -> 删源 -> 恢复 -> 对拍 ==' -ForegroundColor Cyan
|
||||||
|
Write-Host " 工作目录:$WorkRoot"
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 1. 造数据
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
foreach ($dir in 'logs', 'sub', 'Cache') {
|
||||||
|
New-Item -ItemType Directory -Path (Join-Path $source $dir) -Force | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
Set-Content -LiteralPath (Join-Path $source 'keep.txt') -Value 'keep-me' -Encoding UTF8
|
||||||
|
Set-Content -LiteralPath (Join-Path $source 'sub\b.txt') -Value 'keep-me-too' -Encoding UTF8
|
||||||
|
Set-Content -LiteralPath (Join-Path $source 'logs\a.log') -Value 'must-be-excluded' -Encoding UTF8
|
||||||
|
Set-Content -LiteralPath (Join-Path $source 'Cache\c.bin') -Value 'must-be-excluded-too' -Encoding UTF8
|
||||||
|
|
||||||
|
# 一个确定性随机二进制文件,用于逐字节对拍
|
||||||
|
$blob = New-Object byte[] 8192
|
||||||
|
(New-Object System.Random 42).NextBytes($blob)
|
||||||
|
[System.IO.File]::WriteAllBytes((Join-Path $source 'blob.bin'), $blob)
|
||||||
|
|
||||||
|
[System.IO.File]::WriteAllText($listPath, "# e2e`n$source :: logs\,!*Cache`n", [System.Text.UTF8Encoding]::new($false))
|
||||||
|
|
||||||
|
$expectedHashes = @{}
|
||||||
|
foreach ($relative in 'keep.txt', 'sub\b.txt', 'blob.bin') {
|
||||||
|
$expectedHashes[$relative] = (Get-FileHash -LiteralPath (Join-Path $source $relative) -Algorithm SHA256).Hash
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 2. 备份
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
& $backupScript -BackupListPath $listPath -BackupDir $backupDir -Force -QuietTool
|
||||||
|
$backupExitCode = $LASTEXITCODE
|
||||||
|
|
||||||
|
Test-Case '备份退出码为 0(旧实现会把成功的压缩判成失败)' {
|
||||||
|
Assert-Equal 0 $backupExitCode
|
||||||
|
}
|
||||||
|
|
||||||
|
$archives = @(Get-ChildItem -LiteralPath $backupDir -File -Filter *.7z -ErrorAction SilentlyContinue)
|
||||||
|
|
||||||
|
Test-Case '归档已生成' {
|
||||||
|
Assert-Equal 1 $archives.Count
|
||||||
|
Assert-True ($archives[0].Length -gt 0) '归档不应为空'
|
||||||
|
}
|
||||||
|
|
||||||
|
$manifestPath = Join-Path $backupDir 'manifest.json'
|
||||||
|
|
||||||
|
Test-Case 'manifest 记录了条目、动作与校验结果' {
|
||||||
|
Assert-FileExists $manifestPath
|
||||||
|
$manifest = Read-BaknretManifest -Path $manifestPath
|
||||||
|
Assert-Equal 1 $manifest.items.Count
|
||||||
|
|
||||||
|
$record = $manifest.items[$archives[0].BaseName]
|
||||||
|
Assert-True ($null -ne $record) 'manifest 里应有对应条目'
|
||||||
|
Assert-Equal 'backed-up' $record.action
|
||||||
|
Assert-Equal $true $record.verified
|
||||||
|
Assert-Equal 0 $record.exitCode
|
||||||
|
Assert-Equal $source $record.source
|
||||||
|
Assert-True ($record.sourceFiles -ge 4) '源文件数应不少于 4'
|
||||||
|
}
|
||||||
|
|
||||||
|
Test-Case '备份过程写了日志文件' {
|
||||||
|
$logDir = Join-Path $projectRoot 'logs'
|
||||||
|
$logs = @(Get-ChildItem -LiteralPath $logDir -File -Filter 'backup-*.log' -ErrorAction SilentlyContinue)
|
||||||
|
Assert-True ($logs.Count -gt 0) '应生成 backup-*.log'
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 3. 解压归档,验证排除真的生效
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Path $verifyDir -Force | Out-Null
|
||||||
|
$sevenZip = Get-Command 7z -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source
|
||||||
|
|
||||||
|
if ($sevenZip) {
|
||||||
|
$null = Invoke-ExternalCommand -FilePath $sevenZip `
|
||||||
|
-ArgumentList @('x', '-bso0', '-bsp0', '-y', "-o$verifyDir", $archives[0].FullName)
|
||||||
|
}
|
||||||
|
|
||||||
|
Test-Case '归档里保留了应当保留的内容' {
|
||||||
|
Assert-FileExists (Join-Path $verifyDir 'My Code Space\keep.txt')
|
||||||
|
Assert-FileExists (Join-Path $verifyDir 'My Code Space\sub\b.txt')
|
||||||
|
Assert-FileExists (Join-Path $verifyDir 'My Code Space\blob.bin')
|
||||||
|
}
|
||||||
|
|
||||||
|
Test-Case '归档里不含被排除的 logs\ 与 !*Cache 命中项' {
|
||||||
|
Assert-FileMissing (Join-Path $verifyDir 'My Code Space\logs\a.log') 'logs\ 应被排除'
|
||||||
|
Assert-FileMissing (Join-Path $verifyDir 'My Code Space\Cache\c.bin') '!*Cache 应命中 Cache 目录'
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 4. 删源后恢复,逐字节对拍
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
Remove-Item -LiteralPath $source -Recurse -Force
|
||||||
|
|
||||||
|
Test-Case '源目录确实已被删除(保证下面的恢复不是空操作)' {
|
||||||
|
Assert-FileMissing $source
|
||||||
|
}
|
||||||
|
|
||||||
|
& $restoreScript -BackupListPath $listPath -BackupDir $backupDir -Force
|
||||||
|
$restoreExitCode = $LASTEXITCODE
|
||||||
|
|
||||||
|
Test-Case '恢复退出码为 0' {
|
||||||
|
Assert-Equal 0 $restoreExitCode
|
||||||
|
}
|
||||||
|
|
||||||
|
Test-Case '恢复出的文件与源逐字节一致' {
|
||||||
|
foreach ($relative in $expectedHashes.Keys) {
|
||||||
|
$restored = Join-Path $source $relative
|
||||||
|
Assert-FileExists $restored
|
||||||
|
Assert-Equal $expectedHashes[$relative] (Get-FileHash -LiteralPath $restored -Algorithm SHA256).Hash "对拍 $relative"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Test-Case '被排除的内容没有被恢复出来' {
|
||||||
|
Assert-FileMissing (Join-Path $source 'logs\a.log')
|
||||||
|
Assert-FileMissing (Join-Path $source 'Cache\c.bin')
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 4.5 保护规则:有警告时不拿不完整的归档覆盖完整归档
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
$lockSource = Join-Path $sourceParent 'Locked Case'
|
||||||
|
$lockBackupDir = Join-Path $WorkRoot 'Backups-lock'
|
||||||
|
$lockList = Join-Path $WorkRoot 'lock.txt'
|
||||||
|
New-Item -ItemType Directory -Path $lockSource -Force | Out-Null
|
||||||
|
Set-Content -LiteralPath (Join-Path $lockSource 'a.txt') -Value 'aaa' -Encoding UTF8
|
||||||
|
[System.IO.File]::WriteAllText($lockList, "$lockSource`n", [System.Text.UTF8Encoding]::new($false))
|
||||||
|
|
||||||
|
# 第一轮:没有占用,归档是"干净"的
|
||||||
|
& $backupScript -BackupListPath $lockList -BackupDir $lockBackupDir -Force -QuietTool
|
||||||
|
$cleanExitCode = $LASTEXITCODE
|
||||||
|
$cleanArchive = Get-ChildItem -LiteralPath $lockBackupDir -File -Filter *.7z | Select-Object -First 1
|
||||||
|
$cleanSize = $cleanArchive.Length
|
||||||
|
$lockManifestPath = Join-Path $lockBackupDir 'manifest.json'
|
||||||
|
$cleanRecord = (Read-BaknretManifest -Path $lockManifestPath).items[$cleanArchive.BaseName]
|
||||||
|
|
||||||
|
Test-Case '干净归档:退出码 0,manifest 记 warnings=false' {
|
||||||
|
Assert-Equal 0 $cleanExitCode
|
||||||
|
Assert-Equal 'backed-up' $cleanRecord.action
|
||||||
|
Assert-Equal $false $cleanRecord.warnings
|
||||||
|
}
|
||||||
|
|
||||||
|
# 第二轮:独占锁住一个文件,真实制造"有文件读不到"的 7z 警告
|
||||||
|
Set-Content -LiteralPath (Join-Path $lockSource 'b.txt') -Value 'bbb' -Encoding UTF8
|
||||||
|
$lockedPath = Join-Path $lockSource 'locked.bin'
|
||||||
|
Set-Content -LiteralPath $lockedPath -Value 'locked' -Encoding UTF8
|
||||||
|
$lockStream = [System.IO.File]::Open($lockedPath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::None)
|
||||||
|
|
||||||
|
try {
|
||||||
|
& $backupScript -BackupListPath $lockList -BackupDir $lockBackupDir -Force -QuietTool
|
||||||
|
$warnExitCode = $LASTEXITCODE
|
||||||
|
$afterArchive = Get-ChildItem -LiteralPath $lockBackupDir -File -Filter *.7z | Select-Object -First 1
|
||||||
|
$afterRecord = (Read-BaknretManifest -Path $lockManifestPath).items[$afterArchive.BaseName]
|
||||||
|
|
||||||
|
Test-Case '有警告时拒绝覆盖完整归档:退出码 1,旧归档原样保留' {
|
||||||
|
Assert-Equal 1 $warnExitCode
|
||||||
|
Assert-Equal $cleanSize $afterArchive.Length '在位归档应与被保护的那份完全一致'
|
||||||
|
Assert-Equal 'failed' $afterRecord.action
|
||||||
|
Assert-Equal $false $afterRecord.warnings '在位归档仍然是完整的'
|
||||||
|
Assert-Equal $true $afterRecord.attemptWarnings '这次尝试确实报了警告'
|
||||||
|
}
|
||||||
|
|
||||||
|
# 明确接受之后才允许覆盖
|
||||||
|
& $backupScript -BackupListPath $lockList -BackupDir $lockBackupDir -Force -QuietTool -AcceptWarnings
|
||||||
|
$acceptExitCode = $LASTEXITCODE
|
||||||
|
$acceptedRecord = (Read-BaknretManifest -Path $lockManifestPath).items[$afterArchive.BaseName]
|
||||||
|
|
||||||
|
Test-Case '-AcceptWarnings 允许覆盖,并在 manifest 记 warnings=true' {
|
||||||
|
Assert-Equal 0 $acceptExitCode
|
||||||
|
Assert-Equal 'backed-up' $acceptedRecord.action
|
||||||
|
Assert-Equal $true $acceptedRecord.warnings
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
$lockStream.Close()
|
||||||
|
$lockStream.Dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 5. DryRun 不写盘
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
if (Test-Path -LiteralPath $source) { Remove-Item -LiteralPath $source -Recurse -Force }
|
||||||
|
|
||||||
|
& $restoreScript -BackupListPath $listPath -BackupDir $backupDir -DryRun
|
||||||
|
$dryRestoreExitCode = $LASTEXITCODE
|
||||||
|
|
||||||
|
Test-Case 'Restore -DryRun:退出码 0、不创建目标目录' {
|
||||||
|
Assert-Equal 0 $dryRestoreExitCode
|
||||||
|
Assert-FileMissing $source
|
||||||
|
}
|
||||||
|
|
||||||
|
& $backupScript -BackupListPath $listPath -BackupDir $dryBackupDir -Force -QuietTool -DryRun
|
||||||
|
$dryBackupExitCode = $LASTEXITCODE
|
||||||
|
|
||||||
|
Test-Case 'Backup -DryRun:退出码 0、不写归档也不写 manifest' {
|
||||||
|
Assert-Equal 0 $dryBackupExitCode
|
||||||
|
if (Test-Path -LiteralPath $dryBackupDir) {
|
||||||
|
$written = @(Get-ChildItem -LiteralPath $dryBackupDir -File -ErrorAction SilentlyContinue)
|
||||||
|
Assert-Equal 0 $written.Count '试运行不应写入任何文件'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# 6. 失败路径:源不存在时必须留下可核对的记录
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
$missingList = Join-Path $WorkRoot 'missing.txt'
|
||||||
|
[System.IO.File]::WriteAllText($missingList, "Z:\definitely-not-here-12345`n", [System.Text.UTF8Encoding]::new($false))
|
||||||
|
& $backupScript -BackupListPath $missingList -BackupDir $backupDir -Force -QuietTool
|
||||||
|
$missingExitCode = $LASTEXITCODE
|
||||||
|
|
||||||
|
Test-Case '源路径不存在被记为 missing-source,退出码仍为 0(跳过不算失败)' {
|
||||||
|
Assert-Equal 0 $missingExitCode
|
||||||
|
$after = Read-BaknretManifest -Path $manifestPath
|
||||||
|
$record = $after.items['definitely-not-here-12345_from_Z_']
|
||||||
|
Assert-True ($null -ne $record) 'manifest 里应有该条目的记录'
|
||||||
|
Assert-Equal 'missing-source' $record.action
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
if ($KeepWorkRoot) {
|
||||||
|
Write-Host "`n工作目录保留在:$WorkRoot" -ForegroundColor Yellow
|
||||||
|
} else {
|
||||||
|
Remove-Item -LiteralPath $WorkRoot -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
|
|
||||||
|
$failed = Write-TestSummary -Title '端到端验收'
|
||||||
|
if ($failed -gt 0) { exit 1 }
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
BakNRet 测试套件(零依赖,Windows PowerShell 5.1 与 PowerShell 7.x 均可运行)。
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
为什么不用 Pester:本机只装了 Pester 3.4.0,`Should -Be` 这类 Pester 4+ 语法
|
||||||
|
直接语法错误,而为了跑测试去装 Pester 5 不值得。这里自带一个极简断言运行器,
|
||||||
|
对备份工具这种小脚本来说更可靠。
|
||||||
|
|
||||||
|
覆盖范围:
|
||||||
|
* BackupList.txt 语法解析(包含历史上出错的两种写法)
|
||||||
|
* 归档命名与逆向解析
|
||||||
|
* 7z 排除参数翻译(含空格、! 前缀、重复前缀、禁用引号)
|
||||||
|
* Windows 命令行拼接
|
||||||
|
* manifest 读写往返
|
||||||
|
* 配置默认值与嵌套合并
|
||||||
|
* 集成测试:真的调用 7z 打包 + 解压,验证排除结果落在文件系统上
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
pwsh -File .\tests\Run-Tests.ps1
|
||||||
|
#>
|
||||||
|
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$ModulePath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'Common.psm1')
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
Import-Module $ModulePath -Force
|
||||||
|
Import-Module (Join-Path $PSScriptRoot 'TestHelpers.psm1') -Force
|
||||||
|
|
||||||
|
Reset-TestResult
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
Write-Host "`n== BackupList 解析 ==" -ForegroundColor Cyan
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
Test-Case '注释与空行返回 $null' {
|
||||||
|
Assert-Null (ConvertFrom-BackupListLine -Line '# 这是注释')
|
||||||
|
Assert-Null (ConvertFrom-BackupListLine -Line ' ')
|
||||||
|
Assert-Null (ConvertFrom-BackupListLine -Line '')
|
||||||
|
}
|
||||||
|
|
||||||
|
Test-Case '裸路径' {
|
||||||
|
$r = ConvertFrom-BackupListLine -Line 'C:\Programs\FooClolor'
|
||||||
|
Assert-Equal 'C:\Programs\FooClolor' $r.Path
|
||||||
|
Assert-Equal 0 $r.ExcludePatterns.Count
|
||||||
|
Assert-Equal 0 $r.Flags.Count
|
||||||
|
}
|
||||||
|
|
||||||
|
Test-Case '逗号分隔的排除表被拆成多个模式(旧实现只认分号,这里曾经整串当成一个)' {
|
||||||
|
$r = ConvertFrom-BackupListLine -Line 'C:\Programs\March7thAssistant :: a\b,c\d\'
|
||||||
|
Assert-Equal 'C:\Programs\March7thAssistant' $r.Path
|
||||||
|
Assert-Equal 2 $r.ExcludePatterns.Count
|
||||||
|
Assert-Equal 'a\b' $r.ExcludePatterns[0]
|
||||||
|
Assert-Equal 'c\d\' $r.ExcludePatterns[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
Test-Case '分号分隔同样支持' {
|
||||||
|
$r = ConvertFrom-BackupListLine -Line 'C:\x :: a;b'
|
||||||
|
Assert-Equal 2 $r.ExcludePatterns.Count
|
||||||
|
}
|
||||||
|
|
||||||
|
Test-Case '引号只包路径时排除表正常解析' {
|
||||||
|
$r = ConvertFrom-BackupListLine -Line '"C:\Program Files\App" :: cache\*,logs\'
|
||||||
|
Assert-Equal 'C:\Program Files\App' $r.Path
|
||||||
|
Assert-Equal 2 $r.ExcludePatterns.Count
|
||||||
|
}
|
||||||
|
|
||||||
|
Test-Case '整行被一对引号包住时,:: 之后的排除表不被吞进路径(真实第 27 行)' {
|
||||||
|
$line = '"C:\Programs\ScoopApps\persist :: persist\a\DawnCache,persist\a\GPUCache"'
|
||||||
|
$r = ConvertFrom-BackupListLine -Line $line
|
||||||
|
Assert-Equal 'C:\Programs\ScoopApps\persist' $r.Path
|
||||||
|
Assert-Equal 2 $r.ExcludePatterns.Count
|
||||||
|
Assert-Equal 'persist\a\DawnCache' $r.ExcludePatterns[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
Test-Case '@ 标记单独出现' {
|
||||||
|
$r = ConvertFrom-BackupListLine -Line '%UserProfile%\.ssh @encrypt'
|
||||||
|
Assert-Equal '%UserProfile%\.ssh' $r.Path
|
||||||
|
Assert-Equal 1 $r.Flags.Count
|
||||||
|
Assert-Equal 'encrypt' $r.Flags[0]
|
||||||
|
Assert-Equal 0 $r.ExcludePatterns.Count
|
||||||
|
}
|
||||||
|
|
||||||
|
Test-Case '@ 标记跟在排除表后面' {
|
||||||
|
$r = ConvertFrom-BackupListLine -Line 'C:\x :: a,b @encrypt'
|
||||||
|
Assert-Equal 'C:\x' $r.Path
|
||||||
|
Assert-Equal 2 $r.ExcludePatterns.Count
|
||||||
|
Assert-Equal 'encrypt' $r.Flags[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
Write-Host "`n== 归档命名 ==" -ForegroundColor Cyan
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
Test-Case '基础命名规则' {
|
||||||
|
Assert-Equal 'legendary_from_%UserProfile%+.config' (Get-BackupBaseName -RawPath '%UserProfile%/.config/legendary')
|
||||||
|
Assert-Equal 'FooClolor_from_C_+Programs' (Get-BackupBaseName -RawPath 'C:\Programs\FooClolor')
|
||||||
|
Assert-Equal 'settings.json_from_C_+Programs' (Get-BackupBaseName -RawPath 'C:\Programs\settings.json')
|
||||||
|
}
|
||||||
|
|
||||||
|
Test-Case '/ 与 \ 以及重复分隔符结果一致' {
|
||||||
|
$a = Get-BackupBaseName -RawPath '%UserProfile%/.config/scoop'
|
||||||
|
$b = Get-BackupBaseName -RawPath '%UserProfile%\.config\scoop'
|
||||||
|
$c = Get-BackupBaseName -RawPath '%UserProfile%\\Documents\\WindowsPowerShell'
|
||||||
|
Assert-Equal $a $b
|
||||||
|
Assert-Equal 'WindowsPowerShell_from_%UserProfile%+Documents' $c
|
||||||
|
}
|
||||||
|
|
||||||
|
Test-Case '命名 <-> 路径往返' {
|
||||||
|
$cases = @(
|
||||||
|
'%UserProfile%/.config/legendary',
|
||||||
|
'C:\Programs\FooClolor',
|
||||||
|
'%LocalAppData%\Microsoft\Edge\User Data'
|
||||||
|
)
|
||||||
|
foreach ($case in $cases) {
|
||||||
|
$base = Get-BackupBaseName -RawPath $case
|
||||||
|
$back = Convert-BackupFileNameToPath -FileName "$base.7z"
|
||||||
|
Assert-Equal ($case -replace '[/\\]+', '\') $back "往返 $case"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Test-Case '归档名里不含非法文件名字符' {
|
||||||
|
$base = Get-BackupBaseName -RawPath 'C:\a\b?c*d|e'
|
||||||
|
$invalid = [System.IO.Path]::GetInvalidFileNameChars() | Where-Object { $_ -notin @('&', '%', '+') }
|
||||||
|
foreach ($ch in $invalid) {
|
||||||
|
Assert-False ($base.Contains([string]$ch)) "归档名里出现了非法字符 $ch"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
Write-Host "`n== 7z 排除参数翻译 ==" -ForegroundColor Cyan
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
Test-Case '相对模式自动补上归档根目录名' {
|
||||||
|
$exclude = Get-ArchiveExcludeArgument -ItemName 'March7thAssistant' -Patterns @('logs\')
|
||||||
|
Assert-Equal '-x!March7thAssistant\logs' $exclude[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
Test-Case '已经带根目录名时不重复前缀' {
|
||||||
|
$exclude = Get-ArchiveExcludeArgument -ItemName 'March7thAssistant' -Patterns @('March7thAssistant\logs\')
|
||||||
|
Assert-Equal '-x!March7thAssistant\logs' $exclude[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
Test-Case '! 前缀翻译成递归组件匹配' {
|
||||||
|
$exclude = Get-ArchiveExcludeArgument -ItemName 'User Data' -Patterns @('!*Cache')
|
||||||
|
Assert-Equal '-xr!*Cache' $exclude[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
Test-Case '模式里的空格转成 ? (7z 的模式不支持空格)' {
|
||||||
|
$exclude = Get-ArchiveExcludeArgument -ItemName 'User Data' -Patterns @('Default\Code Cache')
|
||||||
|
Assert-Equal '-x!User?Data\Default\Code?Cache' $exclude[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
Test-Case '生成的参数里绝不出现引号(旧实现 -x!"路径" 让排除全部失效)' {
|
||||||
|
$patterns = @('Default\Code Cache', '!*Cache', 'logs\', 'March7thAssistant\3rdparty\WebBrowser\UserProfile\Integrated')
|
||||||
|
foreach ($pattern in $patterns) {
|
||||||
|
foreach ($entry in (Get-ArchiveExcludeArgument -ItemName 'User Data' -Patterns @($pattern))) {
|
||||||
|
Assert-False ($entry.Contains('"')) "参数里出现了引号: $entry"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Test-Case '空模式被忽略' {
|
||||||
|
$exclude = Get-ArchiveExcludeArgument -ItemName 'x' -Patterns @('', ' ', 'real\')
|
||||||
|
Assert-Equal 1 $exclude.Count
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
Write-Host "`n== 命令行拼接 ==" -ForegroundColor Cyan
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
Test-Case '无空格参数原样输出' {
|
||||||
|
Assert-Equal 'a -t7z' (ConvertTo-NativeArgumentString -ArgumentList @('a', '-t7z'))
|
||||||
|
}
|
||||||
|
|
||||||
|
Test-Case '含空格参数加引号' {
|
||||||
|
Assert-Equal '"C:\Program Files\x"' (ConvertTo-NativeArgumentString -ArgumentList @('C:\Program Files\x'))
|
||||||
|
}
|
||||||
|
|
||||||
|
Test-Case '引号内的结尾反斜杠翻倍(否则会被当成转义引号)' {
|
||||||
|
Assert-Equal '"C:\Program Files\\"' (ConvertTo-NativeArgumentString -ArgumentList @('C:\Program Files\'))
|
||||||
|
}
|
||||||
|
|
||||||
|
Test-Case '内部引号被转义' {
|
||||||
|
Assert-Equal '"say \"hi\""' (ConvertTo-NativeArgumentString -ArgumentList @('say "hi"'))
|
||||||
|
}
|
||||||
|
|
||||||
|
Test-Case '空参数输出一对空引号' {
|
||||||
|
Assert-Equal '""' (ConvertTo-NativeArgumentString -ArgumentList @(''))
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
Write-Host "`n== manifest 与配置 ==" -ForegroundColor Cyan
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
$sandbox = Join-Path $env:TEMP ("baknret-tests-" + [guid]::NewGuid().ToString('N').Substring(0, 8))
|
||||||
|
|
||||||
|
Test-Case 'manifest 读写往返' {
|
||||||
|
New-Item -ItemType Directory -Path $sandbox -Force | Out-Null
|
||||||
|
$path = Join-Path $sandbox 'manifest.json'
|
||||||
|
|
||||||
|
$m = Read-BaknretManifest -Path $path
|
||||||
|
Assert-Equal 0 $m.items.Count
|
||||||
|
|
||||||
|
$m.compressor = [pscustomobject]@{ name = '7z'; version = '26.03' }
|
||||||
|
$m.items['FooClolor_from_C_+Programs'] = [ordered]@{ baseName = 'FooClolor_from_C_+Programs'; action = 'backed-up'; archiveBytes = 12345 }
|
||||||
|
Write-BaknretManifest -Path $path -Manifest $m | Out-Null
|
||||||
|
|
||||||
|
$again = Read-BaknretManifest -Path $path
|
||||||
|
Assert-Equal 1 $again.items.Count
|
||||||
|
Assert-Equal 'backed-up' $again.items['FooClolor_from_C_+Programs'].action
|
||||||
|
|
||||||
|
$bytes = [System.IO.File]::ReadAllBytes($path)
|
||||||
|
Assert-False (($bytes[0] -eq 0xEF) -and ($bytes[1] -eq 0xBB)) 'manifest 不应带 BOM'
|
||||||
|
}
|
||||||
|
|
||||||
|
Test-Case 'manifest 损坏时不抛异常' {
|
||||||
|
$path = Join-Path $sandbox 'broken.json'
|
||||||
|
[System.IO.File]::WriteAllText($path, '{ this is not json')
|
||||||
|
$m = Read-BaknretManifest -Path $path
|
||||||
|
Assert-Equal 0 $m.items.Count
|
||||||
|
}
|
||||||
|
|
||||||
|
Test-Case '配置缺失时返回默认值' {
|
||||||
|
$config = Get-BaknretConfig -Path (Join-Path $sandbox 'nope.psd1')
|
||||||
|
Assert-Equal 'Backups' $config.BackupDir
|
||||||
|
Assert-True ($config.MinFreeSpaceGB -gt 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
Test-Case '配置嵌套段落合并且不丢默认键' {
|
||||||
|
$path = Join-Path $sandbox 'cfg.psd1'
|
||||||
|
Set-Content -LiteralPath $path -Value "@{ Encryption = @{ Enabled = `$true }; CompressionLevel = 5 }"
|
||||||
|
$config = Get-BaknretConfig -Path $path
|
||||||
|
Assert-Equal 5 $config.CompressionLevel
|
||||||
|
Assert-True $config.Encryption.Enabled
|
||||||
|
Assert-True $config.Encryption.EncryptHeaders '未覆盖的嵌套键应保留默认值'
|
||||||
|
}
|
||||||
|
|
||||||
|
Test-Case '口令:环境变量可读取,取不到返回 $null' {
|
||||||
|
$old = $env:BAKNRET_PASSWORD
|
||||||
|
try {
|
||||||
|
Remove-Item Env:BAKNRET_PASSWORD -ErrorAction SilentlyContinue
|
||||||
|
Assert-Null (Get-BaknretPassword -PasswordFile (Join-Path $sandbox 'nope'))
|
||||||
|
$env:BAKNRET_PASSWORD = 'from-env'
|
||||||
|
Assert-Equal 'from-env' (Get-BaknretPassword)
|
||||||
|
} finally {
|
||||||
|
Remove-Item Env:BAKNRET_PASSWORD -ErrorAction SilentlyContinue
|
||||||
|
if ($old) { $env:BAKNRET_PASSWORD = $old }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
Write-Host "`n== 集成测试:真的跑 7z,验证排除结果落在文件系统上 ==" -ForegroundColor Cyan
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
$sevenZip = Get-Command 7z -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source
|
||||||
|
|
||||||
|
if (-not $sevenZip) {
|
||||||
|
Write-Host ' 跳过:未找到 7z' -ForegroundColor Yellow
|
||||||
|
} else {
|
||||||
|
Test-Case '排除规则与空格处理在真实归档上生效' {
|
||||||
|
$root = Join-Path $sandbox 'src'
|
||||||
|
$itemName = 'User Data'
|
||||||
|
$source = Join-Path $root $itemName
|
||||||
|
New-Item -ItemType Directory -Path $root -Force | Out-Null
|
||||||
|
|
||||||
|
foreach ($d in 'Default\Cache', 'Default\Code Cache', 'Default\Extensions', 'component_crx_cache', 'Default\IndexedDB') {
|
||||||
|
New-Item -ItemType Directory -Path (Join-Path $source $d) -Force | Out-Null
|
||||||
|
}
|
||||||
|
Set-Content -LiteralPath (Join-Path $source 'keep.txt') 'keep'
|
||||||
|
Set-Content -LiteralPath (Join-Path $source 'Default\Cache\c.bin') 'c'
|
||||||
|
Set-Content -LiteralPath (Join-Path $source 'Default\Code Cache\cc.bin') 'cc'
|
||||||
|
Set-Content -LiteralPath (Join-Path $source 'Default\Extensions\e.bin') 'e'
|
||||||
|
Set-Content -LiteralPath (Join-Path $source 'component_crx_cache\x.bin') 'x'
|
||||||
|
Set-Content -LiteralPath (Join-Path $source 'Default\IndexedDB\i.bin') 'i'
|
||||||
|
|
||||||
|
$patterns = @('!*Cache', 'component_crx_cache', 'Default\Code Cache', 'Default\Extensions', 'Default\IndexedDB')
|
||||||
|
$excludeArgs = Get-ArchiveExcludeArgument -ItemName $itemName -Patterns $patterns
|
||||||
|
|
||||||
|
$archive = Join-Path $sandbox 'excl.7z'
|
||||||
|
$argument = @('a', '-t7z', '-mx=1', '-bso0', '-bsp0') + $excludeArgs + @($archive, $itemName)
|
||||||
|
$code = Invoke-ExternalCommand -FilePath $sevenZip -ArgumentList $argument -WorkingDirectory $root
|
||||||
|
Assert-Equal 0 $code '7z 打包退出码'
|
||||||
|
|
||||||
|
$verify = Join-Path $sandbox 'verify'
|
||||||
|
New-Item -ItemType Directory -Path $verify -Force | Out-Null
|
||||||
|
$code = Invoke-ExternalCommand -FilePath $sevenZip -ArgumentList @('x', '-bso0', '-bsp0', '-y', "-o$verify", $archive)
|
||||||
|
Assert-Equal 0 $code '7z 解压退出码'
|
||||||
|
|
||||||
|
Assert-True (Test-Path -LiteralPath (Join-Path $verify "$itemName\keep.txt")) '保留的文件应当存在'
|
||||||
|
Assert-False (Test-Path -LiteralPath (Join-Path $verify "$itemName\Default\Cache\c.bin")) 'Default\Cache 应被 !*Cache 排除'
|
||||||
|
Assert-False (Test-Path -LiteralPath (Join-Path $verify "$itemName\Default\Code Cache\cc.bin")) 'Code Cache 应被排除(空格转 ? 生效)'
|
||||||
|
Assert-False (Test-Path -LiteralPath (Join-Path $verify "$itemName\Default\Extensions\e.bin")) 'Extensions 应被排除'
|
||||||
|
Assert-False (Test-Path -LiteralPath (Join-Path $verify "$itemName\component_crx_cache\x.bin")) 'component_crx_cache 应被排除'
|
||||||
|
Assert-False (Test-Path -LiteralPath (Join-Path $verify "$itemName\Default\IndexedDB\i.bin")) 'IndexedDB 应被排除'
|
||||||
|
}
|
||||||
|
|
||||||
|
Test-Case '对照组:不加排除时被排除的文件确实在归档里(证明上一条不是空归档)' {
|
||||||
|
$root = Join-Path $sandbox 'src'
|
||||||
|
$itemName = 'User Data'
|
||||||
|
$archive = Join-Path $sandbox 'full.7z'
|
||||||
|
$code = Invoke-ExternalCommand -FilePath $sevenZip -ArgumentList @('a', '-t7z', '-mx=1', '-bso0', '-bsp0', $archive, $itemName) -WorkingDirectory $root
|
||||||
|
Assert-Equal 0 $code
|
||||||
|
|
||||||
|
$verify = Join-Path $sandbox 'verify-full'
|
||||||
|
New-Item -ItemType Directory -Path $verify -Force | Out-Null
|
||||||
|
$code = Invoke-ExternalCommand -FilePath $sevenZip -ArgumentList @('x', '-bso0', '-bsp0', '-y', "-o$verify", $archive)
|
||||||
|
Assert-Equal 0 $code
|
||||||
|
|
||||||
|
Assert-True (Test-Path -LiteralPath (Join-Path $verify "$itemName\Default\Cache\c.bin")) '对照组应当包含被排除的那个文件'
|
||||||
|
}
|
||||||
|
|
||||||
|
Test-Case '7z t 对完好归档返回 0,对损坏归档返回非 0' {
|
||||||
|
$good = Join-Path $sandbox 'full.7z'
|
||||||
|
$code = Invoke-ExternalCommand -FilePath $sevenZip -ArgumentList @('t', '-bso0', '-bsp0', $good)
|
||||||
|
Assert-Equal 0 $code '完好归档应当校验通过'
|
||||||
|
|
||||||
|
$bad = Join-Path $sandbox 'corrupt.7z'
|
||||||
|
$bytes = [System.IO.File]::ReadAllBytes($good)
|
||||||
|
for ($i = [math]::Max(0, $bytes.Length - 40); $i -lt $bytes.Length; $i++) { $bytes[$i] = 0xFF }
|
||||||
|
[System.IO.File]::WriteAllBytes($bad, $bytes)
|
||||||
|
|
||||||
|
$code = Invoke-ExternalCommand -FilePath $sevenZip -ArgumentList @('t', '-bso0', '-bsp0', $bad)
|
||||||
|
Assert-True ($code -ne 0) '损坏的归档应当校验失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
Write-Host "`n== 外部命令退出码 ==" -ForegroundColor Cyan
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
Test-Case 'Invoke-ExternalCommand 能拿到真实退出码(旧实现用 Start-Process 拿不到)' {
|
||||||
|
$code = Invoke-ExternalCommand -FilePath 'cmd.exe' -ArgumentList @('/c', 'exit 7')
|
||||||
|
Assert-Equal 7 $code
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
Remove-Item -LiteralPath $sandbox -Recurse -Force -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
$failed = Write-TestSummary -Title '单元测试'
|
||||||
|
if ($failed -gt 0) { exit 1 }
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
BakNRet 测试用的极简断言运行器(零依赖,PS 5.1 / 7.x 通用)。
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
不用 Pester:本机只装了 3.4.0,缺少 `Should -Be` 这类 Pester 4+ 语法,
|
||||||
|
为跑几个测试去装 Pester 5 不划算。这里只需要 Test-Case + Assert-* 就够。
|
||||||
|
#>
|
||||||
|
|
||||||
|
$script:Passed = 0
|
||||||
|
$script:Failed = 0
|
||||||
|
$script:Failures = @()
|
||||||
|
|
||||||
|
function Reset-TestResult {
|
||||||
|
$script:Passed = 0
|
||||||
|
$script:Failed = 0
|
||||||
|
$script:Failures = @()
|
||||||
|
}
|
||||||
|
|
||||||
|
function Get-TestResult {
|
||||||
|
return [pscustomobject]@{
|
||||||
|
Passed = $script:Passed
|
||||||
|
Failed = $script:Failed
|
||||||
|
Failures = @($script:Failures)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Test-Case {
|
||||||
|
param([string]$Name, [scriptblock]$Body)
|
||||||
|
|
||||||
|
try {
|
||||||
|
& $Body
|
||||||
|
$script:Passed++
|
||||||
|
Write-Host " [PASS] $Name" -ForegroundColor Green
|
||||||
|
} catch {
|
||||||
|
$script:Failed++
|
||||||
|
$script:Failures += "$Name —— $($_.Exception.Message)"
|
||||||
|
Write-Host " [FAIL] $Name" -ForegroundColor Red
|
||||||
|
Write-Host " $($_.Exception.Message)" -ForegroundColor DarkGray
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-Equal {
|
||||||
|
param($Expected, $Actual, [string]$Because)
|
||||||
|
if ($Expected -ne $Actual) {
|
||||||
|
throw ("期望 [{0}],实际 [{1}]{2}" -f $Expected, $Actual, $(if ($Because) { "($Because)" } else { '' }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-True {
|
||||||
|
param($Condition, [string]$Because)
|
||||||
|
if (-not $Condition) { throw ("断言为真失败{0}" -f $(if ($Because) { ":$Because" } else { '' })) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-False {
|
||||||
|
param($Condition, [string]$Because)
|
||||||
|
if ($Condition) { throw ("断言为假失败{0}" -f $(if ($Because) { ":$Because" } else { '' })) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-Null {
|
||||||
|
param($Value, [string]$Because)
|
||||||
|
if ($null -ne $Value) {
|
||||||
|
throw ("期望为 null,实际 [{0}]{1}" -f $Value, $(if ($Because) { "($Because)" } else { '' }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-FileExists {
|
||||||
|
param([string]$Path, [string]$Because)
|
||||||
|
if (-not (Test-Path -LiteralPath $Path)) {
|
||||||
|
throw ("文件应当存在但不存在:{0}{1}" -f $Path, $(if ($Because) { "($Because)" } else { '' }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-FileMissing {
|
||||||
|
param([string]$Path, [string]$Because)
|
||||||
|
if (Test-Path -LiteralPath $Path) {
|
||||||
|
throw ("文件不应存在但存在:{0}{1}" -f $Path, $(if ($Because) { "($Because)" } else { '' }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-FileHashEqual {
|
||||||
|
param([string]$ExpectedPath, [string]$ActualPath)
|
||||||
|
Assert-FileExists $ExpectedPath
|
||||||
|
Assert-FileExists $ActualPath
|
||||||
|
$a = (Get-FileHash -LiteralPath $ExpectedPath -Algorithm SHA256).Hash
|
||||||
|
$b = (Get-FileHash -LiteralPath $ActualPath -Algorithm SHA256).Hash
|
||||||
|
if ($a -ne $b) { throw ("内容不一致:{0} vs {1}" -f $ExpectedPath, $ActualPath) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function Write-TestSummary {
|
||||||
|
param([string]$Title = '测试')
|
||||||
|
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host ("=" * 60)
|
||||||
|
if ($script:Failed -eq 0) {
|
||||||
|
Write-Host "$Title 全部通过:$($script:Passed) 项" -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Host "$Title 通过 $($script:Passed) 项,失败 $($script:Failed) 项" -ForegroundColor Red
|
||||||
|
foreach ($failure in $script:Failures) { Write-Host " - $failure" -ForegroundColor Red }
|
||||||
|
}
|
||||||
|
Write-Host ("=" * 60)
|
||||||
|
|
||||||
|
return $script:Failed
|
||||||
|
}
|
||||||
|
|
||||||
|
Export-ModuleMember -Function @(
|
||||||
|
'Reset-TestResult', 'Get-TestResult', 'Test-Case',
|
||||||
|
'Assert-Equal', 'Assert-True', 'Assert-False', 'Assert-Null',
|
||||||
|
'Assert-FileExists', 'Assert-FileMissing', 'Assert-FileHashEqual',
|
||||||
|
'Write-TestSummary'
|
||||||
|
)
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
注册 / 移除 BakNRet 的每日备份计划任务。
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
任务直接调用 Backup.ps1。脚本自身会写日志并按失败数返回退出码,
|
||||||
|
因此「上次运行结果」在任务计划程序里是可读的,不需要额外包装。
|
||||||
|
|
||||||
|
注册计划任务需要管理员权限(本脚本不自己提权,请从管理员终端运行)。
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
# 先看将要注册什么(只读,不需要管理员)
|
||||||
|
.\tools\Register-BackupTask.ps1 -At '21:30' -DryRun
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\tools\Register-BackupTask.ps1 -At '21:30'
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
.\tools\Register-BackupTask.ps1 -Remove
|
||||||
|
#>
|
||||||
|
|
||||||
|
[CmdletBinding(SupportsShouldProcess = $true)]
|
||||||
|
param(
|
||||||
|
[string]$TaskName = 'BakNRet Backup',
|
||||||
|
|
||||||
|
# 每日触发时间,HH:mm
|
||||||
|
[ValidatePattern('^\d{1,2}:\d{2}$')]
|
||||||
|
[string]$At = '21:30',
|
||||||
|
|
||||||
|
# 额外传给 Backup.ps1 的参数,例如 @('-Snapshot')
|
||||||
|
[string[]]$BackupArgument = @(),
|
||||||
|
|
||||||
|
[ValidateSet('S4U', 'Interactive')]
|
||||||
|
[string]$LogonType = 'S4U',
|
||||||
|
|
||||||
|
# 用哪个 PowerShell 宿主执行(默认优先 pwsh.exe)
|
||||||
|
[string]$PowerShellPath,
|
||||||
|
|
||||||
|
[switch]$Remove,
|
||||||
|
[switch]$DryRun
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
$projectRoot = Split-Path -Parent $PSScriptRoot
|
||||||
|
$backupScript = Join-Path $projectRoot 'Backup.ps1'
|
||||||
|
|
||||||
|
if (-not (Test-Path -LiteralPath $backupScript)) {
|
||||||
|
Write-Error "找不到 Backup.ps1:$backupScript"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $PowerShellPath) {
|
||||||
|
$pwsh = Get-Command pwsh.exe -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty Source
|
||||||
|
$PowerShellPath = if ($pwsh) { $pwsh } else { (Get-Command powershell.exe -ErrorAction Stop).Source }
|
||||||
|
}
|
||||||
|
|
||||||
|
$argumentParts = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', "`"$backupScript`"") + $BackupArgument
|
||||||
|
$argument = ($argumentParts -join ' ')
|
||||||
|
|
||||||
|
Write-Host ''
|
||||||
|
Write-Host "任务名 : $TaskName"
|
||||||
|
Write-Host "执行程序 : $PowerShellPath"
|
||||||
|
Write-Host "参数 : $argument"
|
||||||
|
Write-Host "触发 : 每天 $At"
|
||||||
|
Write-Host "登录类型 : $LogonType(S4U = 未登录也运行,但访问不到网络资源)"
|
||||||
|
Write-Host ''
|
||||||
|
|
||||||
|
if ($Remove) {
|
||||||
|
if ($DryRun) {
|
||||||
|
Write-Host '[试运行] 将移除计划任务' -ForegroundColor Yellow
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
if (-not (Get-Command Unregister-ScheduledTask -ErrorAction SilentlyContinue)) {
|
||||||
|
Write-Error '当前环境没有计划任务 cmdlet。'
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction Stop
|
||||||
|
Write-Host "已移除计划任务:$TaskName" -ForegroundColor Green
|
||||||
|
} catch {
|
||||||
|
Write-Error "移除失败(多半是权限不足,请用管理员终端):$_"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($DryRun) {
|
||||||
|
Write-Host '[试运行] 未实际注册。去掉 -DryRun 并确保在管理员终端里执行即可注册。' -ForegroundColor Yellow
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($cmdlet in 'Register-ScheduledTask', 'New-ScheduledTaskTrigger', 'New-ScheduledTaskAction', 'New-ScheduledTaskSettingsSet', 'New-ScheduledTaskPrincipal') {
|
||||||
|
if (-not (Get-Command $cmdlet -ErrorAction SilentlyContinue)) {
|
||||||
|
Write-Error "当前环境缺少 $cmdlet,无法注册计划任务。"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$action = New-ScheduledTaskAction -Execute $PowerShellPath -Argument $argument -WorkingDirectory $projectRoot
|
||||||
|
$trigger = New-ScheduledTaskTrigger -Daily -At $At
|
||||||
|
|
||||||
|
$settings = New-ScheduledTaskSettingsSet `
|
||||||
|
-StartWhenAvailable `
|
||||||
|
-MultipleInstances IgnoreNew `
|
||||||
|
-AllowStartIfOnBatteries `
|
||||||
|
-DontStopIfGoingOnBatteries `
|
||||||
|
-ExecutionTimeLimit (New-TimeSpan -Hours 6)
|
||||||
|
|
||||||
|
$identity = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
|
||||||
|
$principal = New-ScheduledTaskPrincipal -UserId $identity -LogonType $LogonType -RunLevel Highest
|
||||||
|
|
||||||
|
try {
|
||||||
|
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger `
|
||||||
|
-Settings $settings -Principal $principal -Force -ErrorAction Stop | Out-Null
|
||||||
|
Write-Host "已注册计划任务:$TaskName(每天 $At)" -ForegroundColor Green
|
||||||
|
Write-Host "查看上次运行结果:Get-ScheduledTaskInfo -TaskName '$TaskName'" -ForegroundColor DarkGray
|
||||||
|
Write-Host "手动跑一次验证 :Start-ScheduledTask -TaskName '$TaskName'" -ForegroundColor DarkGray
|
||||||
|
} catch {
|
||||||
|
Write-Error "注册失败(多半是权限不足,请用管理员终端):$_"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
exit 0
|
||||||
Reference in new issue
Block a user