引入软件名录:清单写软件名,归档名也用软件名

新功能
- 新增 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 才能验,已实跑确认。
This commit is contained in:
Shuery committed 2026-09-21 20:55:19 +08:00
1 parent dbc0c00554
commit 045d51ac9c
9 files changed
+1330 -143

No files matched your search

+105 -22
View File
@@ -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
+54 -44
View File
@@ -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
+439 -8
View File
@@ -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 的 <name>_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'
)
+114 -50
View File
@@ -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`(同名目录分散在多处)当前打包第一个位置,恢复时逐个位置各解压一份。
+85 -18
View File
@@ -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
+86
View File
@@ -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'
}
+88 -1
View File
@@ -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'
+99
View File
@@ -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
# ============================================================================
+260
View File
@@ -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