From dbc0c00554828b58dac7ac14aace74cff49297fe Mon Sep 17 00:00:00 2001 From: Shuery <2463253700@qq.com> Date: Mon, 21 Sep 2026 20:10:18 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=E4=B8=BA=E5=8F=AF=E6=A0=B8?= =?UTF-8?q?=E5=AF=B9=E3=80=81=E5=8F=AF=E6=81=A2=E5=A4=8D=E7=9A=84=E5=A4=87?= =?UTF-8?q?=E4=BB=BD=E5=B7=A5=E5=85=B7=EF=BC=88P0-P3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复(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 注册每日计划任务。 - 归档命名算法保持不变,已有归档不会失联。 --- .gitignore | 16 + Backup.ps1 | 528 ++++++++++++++++++++++ BackupConfig.psd1 | 54 +++ BackupList.txt | 66 +++ Common.psm1 | 795 ++++++++++++++++++++++++++++++++++ README.md | 203 +++++++++ Restore.ps1 | 411 ++++++++++++++++++ tests/Run-E2E.ps1 | 282 ++++++++++++ tests/Run-Tests.ps1 | 344 +++++++++++++++ tests/TestHelpers.psm1 | 111 +++++ tools/Register-BackupTask.ps1 | 124 ++++++ 11 files changed, 2934 insertions(+) create mode 100644 .gitignore create mode 100644 Backup.ps1 create mode 100644 BackupConfig.psd1 create mode 100644 BackupList.txt create mode 100644 Common.psm1 create mode 100644 README.md create mode 100644 Restore.ps1 create mode 100644 tests/Run-E2E.ps1 create mode 100644 tests/Run-Tests.ps1 create mode 100644 tests/TestHelpers.psm1 create mode 100644 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