commit dbc0c00554828b58dac7ac14aace74cff49297fe Author: Shuery <2463253700@qq.com> Date: Mon Sep 21 20:10:18 2026 +0800 重构为可核对、可恢复的备份工具(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 注册每日计划任务。 - 归档命名算法保持不变,已有归档不会失联。 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d5884f5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +# 归档本体不进版本库(数 GB,且是随时可重建的产物) +Backups/ + +# 运行日志 +logs/ + +# 压缩过程中的临时归档(正常情况下不会残留;中断时可能留下,便于排查) +*.tmp.7z +*.tmp.zip +*.tmp.rar + +# 编辑器 / 系统杂项 +.vscode/ +*.swp +Thumbs.db +desktop.ini diff --git a/Backup.ps1 b/Backup.ps1 new file mode 100644 index 0000000..43a8069 --- /dev/null +++ b/Backup.ps1 @@ -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 diff --git a/BackupConfig.psd1 b/BackupConfig.psd1 new file mode 100644 index 0000000..55a5fe1 --- /dev/null +++ b/BackupConfig.psd1 @@ -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' + ) +} diff --git a/BackupList.txt b/BackupList.txt new file mode 100644 index 0000000..09a7481 --- /dev/null +++ b/BackupList.txt @@ -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 diff --git a/Common.psm1 b/Common.psm1 new file mode 100644 index 0000000..08e8060 --- /dev/null +++ b/Common.psm1 @@ -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 + 把后续日志同时写入 /-<时间戳>.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' +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..5a27edf --- /dev/null +++ b/README.md @@ -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 明确拒绝加密请求)。 diff --git a/Restore.ps1 b/Restore.ps1 new file mode 100644 index 0000000..887310f --- /dev/null +++ b/Restore.ps1 @@ -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 diff --git a/tests/Run-E2E.ps1 b/tests/Run-E2E.ps1 new file mode 100644 index 0000000..5e99e68 --- /dev/null +++ b/tests/Run-E2E.ps1 @@ -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 diff --git a/tests/Run-Tests.ps1 b/tests/Run-Tests.ps1 new file mode 100644 index 0000000..9f7dd51 --- /dev/null +++ b/tests/Run-Tests.ps1 @@ -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 diff --git a/tests/TestHelpers.psm1 b/tests/TestHelpers.psm1 new file mode 100644 index 0000000..a20379c --- /dev/null +++ b/tests/TestHelpers.psm1 @@ -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' +) diff --git a/tools/Register-BackupTask.ps1 b/tools/Register-BackupTask.ps1 new file mode 100644 index 0000000..7e2860f --- /dev/null +++ b/tools/Register-BackupTask.ps1 @@ -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