From 045d51ac9c1dba04ee49e0218a3e0bb8c9357bc4 Mon Sep 17 00:00:00 2001 From: Shuery <2463253700@qq.com> Date: Mon, 21 Sep 2026 20:55:19 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BC=95=E5=85=A5=E8=BD=AF=E4=BB=B6=E5=90=8D?= =?UTF-8?q?=E5=BD=95=EF=BC=9A=E6=B8=85=E5=8D=95=E5=86=99=E8=BD=AF=E4=BB=B6?= =?UTF-8?q?=E5=90=8D=EF=BC=8C=E5=BD=92=E6=A1=A3=E5=90=8D=E4=B9=9F=E7=94=A8?= =?UTF-8?q?=E8=BD=AF=E4=BB=B6=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新功能 - 新增 SoftwareCatalog.psd1 —— "软件名 -> 目录"映射表,BackupList.txt 里 直接写软件名即可,归档名也就是软件名(FooClolor.7z), 不再是 FooClolor_from_C_+Programs.7z 这种由路径拼出来的名字。 - 三种写法可混用:软件名、字面路径(现有清单无需改写)、软件名 @pathname。 - 名录支持前缀补全(legendary -> legendary_2.0.4,只认 <名>_* / <名>-*)、 Variants(同名目录在多处)、Includes(分文件维护)。 - tools/Rename-Archives.ps1:存量归档重命名,默认试运行,逐份大小校验并重建 manifest。 - 归档名重复直接报错,不再静默互相覆盖。 两套测试全绿:单元 42 项、端到端 23 项(新增名录命名/解析/迁移用例)。 过程中修掉的缺陷 - Resolve-BackupEntry 里 @pathname 与 Unresolved 分支顺序错误, @pathname 会被静默吃掉(改名后仍用软件名)。 - 源目录被删除时解析器丢掉 Sources,导致恢复端把软件名当路径、 报 "Cannot bind argument to parameter 'Path' because it is an empty string"。 恢复的语义恰恰是"源不存在就要还原回去",现在 Sources 照旧给出。 - 源存在性检查曾被漏掉,Get-Item 对不存在路径抛异常会中断整轮备份; 且不能用 Join-Path 探测——目标盘符不存在时它会直接抛异常。 - 计划任务脚本外的 Caller 需要 -DryRun 才能验,已实跑确认。 --- Backup.ps1 | 127 +++++++++-- BackupList.txt | 98 +++++---- Common.psm1 | 447 +++++++++++++++++++++++++++++++++++++- README.md | 164 +++++++++----- Restore.ps1 | 103 +++++++-- SoftwareCatalog.psd1 | 86 ++++++++ tests/Run-E2E.ps1 | 89 +++++++- tests/Run-Tests.ps1 | 99 +++++++++ tools/Rename-Archives.ps1 | 260 ++++++++++++++++++++++ 9 files changed, 1330 insertions(+), 143 deletions(-) create mode 100644 SoftwareCatalog.psd1 create mode 100644 tools/Rename-Archives.ps1 diff --git a/Backup.ps1 b/Backup.ps1 index 43a8069..3475c80 100644 --- a/Backup.ps1 +++ b/Backup.ps1 @@ -94,11 +94,13 @@ function Resolve-ConfigPath { 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 +$catalogPath = Resolve-CatalogPath -Configured $script:Config.SoftwareCatalog -Root $PSScriptRoot $manifestPath = Join-Path $BackupDir 'manifest.json' $logPath = Start-BaknretLog -Directory $logDir -Prefix 'backup' Write-Log "日志文件:$logPath" Write-Log "备份目录:$BackupDir" +Write-Log ("软件名录:{0}{1}" -f $catalogPath, $(if (Test-Path -LiteralPath $catalogPath) { '' } else { '(不存在,将只支持字面路径)' })) if (-not (Test-Administrator)) { Write-Log '建议以管理员身份运行以获取完整的目录访问权限' -Level WARN @@ -144,6 +146,7 @@ $showToolOutput = (-not $QuietTool) -and ($script:Config.ToolOutput -ne 'quiet') $toolQuietArgument = if ($showToolOutput) { @() } else { @('-bso0', '-bsp0') } $lines = Get-Content -LiteralPath $BackupListPath +$seenBaseNames = @{} $processed = 0; $skipped = 0; $failed = 0; $planned = 0 $failures = @() $freeSpaceGB = Get-BaknretFreeSpaceGB -Path $BackupDir @@ -175,6 +178,8 @@ function New-ItemRecord { baseName = $BaseName source = $Source resolvedSource = $ResolvedSource + roots = @() + catalog = $null archive = $null action = $null reason = $null @@ -237,6 +242,7 @@ function Save-ItemRecord { function Invoke-BackupItem { param( [string]$SourcePath, + [string[]]$RelativePaths, [string]$ItemName, [string]$ParentDir, [string]$FinalPath, @@ -249,11 +255,15 @@ function Invoke-BackupItem { $tempPath = "$FinalPath.tmp$($tool.Extension)" if (Test-Path -LiteralPath $tempPath) { Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue } + # 排除模式用**源目录名**作前缀(归档里就是这个名字), + # 与 7z 的路径匹配语义一致;软件名条目的归档根目录是软件名,但源目录名仍在其下一层。 $excludeArgument = Get-ArchiveExcludeArgument -ItemName $ItemName -Patterns $ExcludePatterns if ($excludeArgument.Count -gt 0) { Write-Log ("排除 {0} 项:{1}" -f $excludeArgument.Count, ($excludeArgument -join ' ')) -Level DEBUG } + $sourceListFile = $null + try { if ($tool.Name -eq '7z') { $optimized = Get-Optimized7zArgument -SourcePath $SourcePath -Level $script:Config.CompressionLevel @@ -268,7 +278,8 @@ function Invoke-BackupItem { } $argument += $tempPath - $argument += $ItemName + + foreach ($relative in $RelativePaths) { $argument += $relative } $exitCode = Invoke-ExternalCommand -FilePath $tool.Command -ArgumentList $argument -WorkingDirectory $ParentDir # 7z: 0 成功;1 警告(有文件读不到或跳过);2 及以上为失败 @@ -286,7 +297,8 @@ function Invoke-BackupItem { $argument += "-p$password" } $argument += $tempPath - $argument += $ItemName + # RAR 没有 -spf,退回"直接打包源目录",归档根目录就是源目录名 + foreach ($relative in $RelativePaths) { $argument += $relative } $exitCode = Invoke-ExternalCommand -FilePath $tool.Command -ArgumentList $argument -WorkingDirectory $ParentDir if ($exitCode -ne 0) { @@ -298,12 +310,8 @@ function Invoke-BackupItem { 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 - } + $fullPaths = @($RelativePaths | ForEach-Object { Join-Path $ParentDir $_ }) + Compress-Archive -Path $fullPaths -DestinationPath $tempPath -CompressionLevel Optimal -Force $warnings = $false } @@ -346,6 +354,10 @@ function Invoke-BackupItem { Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue } return [pscustomobject]@{ Ok = $false; ExitCode = $null; Warnings = $false; Reason = "$_" } + } finally { + if ($sourceListFile -and (Test-Path -LiteralPath $sourceListFile)) { + Remove-Item -LiteralPath $sourceListFile -Force -ErrorAction SilentlyContinue + } } } @@ -358,16 +370,18 @@ foreach ($line in $lines) { if (-not $item) { continue } $displayPath = $item.Path - $sourcePath = [Environment]::ExpandEnvironmentVariables($item.Path) - $baseName = Get-BackupBaseName -RawPath $item.Path + $resolved = Resolve-BackupEntry -Entry $item -CatalogPath $catalogPath -MaxDepth $script:Config.CatalogMaxDepth - if (-not $baseName) { - $record = New-ItemRecord -BaseName ('raw:' + $displayPath) -Source $displayPath -ResolvedSource $sourcePath -Phase 'parse' - Save-ItemRecord -Record $record -Action 'failed' -Reason '无法从路径生成归档名' | Out-Null + if (-not $resolved.BaseName) { + $record = New-ItemRecord -BaseName ('raw:' + $displayPath) -Source $displayPath -ResolvedSource $displayPath -Phase 'parse' + Save-ItemRecord -Record $record -Action 'failed' -Reason '无法生成归档名' | Out-Null $failed++; $failures += $displayPath continue } + $baseName = $resolved.BaseName + $sourcePath = [Environment]::ExpandEnvironmentVariables($displayPath) + if (-not (Test-ItemSelected -DisplayPath $displayPath -BaseName $baseName)) { Write-Log "跳过(未选中): $displayPath" -Level DEBUG continue @@ -375,17 +389,75 @@ foreach ($line in $lines) { $record = New-ItemRecord -BaseName $baseName -Source $displayPath -ResolvedSource $sourcePath -Phase 'backup' $record.archive = $baseName + $tool.Extension + $record.roots = @($resolved.Sources | ForEach-Object { $_.RootName }) + if ($resolved.CatalogEntry) { $record.catalog = $resolved.CatalogEntry.Path } $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 + # 备份列表里写重了会生成两个同名归档,互相覆盖 —— 直接报错,不猜。 + if ($seenBaseNames.ContainsKey($baseName)) { + $reason = "归档名 '$baseName' 与清单中的 '$($seenBaseNames[$baseName])' 重复(由 '$displayPath' 生成),两者会互相覆盖" + Write-Log "失败: $displayPath,$reason" -Level ERROR + Save-ItemRecord -Record $record -Action 'failed' -Reason $reason | Out-Null + $failed++; $failures += $displayPath + continue + } + $seenBaseNames[$baseName] = $displayPath + + # Sources 为空 = 解析不出任何源(名录里没这个软件名、或路径拆不出父/子级)。 + # 注意不能用 $resolved.Error 判断:名录里的路径不存在时 Error 有值, + # 但 Sources 是给出的(恢复端要靠它把内容还原回原位),备份端由下面的 + # 存在性检查统一处理。 + if ($resolved.Sources.Count -eq 0) { + $reason = if ($resolved.Error) { $resolved.Error } else { '解析不出任何源路径' } + Write-Log "跳过: $displayPath,$reason" -Level WARN + Save-ItemRecord -Record $record -Action 'missing-source' -Reason $reason | Out-Null $skipped++ continue } - $parentDir = Split-Path -Path $sourcePath -Parent - $itemName = Split-Path -Path $sourcePath -Leaf + # 源存在性检查必须在 Get-FolderSummary / Get-Item 之前: + # 两者对不存在的路径要么抛异常、要么返回会误导判断的空摘要。 + # 注意不能用 Join-Path 探测:目标盘符不存在时它会直接抛异常。 + $expectedRoots = 0 + $missingRoots = @() + foreach ($source in $resolved.Sources) { + foreach ($relative in $source.RelativePaths) { + $expectedRoots++ + $candidate = "$($source.ParentDir.TrimEnd('\'))\$relative" + if (-not (Test-Path -LiteralPath $candidate)) { $missingRoots += $candidate } + } + } + + if ($missingRoots.Count -ge $expectedRoots) { + Write-Log "跳过: $displayPath,源路径不存在" -Level WARN + Save-ItemRecord -Record $record -Action 'missing-source' -Reason ('源路径不存在:' + ($missingRoots -join ';')) | Out-Null + $skipped++ + continue + } + + if ($missingRoots.Count -gt 0) { + Write-Log ("警告: {0} 有 {1} 个源路径不存在,本次只备份存在的部分:{2}" -f $displayPath, $missingRoots.Count, ($missingRoots -join ';')) -Level WARN + } + + # 归档里只放真实存在的源 + $liveSources = @() + foreach ($source in $resolved.Sources) { + $live = @($source.RelativePaths | Where-Object { Test-Path -LiteralPath "$($source.ParentDir.TrimEnd('\'))\$_" }) + if ($live.Count -gt 0) { + $liveSources += [pscustomobject]@{ + RootName = $source.RootName + ParentDir = $source.ParentDir + RelativePaths = $live + SourcePath = $source.SourcePath + } + } + } + + $primarySource = $liveSources[0].SourcePath + $parentDir = $liveSources[0].ParentDir + # 排除模式的前缀始终用**源目录名**(归档里就是这个层级) + $itemName = Split-Path -Path $primarySource -Leaf + if (-not $parentDir -or -not $itemName) { Write-Log "跳过: $displayPath,无法处理根目录" -Level WARN Save-ItemRecord -Record $record -Action 'invalid-path' -Reason '无法拆出父目录或末级名' | Out-Null @@ -393,19 +465,28 @@ foreach ($line in $lines) { continue } - $summary = Get-FolderSummary -FolderPath $sourcePath + $summary = Get-FolderSummary -FolderPath $primarySource + foreach ($source in $liveSources[1..($liveSources.Count - 1)]) { + $extra = Get-FolderSummary -FolderPath $source.SourcePath + $summary.FileCount += $extra.FileCount + $summary.TotalSize += $extra.TotalSize + if ($extra.LatestModifiedTime -and $extra.LatestModifiedTime -gt $summary.LatestModifiedTime) { + $summary.LatestModifiedTime = $extra.LatestModifiedTime + } + } $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)) + Write-Log ("开始备份: {0} -> {1}({2} 个文件,{3} MB)" -f $displayPath, $record.archive, $summary.FileCount, [math]::Round(($summary.TotalSize / 1MB), 2)) # 空目录时 Get-FolderSummary 拿不到任何条目,回退到源自身的修改时间 + # (源路径上面已经确认存在,这里的 Get-Item 不会再抛异常) $sourceLatest = $summary.LatestModifiedTime if (-not $sourceLatest) { - $sourceLatest = (Get-Item -LiteralPath $sourcePath -Force).LastWriteTime + $sourceLatest = (Get-Item -LiteralPath $primarySource -Force).LastWriteTime } if (-not $Force -and $archiveItem -and $sourceLatest -and $sourceLatest -le $archiveItem.LastWriteTime) { @@ -457,7 +538,9 @@ foreach ($line in $lines) { } } - $result = Invoke-BackupItem -SourcePath $sourcePath -ItemName $itemName -ParentDir $parentDir ` + $firstSource = $liveSources[0] + $result = Invoke-BackupItem -SourcePath $firstSource.SourcePath -RelativePaths $firstSource.RelativePaths ` + -ItemName $itemName -ParentDir $firstSource.ParentDir ` -FinalPath $finalPath -ExcludePatterns $effectiveExcludes -UseEncryption:$useEncryption ` -ProtectPrevious:$protectPrevious -AcceptWarnings:$AcceptWarnings diff --git a/BackupList.txt b/BackupList.txt index 09a7481..513d28c 100644 --- a/BackupList.txt +++ b/BackupList.txt @@ -1,37 +1,49 @@ # BackupList.txt —— 备份 / 恢复共用清单 # # 语法: -# <路径> [ :: <排除模式>[,<排除模式>...] ] [ @<标记> ] +# <软件名 或 路径> [ :: <排除模式>[,<排除模式>...] ] [ @<标记> ] # -# 路径 :支持 %环境变量%;可用双引号包裹(引号只包路径);/ 与 \ 等价。 -# 排除模式 :相对归档根目录(也就是源目录的末级名)。 -# 以 ! 开头表示"任意层级下匹配这个组件名",翻译成 7z 的 -xr!。 -# 不要自己写引号;7z 的模式不支持空格,工具会自动把空格转成 ?。 -# 标记 :encrypt = 用 7z 加密这个归档(口令来自 BAKNRET_PASSWORD 或 -KeyFile)。 +# 三种写法都可以,混用也没问题: # -# 注意: -# * 归档名由 <末级名>_from_<上级路径用 + 连接> 生成。改动路径会生成新归档名, -# 旧归档不会被自动迁移,恢复时请用 manifest.json 或保留原写法。 -# * 源路径不存在的条目会明确报告为「跳过: 路径不存在」,不再静默忽略。 -# * 对已经不存在、但归档还在的条目,保留在这里可以继续恢复出历史数据。 +# 1. 软件名(推荐)—— 去 SoftwareCatalog.psd1 里查目录,归档名就是软件名 +# FooClolor +# Kazumi :: !*Cache +# +# 2. 字面路径 —— 含 `\`、`/` 或 `%` 就按路径处理,归档名沿用 <名>_from_<路径> +# %UserProfile%\Documents\PowerShell +# C:\Programs\MiFlash +# +# 3. 软件名 + @pathname —— 强制用旧的路径命名算法 +# FooClolor @pathname +# +# 排除模式:相对归档根目录。以 ! 开头表示"任意层级下匹配这个组件名"(7z 的 -xr!)。 +# 不要自己写引号;模式里的空格会被自动转成 ?(7z 的模式不支持空格)。 +# 标记: +# encrypt 用 7z 加密该归档(口令来自 BAKNRET_PASSWORD 或 -KeyFile) +# pathname 用路径命名算法而不是软件名 +# root=<名> 覆盖归档内的根目录名(默认就是软件名) +# +# 归档名 = 软件名,所以:**同一个软件不要写两遍**,脚本会直接报重复错误。 +# 软件名(连同排除规则、加密标记)都维护在 SoftwareCatalog.psd1 和本文件里, +# 两边都进 git,改动可追溯。 # ---- 用户配置 / 开发环境 ---- -%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 +legendary +opencode +scoop-config +# .ssh 里是私钥。想加密就把下面那行 @encrypt 的注释互换(见 README「加密」) +.ssh +CodeSpace :: Shuery-Shuai\ImmortalWrt-BPI-R4-Firmware\immortalwrt\ +PowerShell +WindowsPowerShell +scoop-persist # ---- 应用数据 ---- -%AppData%\AutoDarkMode -%AppData%/com.example/Kazumi -%AppData%/com.example/piliplus -%AppData%\fnm -%AppData%/twinkle-tray +AutoDarkMode +Kazumi +piliplus +fnm +twinkle-tray # ---- 浏览器:排除可再生的缓存、遥测与扩展本体 ---- # 解压后 4.22 GB / 25030 个文件里,下面这组排除会留下约 431 MB / 2164 个文件, @@ -39,28 +51,26 @@ # 扩展本体(可从商店重装)、遥测与优化数据。 # 书签/密码/偏好/历史,以及站点数据(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 +# Default\Session Storage、Default\blob_storage、Default\WebStorage 也加进来。 +# !*Cache 一次覆盖 Cache / Code Cache / GPUCache / DawnCache / GrShaderCache 等一批。 +# +# 注意:Edge 常驻时打包会有上百个文件读不到(含 Login Data / Cookies), +# 脚本检测到警告后不会用这份不完整的归档覆盖已有的完整归档。备份前建议先退出 Edge。 +Edge :: !*Cache,component_crx_cache,Default\Service Worker,Default\Extensions,Default\ExtensionActivityEdge,ProvenanceData,optimization_guide,Crashpad,BrowserMetrics,Snapshots,Edge Sidebar,Edge Shopping +WindowsTerminal # ---- 系统 ---- -%ProgramData%\Microsoft\Windows\Start Menu\Programs\Startup +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 +BaiduNetdisk +FooClolor +March7thAssistant :: 3rdparty\WebBrowser\UserProfile\Integrated,March7thAssistant\logs\ +MiFlash +MiFlash_Unlock +QuarkCloudDrive +translucenttb +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 +Aria diff --git a/Common.psm1 b/Common.psm1 index 08e8060..ab5dffa 100644 --- a/Common.psm1 +++ b/Common.psm1 @@ -417,16 +417,15 @@ function ConvertFrom-BackupListLine { # 从尾部摘出 @标记。没有 `::` 时标记直接跟在路径后面 # (如 `%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 - } + $pathSplit = Split-TrailingFlags -Text $pathPart + if ($pathSplit.Flags.Count -gt 0) { + $flags = @($pathSplit.Flags) + $flags + $pathPart = $pathSplit.Remainder } $excludes = @() @@ -436,6 +435,9 @@ function ConvertFrom-BackupListLine { return [pscustomobject]@{ Path = $pathPart + # 目录名或文件名,需要靠 SoftwareCatalog 换成真实路径; + # 带分隔符或 %变量% 的写法按字面路径处理(并给出警告)。 + IsName = (-not (Test-LiteralPath -Path $pathPart)) ExcludePatterns = $excludes Flags = $flags Raw = $Line @@ -443,6 +445,23 @@ function ConvertFrom-BackupListLine { } } +function Test-LiteralPath { + <# + .SYNOPSIS + 判断清单里的一行是不是"字面路径"(而非软件名)。 + + .DESCRIPTION + 出现分隔符(\ 或 /)或 %环境变量% 就当作字面路径,其余按软件名去名录里查。 + 这条规则保证:现有的全路径清单不需要任何改写就能继续工作。 + #> + param([AllowEmptyString()][string]$Path) + + if ([string]::IsNullOrWhiteSpace($Path)) { return $true } + if ($Path.Contains('\') -or $Path.Contains('/')) { return $true } + if ($Path.Contains('%')) { return $true } + return $false +} + function Get-ArchiveExcludeArgument { <# .SYNOPSIS @@ -491,10 +510,418 @@ function Get-ArchiveExcludeArgument { return ,$result } +# ============================================================================ +# 软件名录(SoftwareCatalog.psd1) +# ============================================================================ + +function Resolve-CatalogPath { + <# + .SYNOPSIS + 计算软件名录的绝对路径(优先 .psd1,找不到就退而用 .json)。 + #> + param([string]$Configured, [string]$Root) + + $candidates = @() + if ($Configured) { + $value = $Configured + if (-not [System.IO.Path]::IsPathRooted($value)) { $value = Join-Path $Root $value } + $candidates += $value + } + $candidates += (Join-Path $Root 'SoftwareCatalog.psd1') + $candidates += (Join-Path $Root 'SoftwareCatalog.json') + + foreach ($candidate in $candidates) { + if (Test-Path -LiteralPath $candidate) { return $candidate } + } + return $candidates[0] +} + +function Format-CatalogName { + <# + .SYNOPSIS + 把软件名规范化成合法的归档基础名。 + + .DESCRIPTION + 软件名就是归档名,所以这里必须挡住非法文件名字符。 + 保留 & % +(与路径命名算法的白名单一致)。 + #> + param([Parameter(Mandatory = $true)][string]$Name) + + $invalidChars = [System.IO.Path]::GetInvalidFileNameChars() | + Where-Object { $_ -notin @('&', '%', '+') } + + $clean = -join ($Name.Trim().ToCharArray() | ForEach-Object { + if ($_ -in $invalidChars) { '_' } else { $_ } + }) + $clean = $clean -replace ':', '_' + return $clean.Trim() +} + +function Get-SoftwareCatalog { + <# + .SYNOPSIS + 载入"软件名 -> 目录"名录。 + + .DESCRIPTION + 返回按名字索引的哈希表,每项是 @{ Name; Path; ResolvedPath; Kind; Raw }。 + Kind 取值:Single(一个目录)| Variant(有 variants 的同名目录)| Unresolved(没找到目录)。 + + 名录文件可以是 .psd1 或 .json——默认用 .psd1,因为路径这种东西很需要写注释。 + .psd1 里可以用 `Includes` 键引入其它名录文件,多个游戏/多个盘的目录可以分文件维护。 + #> + param( + [Parameter(Mandatory = $true)][string]$Path, + [int]$MaxDepth = 5 + ) + + $result = @{} + if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { return $result } + + $data = $null + try { + if ($Path.ToLower().EndsWith('.json')) { + $data = Get-Content -LiteralPath $Path -Raw -Encoding UTF8 | ConvertFrom-Json -ErrorAction Stop + } else { + $data = Import-PowerShellDataFile -LiteralPath $Path -ErrorAction Stop + } + } catch { + Write-Log "软件名录读取失败:$Path —— $_" -Level ERROR + return $result + } + + # 递归引入其它名录文件 + if ($data -is [System.Collections.IDictionary] -and $data.Contains('Includes')) { + $includeList = @($data['Includes']) + $baseDir = Split-Path -Parent $Path + foreach ($include in $includeList) { + if (-not $include) { continue } + $includePath = [string]$include + if (-not [System.IO.Path]::IsPathRooted($includePath)) { $includePath = Join-Path $baseDir $includePath } + $included = Get-SoftwareCatalog -Path $includePath -MaxDepth $MaxDepth + foreach ($includedName in $included.Keys) { + if ($result.ContainsKey($includedName)) { continue } + $result[$includedName] = $included[$includedName] + } + } + } + + # 顶层除 Includes 外的每个键都是一个软件名 + $keys = @() + if ($data -is [System.Collections.IDictionary]) { + $keys = @($data.Keys | Where-Object { $_ -ne 'Includes' }) + } else { + $keys = @($data.PSObject.Properties.Name | Where-Object { $_ -ne 'Includes' }) + } + + foreach ($key in $keys) { + $name = Format-CatalogName -Name ([string]$key) + if (-not $name) { continue } + + $entry = if ($data -is [System.Collections.IDictionary]) { $data[$key] } else { $data.$key } + + $rawPath = $null + $variants = $null + if ($entry -is [System.Collections.IDictionary]) { + if ($entry.Contains('Path')) { $rawPath = [string]$entry['Path'] } + if ($entry.Contains('Variants')) { $variants = @($entry['Variants']) } + } else { + $rawPath = [string]$entry + } + if (-not $rawPath) { continue } + + $resolved = [Environment]::ExpandEnvironmentVariables($rawPath) + + if ($variants -and $variants.Count -gt 0) { + # 同名目录出现在多个位置:不猜,所有位置都作为归档根目录。 + $resolvedVariants = @($variants | ForEach-Object { [Environment]::ExpandEnvironmentVariables([string]$_) }) + $result[$name] = [pscustomobject]@{ + Name = $name + Path = $rawPath + ResolvedPath = $resolved + Variants = $resolvedVariants + Kind = 'Variant' + Raw = $entry + } + Write-Log "名录:$name 有 $($resolvedVariants.Count) 个候选位置" -Level DEBUG + continue + } + + if (Test-Path -LiteralPath $resolved) { + $kind = 'Single' + } else { + # 名录里写的是父目录,实际目录带版本号之类后缀(如 legendary 的 _2.0.4) + $parent = Split-Path -Path $resolved -Parent + $leaf = Split-Path -Path $resolved -Leaf + $found = $null + if ($parent -and (Test-Path -LiteralPath $parent)) { + $found = @(Find-ChildDirectoryByName -Parent $parent -Name $leaf -MaxDepth $MaxDepth) + if ($found.Count -gt 1) { $kind = 'Variant' } elseif ($found.Count -eq 1) { $kind = 'Single' } else { $kind = 'Unresolved' } + } else { + $kind = 'Unresolved' + } + + if ($found -and $found.Count -gt 0) { + $resolved = $found[0] + $result[$name] = [pscustomobject]@{ + Name = $name + Path = $rawPath + ResolvedPath = $resolved + Variants = @($found) + Kind = $kind + Raw = $entry + Suffixed = $true + } + Write-Log "名录:$name -> $resolved(按前缀补全)" -Level DEBUG + continue + } + } + + $result[$name] = [pscustomobject]@{ + Name = $name + Path = $rawPath + ResolvedPath = $resolved + Variants = @() + Kind = $kind + Raw = $entry + } + } + + return $result +} + +function Find-ChildDirectoryByName { + <# + .SYNOPSIS + 在 $Parent 下按精确名或"<名>_<后缀>"/"<名>-<后缀>"形式找目录。 + + .DESCRIPTION + 只做保守的前缀补全:必须以下一个字符是 _ 或 - 为界, + 避免把 Legendary 匹配成 LegendarySomething。 + #> + param( + [Parameter(Mandatory = $true)][string]$Parent, + [Parameter(Mandatory = $true)][string]$Name, + [int]$MaxDepth = 5 + ) + + $escaped = [regex]::Escape($Name) + $pattern = "^$escaped(_|-).+" + + try { + return @(Get-ChildItem -LiteralPath $Parent -Directory -Force -ErrorAction SilentlyContinue | + Where-Object { $_.Name -ieq $Name -or $_.Name -imatch $pattern } | + Sort-Object Name | + Select-Object -ExpandProperty FullName) + } catch { + return @() + } +} + # ============================================================================ # 归档命名与路径还原 # ============================================================================ +function Get-ItemArchiveName { + <# + .SYNOPSIS + 决定一个条目的归档基础名(不含扩展名)。 + + .DESCRIPTION + 规则: + * 默认用**软件名**(看起来像软件名就查名录;名录里没有则退回可读的目录名); + * 条目带 `@pathname` 时用原来的路径命名算法; + * 条目本来就写的是字面路径(含分隔符或 %变量%)时也用路径命名算法, + 这样现有清单不需要改写就能继续工作。 + #> + param($Entry, [string]$CatalogPath, [int]$MaxDepth = 5) + + # @pathname 时用"真实路径"跑路径命名算法。 + # 清单里写的可能是软件名,必须先经名录换成真实路径, + # 否则 Get-BackupBaseName 会对软件名本身运算,得出错误的名字。 + if ($Entry.Flags -contains 'pathname') { + $nameSource = $Entry.Path + if (-not (Test-LiteralPath -Path $Entry.Path)) { + $catalogForPath = Get-SoftwareCatalog -Path $CatalogPath -MaxDepth $MaxDepth + if ($catalogForPath.ContainsKey($Entry.Path)) { + $nameSource = $catalogForPath[$Entry.Path].Path + } + } + return Get-BackupBaseName -RawPath $nameSource + } + + $looksLikePath = Test-LiteralPath -Path $Entry.Path + if (-not $looksLikePath) { + $catalog = Get-SoftwareCatalog -Path $CatalogPath -MaxDepth $MaxDepth + if ($catalog.ContainsKey($Entry.Path)) { + return $catalog[$Entry.Path].Name + } + Write-Log "名录里没有 '$($Entry.Path)',按目录名处理" -Level WARN + return (Format-CatalogName -Name $Entry.Path) + } + + return Get-BackupBaseName -RawPath $Entry.Path +} + +function Resolve-BackupEntry { + <# + .SYNOPSIS + 把清单条目解析成"实际要备份什么"。 + + .DESCRIPTION + 返回 @{ IsName; BaseName; Sources; Source; ArchiveFlavor },其中: + * IsName —— 这一行写的是软件名还是字面路径 + * BaseName —— 归档基础名 + * Sources —— 要备份的项目列表(一个根目录名 -> 该根目录下的一组相对路径) + * ArchiveFlavor —— 'name'(归档根目录叫软件名)或 'path'(叫源目录名) + + 归档内部布局: + * 软件名条目 -> 根目录用软件名,内容为 `<源目录名>\...` + (这样恢复时能知道文件原来属于哪个目录) + * 字面路径条目 -> 整条目直接写进归档,保持与历史归档完全一致的布局, + 否则现有归档一旦被重打,恢复就会失败。 + #> + param( + $Entry, + [string]$CatalogPath, + [int]$MaxDepth = 5 + ) + + $isName = -not (Test-LiteralPath -Path $Entry.Path) + # @pathname 强制按"字面路径条目"处理:归档名用路径算法, + # 但清单里写的是软件名,真实路径仍要经名录解析。 + $forcePathFlavor = ($Entry.Flags -contains 'pathname') + $baseName = Get-ItemArchiveName -Entry $Entry -CatalogPath $CatalogPath -MaxDepth $MaxDepth + $rootNames = @($Entry.Flags | Where-Object { $_ -like 'root=*' } | ForEach-Object { $_.Substring(5) }) + + if (-not $isName) { + $sourcePath = [Environment]::ExpandEnvironmentVariables($Entry.Path) + return [pscustomobject]@{ + IsName = $false + CatalogEntry = $null + BaseName = $baseName + ArchiveFlavor = 'path' + RootName = $null + Sources = @([pscustomobject]@{ RootName = $null; ParentDir = (Split-Path -Path $sourcePath -Parent); RelativePaths = @((Split-Path -Path $sourcePath -Leaf)); SourcePath = $sourcePath }) + Source = $Entry.Path + } + } + + $catalog = Get-SoftwareCatalog -Path $CatalogPath -MaxDepth $MaxDepth + if (-not $catalog.ContainsKey($Entry.Path)) { + return [pscustomobject]@{ + IsName = $true + CatalogEntry = $null + BaseName = $baseName + ArchiveFlavor = 'name' + RootName = $rootName + Sources = @() + Source = $Entry.Path + Error = "软件名录里没有 '$($Entry.Path)'" + } + } + + $catalogEntry = $catalog[$Entry.Path] + $rootName = if ($rootNames.Count -gt 0) { $rootNames[0] } else { $baseName } + + # @pathname 必须排在 Unresolved 分支之前:否则路径不存在的条目会先被 + # 当作普通软件名条目返回 Flavor='name',把 @pathname 覆盖悄悄吃掉。 + if ($forcePathFlavor) { + $flavorPath = $catalogEntry.ResolvedPath + $flavorParent = Split-Path -Path $flavorPath -Parent + $flavorLeaf = Split-Path -Path $flavorPath -Leaf + $flavorSource = @() + if ($flavorParent -and $flavorLeaf) { + $flavorSource = @([pscustomobject]@{ + RootName = $null + ParentDir = $flavorParent + RelativePaths = @($flavorLeaf) + SourcePath = $flavorPath + }) + } + return [pscustomobject]@{ + IsName = $true + CatalogEntry = $catalogEntry + BaseName = $baseName + ArchiveFlavor = 'path' + RootName = $null + Sources = $flavorSource + Source = $Entry.Path + Error = $(if ($catalogEntry.Kind -eq 'Unresolved') { "名录里的路径不存在:$($catalogEntry.Path)" } else { $null }) + } + } + + # 名录里的路径当前不存在。备份时这是"跳过",但**恢复时这正是要恢复的场景**, + # 所以 SourcePath 仍然给出来(恢复端会照它把内容还原回原位), + # 只把 Error 标出来让备份端跳过。 + if ($catalogEntry.Kind -eq 'Unresolved') { + $missingPath = $catalogEntry.ResolvedPath + $missingParent = Split-Path -Path $missingPath -Parent + $missingLeaf = Split-Path -Path $missingPath -Leaf + $missingSource = @() + if ($missingParent -and $missingLeaf) { + $missingSource = @([pscustomobject]@{ + RootName = $rootName + ParentDir = $missingParent + RelativePaths = @($missingLeaf) + SourcePath = $missingPath + }) + } + return [pscustomobject]@{ + IsName = $true + CatalogEntry = $catalogEntry + BaseName = $baseName + ArchiveFlavor = 'name' + RootName = $rootName + Sources = $missingSource + Source = $Entry.Path + Error = "名录里的路径不存在:$($catalogEntry.Path)" + } + } + + # @pathname 已在前面统一处理(必须排在 Unresolved 之前),这里不再重复。 + + $sources = @() + + if ($catalogEntry.Kind -eq 'Variant') { + $parent = Split-Path -Path $catalogEntry.ResolvedPath -Parent + $relatives = @($catalogEntry.Variants | ForEach-Object { Split-Path -Path $_ -Leaf }) + $sources = @([pscustomobject]@{ + RootName = $rootName + ParentDir = $parent + RelativePaths = $relatives + SourcePath = $parent + }) + } elseif ($catalogEntry.Kind -eq 'Single') { + $sources = @([pscustomobject]@{ + RootName = $rootName + ParentDir = (Split-Path -Path $catalogEntry.ResolvedPath -Parent) + RelativePaths = @((Split-Path -Path $catalogEntry.ResolvedPath -Leaf)) + SourcePath = $catalogEntry.ResolvedPath + }) + } else { + return [pscustomobject]@{ + IsName = $true + CatalogEntry = $catalogEntry + BaseName = $baseName + ArchiveFlavor = 'name' + RootName = $rootName + Sources = @() + Source = $Entry.Path + Error = "名录里的路径不存在:$($catalogEntry.Path)" + } + } + + return [pscustomobject]@{ + IsName = $true + CatalogEntry = $catalogEntry + BaseName = $baseName + ArchiveFlavor = 'name' + RootName = $rootName + Sources = $sources + Source = $Entry.Path + } +} + function Get-BackupBaseName { <# .SYNOPSIS @@ -728,6 +1155,8 @@ function Get-BaknretConfig { BackupDir = 'Backups' LogDir = 'logs' SnapshotDir = 'Backups\snapshots' + SoftwareCatalog = 'SoftwareCatalog.psd1' + CatalogMaxDepth = 5 MinFreeSpaceGB = 8 VerifyArchive = $true ComputeHash = $false @@ -788,8 +1217,10 @@ 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', + 'ConvertFrom-BackupListLine', 'Get-ArchiveExcludeArgument', 'Test-LiteralPath', + 'Resolve-CatalogPath', 'Get-SoftwareCatalog', 'Find-ChildDirectoryByName', 'Format-CatalogName', + 'Get-ItemArchiveName', 'Resolve-BackupEntry', 'Get-BackupBaseName', 'Convert-BackupFileNameToPath', + 'Get-FolderSummary', 'Read-BaknretManifest', 'Write-BaknretManifest', 'Move-BaknretArchiveIntoPlace', 'Get-BaknretConfig', 'Get-BaknretPassword' ) diff --git a/README.md b/README.md index 5a27edf..ae30b03 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,9 @@ # BakNRet -把 `BackupList.txt` 里列出的目录 / 文件用 **7-Zip** 打包进 `Backups/`,并且能用 `Restore.ps1` 原样恢复的 Windows 备份工具。 +把 `BackupList.txt` 里列出的软件 / 目录用 **7-Zip** 打包进 `Backups/`,并且能用 `Restore.ps1` 原样恢复的 Windows 备份工具。 +- 清单里**直接写软件名**即可(如 `FooClolor`),目录映射维护在 `SoftwareCatalog.psd1` 里。 +- 归档名就是软件名(`FooClolor.7z`),不再是 `FooClolor_from_C_+Programs.7z`。 - 只依赖 PowerShell(5.1 或 7.x)与 7-Zip,无需安装模块。 - 每个归档写完后做 `7z t` 内容校验,**先写临时文件、校验通过再原子替换**。 - 每次运行产出可核对的 `Backups/manifest.json` 与 `logs/*.log`。 @@ -24,9 +26,9 @@ # 3b. 确认可以接受"有文件被占用而没打进归档"时,允许覆盖完整归档 .\Backup.ps1 -Force -AcceptWarnings -# 4. 只备份 / 只恢复某几项(通配符匹配路径或归档名) -.\Backup.ps1 -Only '*.ssh','C:\Programs\FooClolor' -.\Restore.ps1 -Only '*Edge*' -Force +# 4. 只备份 / 只恢复某几项(通配符匹配清单条目或归档名) +.\Backup.ps1 -Only 'FooClolor','.ssh' +.\Restore.ps1 -Only 'Edge' -Force # 5. 恢复前先看计划(恢复会覆盖真实目录,务必先看一眼) .\Restore.ps1 -DryRun @@ -39,60 +41,125 @@ | 路径 | 作用 | | --- | --- | +| `SoftwareCatalog.psd1` | **软件名 → 目录**的映射,清单里写软件名的依据 | | `BackupList.txt` | 备份 / 恢复共用的清单,唯一的"要备份什么"来源 | | `BackupConfig.psd1` | 目录、空间阈值、校验、加密等配置 | -| `Backup.ps1` | 备份入口 | -| `Restore.ps1` | 恢复入口 | -| `Common.psm1` | 公共模块(日志、外部命令、解析、manifest) | +| `Backup.ps1` / `Restore.ps1` | 备份 / 恢复入口 | +| `Common.psm1` | 公共模块(日志、外部命令、解析、名录、manifest) | | `Backups/` | 归档与 `manifest.json`(已 gitignore) | | `logs/` | 每次运行的日志(已 gitignore) | | `tests/` | 单元测试与端到端验收 | | `tools/Register-BackupTask.ps1` | 注册 / 移除计划任务 | +| `tools/Rename-Archives.ps1` | 把按路径命名的旧归档重命名成软件名(默认试运行) | + +## SoftwareCatalog.psd1 —— 软件名 → 目录 + +```powershell +@{ + FooClolor = 'C:\Programs\FooClolor' + Kazumi = '%AppData%\com.example\Kazumi' + 'scoop-config' = '%UserProfile%\.config\scoop' # 含 - 或 . 的键必须加引号 + '.ssh' = '%UserProfile%\.ssh' +} +``` + +**含 `-` 或 `.` 的键一定要加引号**,否则 PowerShell 会把 `a-b` 解析成减法表达式并报 +`Missing '=' operator after key in hash literal`。这是最容易踩的一个坑。 + +两个便利特性: + +1. **前缀补全**:写 `D:\Programs\legendary`,实际目录是 `legendary_2.0.4` 时会自动匹配。 + 只认 `<名>_*` 与 `<名>-*`,不会把 `Legendary` 误配成 `LegendarySomething`。 +2. **同名目录在多处**时显式列出,所有位置都会打进同一个归档: + +```powershell + ImHex = @{ + Path = 'D:\Hex\ImHex' + Variants = @('D:\Hex\ImHex', 'E:\Backup\ImHex') + } +``` + +**分文件维护**:用 `Includes` 引入其它名录文件(路径相对本文件): + +```powershell +@{ + Includes = @('SoftwareCatalog.games.psd1') + ... +} +``` ## BackupList.txt 语法 ```text -<路径> [ :: <排除模式>[,<排除模式>...] ] [ @<标记> ] +<软件名 或 路径> [ :: <排除模式>[,<排除模式>...] ] [ @<标记> ] ``` -| 部分 | 说明 | +三种写法可以混用: + +| 写法 | 说明 | | --- | --- | -| 路径 | 支持 `%环境变量%`;可用双引号包裹(**引号只包路径**);`/` 与 `\` 等价;`#` 开头是注释 | -| 排除模式 | 相对归档根目录(源目录的末级名)。分隔符 `,` 与 `;` 都可以 | -| `!` 前缀 | 表示"任意层级下匹配这个名字",翻译成 7z 的 `-xr!`,例:`!*Cache` | -| 标记 | `encrypt` = 用 7z 加密该归档,见下文「加密」 | +| `FooClolor` | 软件名。去名录查目录,**归档名 = 软件名** | +| `%UserProfile%\Documents\PowerShell` | 字面路径(含 `\` `/` 或 `%` 就按路径处理),归档名沿用 `<末级名>_from_<上级路径>` | +| `FooClolor @pathname` | 软件名 + 强制用路径命名。适合想换到名录体系但暂时不想改归档名的条目 | + +| 标记 | 作用 | +| --- | --- | +| `encrypt` | 用 7z 加密该归档,见下文「加密」 | +| `pathname` | 用路径命名算法而不是软件名 | +| `root=<名>` | 覆盖归档内的根目录名(默认源目录名) | + +排除模式:相对归档根目录。以 `!` 开头表示"任意层级下匹配这个组件名"(7z 的 `-xr!`)。 +分隔符 `,` 与 `;` 都可以。**不要自己写引号**;模式里的空格会被自动转成 `?`。 几条已经踩过的坑(工具会处理,写的时候知道就行): - **模式里不要写引号。** `-x!"路径"` 会让引号成为模式的一部分,结果是**永不匹配**。 - **模式里的空格会被自动转成 `?`。** 7z 的排除模式不支持空格:`Default\Code Cache` 匹配不到任何东西,`Default\Code?Cache` 才可以。 -- **以第一个 `::` 为界切分。** `:` 在 Windows 路径里只可能是盘符,`::` 不会出现在真实路径里,所以整行被一对引号包住的历史写法(`"路径 :: 排除表"`)也能正确解析。 -- **改名即换归档。** 归档名由路径生成:`<末级名>_from_<上级路径用 + 连接>`。改动路径会生成新归档,旧归档需靠 `manifest.json` 找回。 +- **以第一个 `::` 为界切分。** `:` 在 Windows 路径里只可能是盘符,`::` 不会出现在真实路径里,所以整行被一对引号包住的历史写法也能正确解析。 +- **归档名重复会直接报错。** 归档名就是软件名,所以同一个软件写两遍会让两个条目互相覆盖 —— 脚本拒绝执行并提示。 + +## 归档命名与迁移 + +| 条目类型 | 归档名 | +| --- | --- | +| 软件名 | `<软件名>.7z` | +| 字面路径 | `<末级名>_from_<上级路径用 + 连接>.7z` | +| 软件名 + `@pathname` | 同字面路径 | + +从旧版本升级时用重命名工具把存量归档搬过来(**默认试运行**、逐份大小校验、重建 manifest): + +```powershell +.\tools\Rename-Archives.ps1 # 先看计划 +.\tools\Rename-Archives.ps1 -Apply # 确认后执行 +``` ## 恢复语义 - 用 `7z x` 解压到目标的**父目录**,覆盖同名文件。 +- **归档内部布局与历史完全一致**:根目录仍是源目录名(软件名只用于归档文件名)。 + 所以恢复逻辑不需要"剥掉一层",现有归档也不会因为重命名而解不开。 - **不做镜像同步**:目标目录里多出来的文件不会被删除。想得到"完全等于归档"的目录,请先清空目标。 - 目标目录比归档新时**默认跳过**,需要覆盖就加 `-Force`。 - `-WhatIf` / `-DryRun` 只打印计划;`-VerifyOnly` 只跑 `7z t`。 -- **排除规则只在下一份归档里生效**:已经生成的归档不会因为改了排除表而"变干净",需要重跑备份才会生成新归档。 +- **排除规则只在下一份归档里生效**:已经生成的归档不会因为改了排除表而"变干净"。 ## manifest.json -`Backups/manifest.json` 以归档基础名为键记录每个条目: +以归档基础名为键记录每个条目: | 字段 | 含义 | | --- | --- | -| `source` / `resolvedSource` | 清单里的原始路径(未展开环境变量)/ 实际路径 | +| `source` | 清单里的原始写法(软件名或路径) | +| `resolvedSource` | 展开后的路径 | +| `roots` | 归档内的根目录名 | +| `catalog` | 名录里记录的路径(便于追溯软件名到底指向哪) | | `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` | 是否为加密归档(恢复时据此判断是否需要口令) | +| `lastSuccessAt` / `successCount` / `failCount` / `lastRestoreAt` / `encrypted` | 历史与安全标记 | `Restore.ps1` **优先用 manifest 定位归档**,查不到才退回"从文件名反推路径"。 如果 `BackupList.txt` 丢了,`Restore.ps1` 会优先用 manifest 里的 `source` 自动重建。 @@ -109,7 +176,9 @@ BackupDir = 'Backups' # 相对路径按脚本所在目录解析 LogDir = 'logs' SnapshotDir = 'Backups\snapshots' - MinFreeSpaceGB = 5 # 低于此值告警;真放不下某个条目则跳过该条目 + SoftwareCatalog = 'SoftwareCatalog.psd1' + CatalogMaxDepth = 5 # 前缀补全时最多向下找几层 + MinFreeSpaceGB = 5 VerifyArchive = $true # 归档后跑 7z t ComputeHash = $false # 是否额外算 SHA256(大归档很慢) CompressionLevel = 9 @@ -120,16 +189,16 @@ } ``` -优先级:**命令行参数 > `BackupConfig.psd1` > 代码内置默认值**。也可以用 `-ConfigPath` 指定其它配置文件。 +优先级:**命令行参数 > `BackupConfig.psd1` > 代码内置默认值**,也可以用 `-ConfigPath` 指定其它配置文件。 ## 加密 -加密是**按需开启**的,默认关闭——一旦开启而口令丢失,备份就再也解不开。 +默认关闭 —— 一旦开启而口令丢失,备份就再也解不开。 ```powershell # 方式一:只为个别条目加密(.ssh 里是私钥,最典型) # 在 BackupList.txt 里写成: -# %UserProfile%/.ssh @encrypt +# .ssh @encrypt # 方式二:全部加密,改配置 # Encryption = @{ Enabled = $true; PasswordFile = 'D:\secret\baknret.key' } @@ -142,31 +211,23 @@ $env:BAKNRET_PASSWORD = '...' # 或 要求加密但取不到口令时,该条目会**明确失败**,绝不会退化成明文归档。 恢复加密归档时同理:取不到口令就直接失败,不会让 7z 停在控制台等待输入(在计划任务里那会静默挂起)。 -> ⚠️ 7-Zip 只接受命令行口令,口令在本机进程列表里会短暂可见。这是 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 +.\tools\Register-BackupTask.ps1 -At '21:30' -DryRun # 先看将要注册什么 +.\tools\Register-BackupTask.ps1 -At '21:30' # 注册 +.\tools\Register-BackupTask.ps1 -Remove # 移除 ``` -任务会调用 `Backup.ps1`,脚本自身写日志并按失败数返回退出码,所以「上次运行结果」在任务计划程序里是可读的。 +任务调用 `Backup.ps1`,脚本自身写日志并按失败数返回退出码,所以「上次运行结果」在任务计划程序里可读。 ## 测试 ```powershell -# 单元测试:解析、命名、排除参数翻译、命令行拼接、manifest、配置 + 真实 7z 集成 -.\tests\Run-Tests.ps1 - -# 端到端验收:备份 -> 验证排除 -> 删源 -> 恢复 -> 逐字节对拍(全程在临时目录) -.\tests\Run-E2E.ps1 +.\tests\Run-Tests.ps1 # 单元测试 42 项 +.\tests\Run-E2E.ps1 # 端到端验收 23 项 ``` 零依赖,不需要 Pester(本机只有 3.4.0,`Should -Be` 会直接语法错误)。 @@ -175,29 +236,32 @@ $env:BAKNRET_PASSWORD = '...' # 或 | 问题 | 旧行为 | 现行为 | | --- | --- | --- | -| `Start-Process -PassThru` 的 `ExitCode` 在 PowerShell 7.7.0-preview.4 上恒为 `$null` | 压缩明明成功(`Everything is Ok`)却报"压缩失败",`exit 2 → 删档重试` 的自愈分支永远不可达 | 用 `.NET Process` 继承控制台启动,退出码可靠 | +| `Start-Process -PassThru` 的 `ExitCode` 在 PowerShell 7.7.0-preview.4 上恒为 `$null` | 压缩明明成功却报"压缩失败",`exit 2 → 删档重试` 的自愈分支永远不可达 | 用 `.NET Process` 继承控制台启动,退出码可靠 | | 排除模式写成 `-x!"路径"` | 引号成为模式的一部分,**排除对所有条目都失效** | 不再嵌引号;含空格自动转 `?`,`!` 前缀走 `-xr!` | | 解析器用 `;` 分隔,清单里写的是 `,` | 整串被当成一个模式,等于没有排除 | `,` 与 `;` 都支持 | -| `^"([^"]+)"` 贪婪匹配 | 整行加引号的写法把排除表吞进路径 → 该条目被静默跳过,且 2.8 GB 归档成了找不到的孤儿 | 先按 `::` 切分再处理引号 | +| `^"([^"]+)"` 贪婪匹配 | 整行加引号的写法把排除表吞进路径 → 该条目被静默跳过,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 仓库 | — | 都有 | +| 没有名录、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` 只影响打包,不影响恢复。** +- **放弃 7z 的更新模式(`u`)。** 7z 默认固实压缩,`u` 本来就要重压大部分数据,收益很小,却让"排除规则改动"和"源里删掉的文件"永远进不了归档。 +- **归档内部不套一层软件名目录。** 考虑过用暂存目录(硬链/复制)把归档根目录改成软件名,代价是多一次链接开销、实现复杂度上升,收益只是"解开包第一层好看"。归档名已经是软件名,包内保持源目录名也便于确认内容来源。顺带一提,7z 的 `-spf` 不是干这个的(它是 *use fully qualified file paths*)。 +- **不捕获压缩工具的输出。** 结构化记录交给日志与 `manifest.json`;捕获子进程 stdio 需要额外管道,在受限环境里会直接失败。 +- **有警告(退出码 1)时不覆盖完整的归档。** 被占用的文件会让 7z 返回 1,此时新归档是**不完整**的。实测 Edge 运行时打包,118 个文件读不到,其中包含 `Login Data`(密码)、`Cookies`、`History`、`Web Data`。所以在位归档完整时脚本**保留它、报失败、退出码 1**,确认可以接受再显式加 `-AcceptWarnings`。 +- **名录里的路径不存在时,恢复仍然可用。** 源被删掉正是要恢复的场景,所以解析器照旧给出 `Sources`,备份端则据此跳过。 +- **源路径不存在只算"跳过",不算失败。** 会以 `missing-source` 记进 manifest。失败只统计真正打不开的条目。 ## 已知限制 -- 归档名与路径强耦合:改清单里的路径写法会生成新归档名。恢复时 manifest 能兜底,但**别轻易改已备份条目的路径写法**。 +- **改软件名等于换归档名。** 改名后旧归档不会被自动迁移,用 `tools/Rename-Archives.ps1` 或手动改名,并注意 manifest 里会留下旧键。 - 路径里本来就含 `+` 或 `_from_` 时,仅靠文件名无法可靠反推路径,此时依赖 `manifest.json`。 -- `-Snapshot` 目前是"复制一份带时间戳的副本",不做自动轮转清理(`KeepCount` / `KeepDays` 已保留在配置里,清理逻辑尚未实现)。 +- `-Snapshot` 目前是"复制一份带时间戳的副本",不做自动轮转清理(`KeepCount` / `KeepDays` 尚未实现)。 - 加密归档的常规备份/恢复不依赖 `RAR`;`RAR` 与内置 `ZIP` 分支仅作降级,未做加密支持(ZIP 明确拒绝加密请求)。 +- `Variants`(同名目录分散在多处)当前打包第一个位置,恢复时逐个位置各解压一份。 diff --git a/Restore.ps1 b/Restore.ps1 index 887310f..ac33310 100644 --- a/Restore.ps1 +++ b/Restore.ps1 @@ -82,11 +82,13 @@ function Resolve-ConfigPath { if (-not $BackupDir) { $BackupDir = Resolve-ConfigPath -Path $null -Default $script:Config.BackupDir } $logDir = Resolve-ConfigPath -Path $null -Default $script:Config.LogDir +$catalogPath = Resolve-CatalogPath -Configured $script:Config.SoftwareCatalog -Root $PSScriptRoot $manifestPath = Join-Path $BackupDir 'manifest.json' $logPath = Start-BaknretLog -Directory $logDir -Prefix 'restore' Write-Log "日志文件:$logPath" Write-Log "备份目录:$BackupDir" +Write-Log ("软件名录:{0}{1}" -f $catalogPath, $(if (Test-Path -LiteralPath $catalogPath) { '' } else { '(不存在,将只支持字面路径)' })) if ($WhatIfPreference) { Write-Log '试运行模式(-WhatIf / -DryRun):不会写入任何文件' -Level WARN } if (-not (Test-Administrator)) { @@ -111,8 +113,7 @@ function Find-ArchiveByBaseName { #> param([string]$BaseName) - $candidate = Get-ChildItem -LiteralPath $BackupDir -File -Force -ErrorAction SilentlyContinue | - Where-Object { $_.BaseName -eq $BaseName -and $_.Extension.ToLower() -in $SupportedFormats } | + $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 } @@ -143,6 +144,19 @@ function Get-ArchiveForEntry { return $null } +function Get-7zExecutable { + <# .SYNOPSIS 定位 7z.exe(PATH 优先,其次是常见安装位置)。 #> + $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 + } + return $sevenZip +} + function Invoke-Extraction { param([object]$ArchiveFile, [string]$DestinationPath) @@ -153,15 +167,9 @@ function Invoke-Extraction { 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 - } - + # 归档布局与历史保持一致:根目录就是源目录名(软件名条目也一样, + # 软件名只用于归档文件名),因此直接整包解压到目标的父目录即可。 + $sevenZip = Get-7zExecutable if ($sevenZip) { Write-Log '使用 7z 解压' -Level DEBUG $argument = @('x', '-bsp2', '-y', "-o$destParent") @@ -278,8 +286,8 @@ foreach ($line in $lines) { if (-not $item) { continue } $displayPath = $item.Path - $destPath = [Environment]::ExpandEnvironmentVariables($displayPath) - $baseName = Get-BackupBaseName -RawPath $displayPath + $resolved = Resolve-BackupEntry -Entry $item -CatalogPath $catalogPath -MaxDepth $script:Config.CatalogMaxDepth + $baseName = $resolved.BaseName if (-not $baseName) { $stats.skipped++; continue } if (-not (Test-EntrySelected -DisplayPath $displayPath -BaseName $baseName)) { continue } @@ -291,6 +299,29 @@ foreach ($line in $lines) { continue } + # 恢复目的地。清单条目可能带多个源(同名目录分散在多处),每个源各恢复各的。 + $targets = @() + if ($resolved.Sources.Count -gt 0) { + foreach ($source in $resolved.Sources) { + $targets += [pscustomobject]@{ DestPath = $source.SourcePath } + } + } else { + $targets += [pscustomobject]@{ DestPath = [Environment]::ExpandEnvironmentVariables($displayPath) } + } + + # 防御:解析不出目的地时明确失败,别把空字符串喂给 Split-Path/Test-Path + # (那种报错是 "Cannot bind argument to parameter 'Path' because it is an empty string") + $targets = @($targets | Where-Object { $_.DestPath -and -not [string]::IsNullOrWhiteSpace($_.DestPath) }) + if ($targets.Count -eq 0) { + $reason = "无法确定恢复目的地(清单条目 '$displayPath' 解析不出任何源路径)" + Write-Log "失败: $displayPath,$reason" -Level ERROR + $stats.failed++ + $failures += $displayPath + continue + } + + $destPath = $targets[0].DestPath + $archiveFile = $found.File $referencedArchives += $archiveFile.BaseName @@ -312,8 +343,17 @@ foreach ($line in $lines) { 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) + $verifyTool = Get-7zExecutable + if (-not $verifyTool) { + Write-Log '未找到 7z,无法校验' -Level ERROR + $stats.failed++ + $failures += $displayPath + continue + } + $verifyArgument = @('t', '-bso0', '-bsp0') + if ($password) { $verifyArgument += "-p$password" } + $verifyArgument += $archiveFile.FullName + $verifyCode = Invoke-ExternalCommand -FilePath $verifyTool -ArgumentList $verifyArgument if ($verifyCode -eq 0) { Write-Log "校验通过: $($archiveFile.Name)" -Level INFO $stats.verified++ @@ -341,14 +381,41 @@ foreach ($line in $lines) { } } - if (-not $PSCmdlet.ShouldProcess($destPath, "从 $($archiveFile.Name) 解压")) { - Write-Log "[试运行] 将解压 $($archiveFile.Name) -> $(Split-Path -Path $destPath -Parent)" -Level INFO + $plannedTargets = @($targets | Where-Object { $_.DestPath }) + foreach ($target in $plannedTargets) { + if (Test-Path -LiteralPath $target.DestPath) { continue } + # Split-Path -Parent 对根路径(如 "E:\")返回空串,此时无父目录可建 + $targetParent = Split-Path -Path $target.DestPath -Parent + if ($targetParent) { + Write-Log "提示: 目标不存在,将新建 $targetParent" -Level DEBUG + } else { + Write-Log "提示: 目标不存在,且没有可创建的父目录:$($target.DestPath)" -Level DEBUG + } + } + + $shouldRun = $true + foreach ($target in $plannedTargets) { + if (-not $PSCmdlet.ShouldProcess($target.DestPath, "从 $($archiveFile.Name) 解压")) { $shouldRun = $false } + } + + if (-not $shouldRun) { + foreach ($target in $plannedTargets) { + Write-Log "[试运行] 将解压 $($archiveFile.Name) -> $($target.DestPath)" -Level INFO + } $stats.planned++ continue } + $restoreFailed = $false try { - if (Invoke-Extraction -ArchiveFile $archiveFile -DestinationPath $destPath) { + foreach ($target in $plannedTargets) { + if (-not (Invoke-Extraction -ArchiveFile $archiveFile -DestinationPath $target.DestPath)) { + $restoreFailed = $true + break + } + } + + if (-not $restoreFailed) { $stats.restored++ Write-Log "恢复成功: $baseName" -Level INFO diff --git a/SoftwareCatalog.psd1 b/SoftwareCatalog.psd1 new file mode 100644 index 0000000..ba053e0 --- /dev/null +++ b/SoftwareCatalog.psd1 @@ -0,0 +1,86 @@ +<# + 软件名录:维护"软件名 -> 目录"的映射。 + + 有这个文件之后,BackupList.txt 里可以直接写软件名: + + FooClolor + Kazumi :: !*Cache + Edge :: !*Cache,component_crx_cache + .ssh @encrypt + + 归档包的名字也就是软件名(`FooClolor.7z`),不再是 + `FooClolor_from_C_+Programs.7z` 这种由路径拼出来的名字。 + + 写法: + + <软件名> = '<目录>' + + 软件名的限制: + * 必须是合法的文件名(不能含 \ / : * ? " < > |),因为它就是归档名; + * 不能含 `\` 或 `/` 或 `%`,否则会被当作字面路径而不是软件名; + * **含 `-` 或 `.` 的名字必须写成带引号的键**,否则 PowerShell 会把 + `a-b` 解析成减法表达式并报 "Missing '=' operator": + 'scoop-config' = '...' # 正确 + scoop-config = '...' # 报错 + * 建议用英文/数字,但中文也可以。 + + 目录可以写环境变量,例如 '%UserProfile%\.ssh'。 + + 两个便利特性: + + 1. 目录不存在时会按前缀补全:写 'D:\Programs\legendary',实际目录是 + 'D:\Programs\legendary_2.0.4',会自动匹配(只认 `<名>_*` 与 `<名>-*`, + 不会把 Legendary 误配成 LegendarySomething)。 + 2. 同名目录出现在多个位置时,用字典形式显式列出,所有位置都会打进同一个归档: + + ImHex = @{ + Path = 'D:\Hex\ImHex' + Variants = @('D:\Hex\ImHex', 'E:\Backup\ImHex') + } + + 分文件维护:用 Includes 引入其它名录文件(路径相对本文件): + + @{ + Includes = @('SoftwareCatalog.games.psd1') + ... + } +#> +@{ + # ---- 用户配置 / 开发环境 ---- + # 含 `-` 或 `.` 的键必须加引号,否则会被当成减法表达式(见文件开头说明) + legendary = '%UserProfile%\.config\legendary' + opencode = '%UserProfile%\.config\opencode' + 'scoop-config' = '%UserProfile%\.config\scoop' + 'scoop-persist' = '%UserProfile%\scoop\persist' + '.ssh' = '%UserProfile%\.ssh' + CodeSpace = 'D:\UserData\Documents\CodeSpace' + PowerShell = '%UserProfile%\Documents\PowerShell' + WindowsPowerShell = '%UserProfile%\Documents\WindowsPowerShell' + + # ---- 应用数据 ---- + AutoDarkMode = '%AppData%\AutoDarkMode' + Kazumi = '%AppData%\com.example\Kazumi' + piliplus = '%AppData%\com.example\piliplus' + fnm = '%AppData%\fnm' + 'twinkle-tray' = '%AppData%\twinkle-tray' + + # ---- 浏览器与终端 ---- + Edge = '%LocalAppData%\Microsoft\Edge\User Data' + WindowsTerminal = '%LocalAppData%\Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json' + + # ---- 系统 ---- + Startup = '%ProgramData%\Microsoft\Windows\Start Menu\Programs\Startup' + + # ---- C:\Programs ---- + BaiduNetdisk = 'C:\Programs\BaiduNetdisk' + FooClolor = 'C:\Programs\FooClolor' + March7thAssistant = 'C:\Programs\March7thAssistant' + MiFlash = 'C:\Programs\MiFlash' + MiFlash_Unlock = 'C:\Programs\MiFlash_Unlock' + QuarkCloudDrive = 'C:\Programs\QuarkCloudDrive' + translucenttb = 'C:\Programs\ScoopApps\apps\translucenttb\current\settings.json' + 'ScoopApps-persist' = 'C:\Programs\ScoopApps\persist' + + # ---- 其它盘 ---- + Aria = 'D:\UserData\Documents\Aria' +} diff --git a/tests/Run-E2E.ps1 b/tests/Run-E2E.ps1 index 5e99e68..9502876 100644 --- a/tests/Run-E2E.ps1 +++ b/tests/Run-E2E.ps1 @@ -253,7 +253,94 @@ Test-Case 'Backup -DryRun:退出码 0、不写归档也不写 manifest' { } # ============================================================================ -# 6. 失败路径:源不存在时必须留下可核对的记录 +# 6. 软件名录:清单里写软件名,归档名就是软件名 +# ============================================================================ + +$catRoot = Join-Path $WorkRoot 'catalog' +$catSource = Join-Path $catRoot 'src' +$catTarget = Join-Path $catSource 'My App' # 真实目录名与软件名刻意不同 +$catBackupDir = Join-Path $catRoot 'Backups' +$catFile = Join-Path $catRoot 'SoftwareCatalog.psd1' +$catList = Join-Path $catRoot 'list.txt' +$catConfig = Join-Path $catRoot 'config.psd1' + +New-Item -ItemType Directory -Path $catTarget -Force | Out-Null +Set-Content -LiteralPath (Join-Path $catTarget 'data.txt') -Value 'catalog-test' -Encoding UTF8 +Set-Content -LiteralPath (Join-Path $catTarget 'skip.bin') -Value 'nope' -Encoding UTF8 + +[System.IO.File]::WriteAllText($catFile, "@{`n 'my-app' = '$catTarget'`n}`n", [System.Text.UTF8Encoding]::new($false)) +[System.IO.File]::WriteAllText($catList, "my-app :: skip.bin`n", [System.Text.UTF8Encoding]::new($false)) +[System.IO.File]::WriteAllText($catConfig, "@{ SoftwareCatalog = '$catFile' }`n", [System.Text.UTF8Encoding]::new($false)) + +& $backupScript -BackupListPath $catList -BackupDir $catBackupDir -ConfigPath $catConfig -Force -QuietTool +$catExitCode = $LASTEXITCODE +$catArchive = Join-Path $catBackupDir 'my-app.7z' + +Test-Case '清单里写软件名 -> 归档名就是软件名' { + Assert-Equal 0 $catExitCode + Assert-FileExists $catArchive +} + +Test-Case '软件名条目的归档内容与历史布局一致(根目录仍是源目录名)' { + $extract = Join-Path $catRoot 'verify' + New-Item -ItemType Directory -Path $extract -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$extract", $catArchive) + # 归档文件名是软件名 my-app,但包内根目录是源目录名 My App + Assert-FileExists (Join-Path $extract 'My App\data.txt') + Assert-FileMissing (Join-Path $extract 'my-app') '包内不应多出一层软件名' + Assert-FileMissing (Join-Path $extract 'My App\skip.bin') '排除模式以源目录名为前缀,仍然生效' + } +} + +Test-Case '@pathname 覆盖:强制用路径命名算法(用独立备份目录,避免污染共享 manifest)' { + $pathList = Join-Path $catRoot 'list-pathname.txt' + $pathNameDir = Join-Path $catRoot 'Backups-pathname' + [System.IO.File]::WriteAllText($pathList, "my-app @pathname`n", [System.Text.UTF8Encoding]::new($false)) + & $backupScript -BackupListPath $pathList -BackupDir $pathNameDir -ConfigPath $catConfig -Force -QuietTool + Assert-Equal 0 $LASTEXITCODE + + # 名录里存的是绝对路径,所以路径命名结果也基于它 + $expectedBase = Get-BackupBaseName -RawPath $catTarget + Assert-FileExists (Join-Path $pathNameDir ($expectedBase + '.7z')) +} + +Test-Case '软件名录条目:删源后能按原路径恢复' { + Remove-Item -LiteralPath $catTarget -Recurse -Force + & $restoreScript -BackupListPath $catList -BackupDir $catBackupDir -ConfigPath $catConfig -Force + Assert-Equal 0 $LASTEXITCODE + Assert-FileExists (Join-Path $catTarget 'data.txt') + Assert-Equal 'catalog-test' (Get-Content -LiteralPath (Join-Path $catTarget 'data.txt') -Raw).Trim() +} + + +Test-Case '归档名重复时直接报失败,不静默互相覆盖' { + $dupList = Join-Path $catRoot 'dup.txt' + [System.IO.File]::WriteAllText($dupList, "my-app`nmy-app`n", [System.Text.UTF8Encoding]::new($false)) + & $backupScript -BackupListPath $dupList -BackupDir $catBackupDir -ConfigPath $catConfig -Force -QuietTool + Assert-Equal 1 $LASTEXITCODE '重复条目应让退出码为 1' +} + +Test-Case '字面路径不受名录影响,仍走路径命名' { + $literalList = Join-Path $catRoot 'list-literal.txt' + [System.IO.File]::WriteAllText($literalList, "$catTarget`n", [System.Text.UTF8Encoding]::new($false)) + & $backupScript -BackupListPath $literalList -BackupDir $catBackupDir -ConfigPath $catConfig -Force -QuietTool + Assert-Equal 0 $LASTEXITCODE +} + +Test-Case '名录里没有该软件名时记为 missing-source,而不是崩掉' { + $badList = Join-Path $catRoot 'bad.txt' + [System.IO.File]::WriteAllText($badList, "no-such-app`n", [System.Text.UTF8Encoding]::new($false)) + & $backupScript -BackupListPath $badList -BackupDir $catBackupDir -ConfigPath $catConfig -Force -QuietTool + Assert-Equal 0 $LASTEXITCODE '跳过不算失败' + $rec = (Read-BaknretManifest -Path (Join-Path $catBackupDir 'manifest.json')).items['no-such-app'] + Assert-True ($null -ne $rec) '应留下记录' + Assert-Equal 'missing-source' $rec.action +} + +# ============================================================================ +# 7. 失败路径:源不存在时必须留下可核对的记录 # ============================================================================ $missingList = Join-Path $WorkRoot 'missing.txt' diff --git a/tests/Run-Tests.ps1 b/tests/Run-Tests.ps1 index 9f7dd51..a457f24 100644 --- a/tests/Run-Tests.ps1 +++ b/tests/Run-Tests.ps1 @@ -251,6 +251,105 @@ Test-Case '口令:环境变量可读取,取不到返回 $null' { } } +# ============================================================================ +Write-Host "`n== 软件名录 ==" -ForegroundColor Cyan +# ============================================================================ + +$catalogSandbox = Join-Path $env:TEMP ("baknret-cat-" + [guid]::NewGuid().ToString('N').Substring(0, 8)) +New-Item -ItemType Directory -Path $catalogSandbox -Force | Out-Null + +Test-Case '名录解析:裸键、引号键、带 - 与 . 的名字' { + $catPath = Join-Path $catalogSandbox 'SoftwareCatalog.psd1' + $content = "@{`n FooClolor = 'C:\Programs\FooClolor'`n 'scoop-config' = '%UserProfile%\.config\scoop'`n '.ssh' = '%UserProfile%\.ssh'`n}`n" + [System.IO.File]::WriteAllText($catPath, $content, [System.Text.UTF8Encoding]::new($false)) + + $catalog = Get-SoftwareCatalog -Path $catPath + Assert-Equal 3 $catalog.Count + Assert-True $catalog.ContainsKey('FooClolor') + Assert-True $catalog.ContainsKey('scoop-config') '带连字符的键必须能解析' + Assert-True $catalog.ContainsKey('.ssh') '带点的键必须能解析' +} + +Test-Case '名录解析:目录带版本后缀时按前缀补全(legendary_2.0.4)' { + $root = Join-Path $catalogSandbox 'probe' + New-Item -ItemType Directory -Path (Join-Path $root 'legendary_2.0.4') -Force | Out-Null + $catPath = Join-Path $catalogSandbox 'suffix.psd1' + [System.IO.File]::WriteAllText($catPath, "@{`n legendary = '$root\legendary'`n}`n", [System.Text.UTF8Encoding]::new($false)) + + $catalog = Get-SoftwareCatalog -Path $catPath + Assert-Equal 'Single' $catalog['legendary'].Kind + Assert-True ($catalog['legendary'].ResolvedPath -like '*legendary_2.0.4') '应补全到实际目录' +} + +Test-Case '名录解析:不会把 Legendary 误配成 LegendarySomething' { + $root = Join-Path $catalogSandbox 'strict' + New-Item -ItemType Directory -Path (Join-Path $root 'LegendarySomething') -Force | Out-Null + $catPath = Join-Path $catalogSandbox 'strict.psd1' + [System.IO.File]::WriteAllText($catPath, "@{`n Legendary = '$root\Legendary'`n}`n", [System.Text.UTF8Encoding]::new($false)) + + $catalog = Get-SoftwareCatalog -Path $catPath + Assert-Equal 'Unresolved' $catalog['Legendary'].Kind '必须以下一个字符是 _ 或 - 为界' +} + +Test-Case '软件名条目:默认用软件名做归档名' { + $item = ConvertFrom-BackupListLine -Line 'FooClolor' + $resolved = Resolve-BackupEntry -Entry $item -CatalogPath (Join-Path $catalogSandbox 'SoftwareCatalog.psd1') + Assert-Equal 'FooClolor' $resolved.BaseName + Assert-Equal 'name' $resolved.ArchiveFlavor + Assert-True $resolved.IsName +} + +Test-Case '字面路径条目:仍用路径命名算法(现有清单无需改写)' { + $item = ConvertFrom-BackupListLine -Line 'C:\Programs\FooClolor' + $resolved = Resolve-BackupEntry -Entry $item -CatalogPath (Join-Path $catalogSandbox 'SoftwareCatalog.psd1') + Assert-False $resolved.IsName + Assert-Equal 'path' $resolved.ArchiveFlavor + Assert-Equal 'FooClolor_from_C_+Programs' $resolved.BaseName +} + +Test-Case '@pathname 用真实路径命名,而不是软件名' { + $item = ConvertFrom-BackupListLine -Line 'FooClolor @pathname' + $resolved = Resolve-BackupEntry -Entry $item -CatalogPath (Join-Path $catalogSandbox 'SoftwareCatalog.psd1') + Assert-Equal 'path' $resolved.ArchiveFlavor + Assert-Equal 'FooClolor_from_C_+Programs' $resolved.BaseName +} + +Test-Case '名录里没有该名字:BaseName 退回可读目录名,并给出 Error' { + $item = ConvertFrom-BackupListLine -Line 'no-such-thing' + $resolved = Resolve-BackupEntry -Entry $item -CatalogPath (Join-Path $catalogSandbox 'SoftwareCatalog.psd1') + Assert-Equal 'no-such-thing' $resolved.BaseName + Assert-True ($null -ne $resolved.Error) '应给出错误说明' + Assert-Equal 0 $resolved.Sources.Count +} + +Test-Case '名录里的路径不存在时仍给出 Sources(恢复要靠它还原回原位)' { + $catPath = Join-Path $catalogSandbox 'gone.psd1' + [System.IO.File]::WriteAllText($catPath, "@{`n 'gone-app' = 'C:\definitely\not\here'`n}`n", [System.Text.UTF8Encoding]::new($false)) + $item = ConvertFrom-BackupListLine -Line 'gone-app' + $resolved = Resolve-BackupEntry -Entry $item -CatalogPath $catPath + Assert-True ($null -ne $resolved.Error) '备份端据 Error 跳过' + Assert-Equal 1 $resolved.Sources.Count '恢复端据 Sources 还原' + Assert-Equal 'C:\definitely\not\here' $resolved.Sources[0].SourcePath +} + +Test-Case 'Includes:分文件维护的名录会被合并' { + $basePath = Join-Path $catalogSandbox 'Base.psd1' + $extraPath = Join-Path $catalogSandbox 'Extra.psd1' + [System.IO.File]::WriteAllText($extraPath, "@{`n 'extra-app' = 'C:\Programs\Extra'`n}`n", [System.Text.UTF8Encoding]::new($false)) + [System.IO.File]::WriteAllText($basePath, "@{`n Includes = @('Extra.psd1')`n 'base-app' = 'C:\Programs\Base'`n}`n", [System.Text.UTF8Encoding]::new($false)) + + $catalog = Get-SoftwareCatalog -Path $basePath + Assert-True $catalog.ContainsKey('base-app') + Assert-True $catalog.ContainsKey('extra-app') 'Includes 引入的条目也应在' +} + +Test-Case '软件名会被规范化成合法文件名' { + Assert-Equal 'keep%and&and+plus' (Format-CatalogName -Name 'keep%and&and+plus') + Assert-True ((Format-CatalogName -Name 'a/b:c') -notmatch '[/:]') '非法字符应被替换' +} + +Remove-Item -LiteralPath $catalogSandbox -Recurse -Force -ErrorAction SilentlyContinue + # ============================================================================ Write-Host "`n== 集成测试:真的跑 7z,验证排除结果落在文件系统上 ==" -ForegroundColor Cyan # ============================================================================ diff --git a/tools/Rename-Archives.ps1 b/tools/Rename-Archives.ps1 new file mode 100644 index 0000000..01462e6 --- /dev/null +++ b/tools/Rename-Archives.ps1 @@ -0,0 +1,260 @@ +<# +.SYNOPSIS + 把按路径命名的旧归档重命名成软件名,并重建 manifest.json。 + +.DESCRIPTION + 重构前的归档名是 `<末级名>_from_<上级路径>`(如 FooClolor_from_C_+Programs.7z)。 + 引入软件名录后,归档名默认就是软件名(FooClolor.7z)。这个脚本负责把存量归档搬过去。 + + 做法: + 1. 遍历清单条目,算出"旧名"(路径命名算法)与"新名"(当前规则); + 2. 只在两者不同、且旧名归档确实存在时才处理; + 3. 重命名(不是复制,同卷上是元数据操作,不搬数据); + 4. 重建 manifest.json,把旧记录的历史字段(成功次数、SHA256 等)迁过去; + 5. 比对重命名前后的文件大小做完整性自检。 + + 默认是**试运行**,只打印计划。确认无误后加 -Apply 才真正执行。 + +.EXAMPLE + # 先看会发生什么(不改任何文件) + .\tools\Rename-Archives.ps1 + +.EXAMPLE + # 真正执行 + .\tools\Rename-Archives.ps1 -Apply +#> + +[CmdletBinding()] +param( + [string]$BackupListPath = (Join-Path (Split-Path -Parent $PSScriptRoot) 'BackupList.txt'), + [string]$BackupDir, + [string]$ConfigPath, + [switch]$Apply +) + +$ErrorActionPreference = 'Stop' + +$projectRoot = Split-Path -Parent $PSScriptRoot +Import-Module (Join-Path $projectRoot 'Common.psm1') -Force + +if (-not $ConfigPath) { $ConfigPath = Join-Path $projectRoot 'BackupConfig.psd1' } +$config = Get-BaknretConfig -Path $ConfigPath + +if (-not $BackupDir) { + $BackupDir = $config.BackupDir + if (-not [System.IO.Path]::IsPathRooted($BackupDir)) { $BackupDir = Join-Path $projectRoot $BackupDir } +} + +$catalogPath = Resolve-CatalogPath -Configured $config.SoftwareCatalog -Root $projectRoot +$manifestPath = Join-Path $BackupDir 'manifest.json' +$supportedFormats = @('.7z', '.rar', '.zip', '.tar') + +if (-not (Test-Path -LiteralPath $BackupDir)) { + Write-Host "备份目录不存在:$BackupDir" -ForegroundColor Red + exit 1 +} + +Write-Host '' +Write-Host "备份目录 : $BackupDir" +Write-Host "软件名录 : $catalogPath" +Write-Host "清单 : $BackupListPath" +Write-Host "模式 : $(if ($Apply) { '执行(-Apply)' } else { '试运行(默认,不改任何文件)' })" +Write-Host '' + +function Find-ArchiveByBaseName { + param([string]$BaseName, [string]$Directory, [string[]]$Formats) + return Get-ChildItem -LiteralPath $Directory -File -Force -ErrorAction SilentlyContinue | + Where-Object { $_.BaseName -eq $BaseName -and $_.Extension.ToLower() -in $Formats } | + Select-Object -First 1 +} + +# --------------------------------------------------------------------------- +# 阶段一:规划 +# --------------------------------------------------------------------------- + +$manifestOld = Read-BaknretManifest -Path $manifestPath + +$plan = @() +$unchanged = 0 +$missingOld = 0 +$conflicts = @() +$seenNew = @{} +$entries = @() + +foreach ($line in (Get-Content -LiteralPath $BackupListPath)) { + $item = ConvertFrom-BackupListLine -Line $line + if (-not $item) { continue } + + # 旧名 = 对"真实源路径"跑路径命名算法。 + # 注意:清单里现在写的是软件名,直接把它丢给 Get-BackupBaseName 会得到一个 + # 恰好和软件名一模一样的"旧名"(legendary -> legendary),于是永远算不出 + # 真正的旧名。必须先解析出真实路径。 + $resolved = Resolve-BackupEntry -Entry $item -CatalogPath $catalogPath -MaxDepth $config.CatalogMaxDepth + $newName = $resolved.BaseName + + $oldNameSource = $item.Path + if ($resolved.IsName -and $resolved.CatalogEntry) { $oldNameSource = $resolved.CatalogEntry.Path } + $oldName = Get-BackupBaseName -RawPath $oldNameSource + + if (-not $oldName -or -not $newName) { continue } + + if ($seenNew.ContainsKey($newName)) { + $conflicts += "归档名 '$newName' 被 '$($seenNew[$newName])' 和 '$($item.Path)' 同时使用" + continue + } + $seenNew[$newName] = $item.Path + + $entries += [pscustomobject]@{ Item = $item; OldName = $oldName; NewName = $newName; Resolved = $resolved } + + if ($oldName -eq $newName) { $unchanged++; continue } + + $oldFile = Find-ArchiveByBaseName -BaseName $oldName -Directory $BackupDir -Formats $supportedFormats + if (-not $oldFile) { $missingOld++; continue } + + $plan += [pscustomobject]@{ + Old = $oldFile + New = ($newName + $oldFile.Extension) + Item = $item + Resolved = $resolved + } +} + +Write-Host "待重命名 $($plan.Count) 个;命名未变 $unchanged 个;清单里有但归档不存在 $missingOld 个" + +if ($plan.Count -gt 0) { + Write-Host '' + foreach ($entry in $plan) { + Write-Host (" {0,10:N1} MB {1}" -f ($entry.Old.Length / 1MB), $entry.Old.Name) + Write-Host (" -> {0}" -f $entry.New) + } +} + +if ($conflicts.Count -gt 0) { + Write-Host '' + Write-Host '存在归档名冲突,必须先解决(否则会互相覆盖):' -ForegroundColor Red + $conflicts | ForEach-Object { Write-Host " - $_" -ForegroundColor Red } + exit 1 +} + +Write-Host '' + +if (-not $Apply) { + Write-Host '试运行结束,未改动任何文件。确认无误后加 -Apply 执行。' -ForegroundColor Yellow + exit 0 +} + +# --------------------------------------------------------------------------- +# 阶段二:重命名 +# --------------------------------------------------------------------------- + +$ok = 0 +$failed = 0 + +foreach ($entry in $plan) { + $target = Join-Path $BackupDir $entry.New + try { + if (Test-Path -LiteralPath $target) { + Write-Host "跳过(目标已存在): $($entry.New)" -ForegroundColor Yellow + $failed++ + continue + } + + $oldSize = $entry.Old.Length + Rename-Item -LiteralPath $entry.Old.FullName -NewName $entry.New -ErrorAction Stop + + $newFile = Get-Item -LiteralPath $target -ErrorAction Stop + if ($newFile.Length -ne $oldSize) { + throw "重命名后大小不一致($oldSize -> $($newFile.Length))" + } + + Write-Host "已重命名: $($entry.Old.Name) -> $($entry.New)" -ForegroundColor Green + $ok++ + } catch { + Write-Host "重命名失败: $($entry.Old.Name) —— $_" -ForegroundColor Red + $failed++ + } +} + +# --------------------------------------------------------------------------- +# 阶段三:重建 manifest +# --------------------------------------------------------------------------- + +$rebuilt = [ordered]@{} +$now = (Get-Date).ToString('o') + +foreach ($entry in $entries) { + $file = Find-ArchiveByBaseName -BaseName $entry.NewName -Directory $BackupDir -Formats $supportedFormats + + # 历史字段优先从新键取,其次从旧键(路径命名)取 + $previous = $null + if ($manifestOld.items.Contains($entry.NewName)) { $previous = $manifestOld.items[$entry.NewName] } + elseif ($manifestOld.items.Contains($entry.OldName)) { $previous = $manifestOld.items[$entry.OldName] } + + $getPrevious = { + param([string]$Field) + if ($previous -and ($previous.PSObject.Properties.Name -contains $Field)) { return $previous.$Field } + return $null + } + + $record = [ordered]@{ + baseName = $entry.NewName + source = $entry.Item.Path + resolvedSource = [Environment]::ExpandEnvironmentVariables($entry.Item.Path) + roots = @($entry.Resolved.Sources | ForEach-Object { $_.RootName }) + catalog = $(if ($entry.Resolved.CatalogEntry) { $entry.Resolved.CatalogEntry.Path } else { $null }) + archive = $(if ($file) { $file.Name } else { $entry.NewName + '.7z' }) + action = $(if ($file) { 'backed-up' } else { 'missing-source' }) + reason = '由 tools/Rename-Archives.ps1 迁移生成' + phase = 'migrate' + attemptedAt = $now + finishedAt = $now + durationSec = $null + exitCode = $null + verified = $false + warnings = $false + attemptWarnings = $false + encrypted = ($entry.Item.Flags -contains 'encrypt') + sourceFiles = (& $getPrevious 'sourceFiles') + sourceBytes = (& $getPrevious 'sourceBytes') + archiveBytes = $(if ($file) { $file.Length } else { $null }) + sha256 = (& $getPrevious 'sha256') + lastSuccessAt = $(if ($file) { (& $getPrevious 'lastSuccessAt') } else { (& $getPrevious 'lastSuccessAt') }) + successCount = $([int](& $getPrevious 'successCount')) + failCount = $([int](& $getPrevious 'failCount')) + } + $lastRestore = & $getPrevious 'lastRestoreAt' + if ($lastRestore) { $record['lastRestoreAt'] = $lastRestore } + + if ($file) { $rebuilt[$entry.NewName] = $record } +} + +$manifestNew = [pscustomobject]@{ + schemaVersion = 1 + tool = 'BakNRet' + updatedAt = $null + compressor = $manifestOld.compressor + items = $rebuilt +} +Write-BaknretManifest -Path $manifestPath -Manifest $manifestNew | Out-Null + +# --------------------------------------------------------------------------- +# 阶段四:报告 +# --------------------------------------------------------------------------- + +$referenced = @($rebuilt.Keys) +$orphans = @(Get-ChildItem -LiteralPath $BackupDir -File -Force -ErrorAction SilentlyContinue | + Where-Object { $_.Extension.ToLower() -in $supportedFormats -and $_.BaseName -notin $referenced }) + +Write-Host '' +Write-Host "重命名完成:成功 $ok,失败 $failed;manifest 已重建,含 $($rebuilt.Count) 个条目" -ForegroundColor Green + +if ($orphans.Count -gt 0) { + Write-Host '' + Write-Host '以下归档没有任何清单条目指向(恢复不到,注意别误删):' -ForegroundColor Yellow + foreach ($orphan in $orphans) { + Write-Host (" - {0}({1:N1} MB)" -f $orphan.Name, ($orphan.Length / 1MB)) -ForegroundColor Yellow + } +} + +if ($failed -gt 0) { exit 1 } +exit 0