fix(backup,restore): 显式指定的清单不存在时报失败,不再静默成功

传 -BackupListPath 指向一个不存在的文件时,原实现把它当成"首次运行"处理:备份端会在
那个位置凭空建一份模板,恢复端则"从备份内容生成清单",然后两者都 exit 0。

后果在计划任务里最明显:任务计划程序读到的是"上次运行结果 = 成功",而实际上一个条目
都没处理。这与本仓库已经修过的"27 条被静默跳过、退出码仍是 0"是同一类缺陷 —— 退出码
是自动化唯一能读到的信号,它必须对得上"到底干了什么"。

现在按 $PSBoundParameters.ContainsKey('BackupListPath') 把两条语义分开:

  * 显式指定了清单却不存在 -> 报 ERROR 并 exit 1。路径写错、或相对路径按了别的工作
    目录解析(计划任务的工作目录通常是 C:\Windows\System32),都是调用方的错误,
    不能伪装成"已经帮你建好模板了"。
  * 没指定(首次运行引导)-> 保持原行为:备份端建模板、恢复端从 manifest 生成清单,
    仍然 exit 0。这是"开箱即用",不是失败。

顺带补 2 处运算符前的空格(Backup.ps1 / Restore.ps1 的 PSUseConsistentWhitespace)。

测试:
  * 2 条成对回归用例(显式缺失报失败 / 首次运行仍建模板)。必须成对 —— 只测一条的话,
    "把所有缺失都改成 exit 1"这种错误实现也能骗过测试。
  * 7 条断言覆盖归档原子替换与路径解析基准。其中 3 条用 AST 判定,因为文本匹配会被
    函数自己的注释绊倒(注释里正当地提到了 $PSScriptRoot,解释为什么不许用)。

验收:test.ps1 9/9 全绿(5.1 与 7);Pester 183 -> 192,0 失败;静态分析 80 条、
0 Error(与改造前持平)。
This commit is contained in:
Shuery committed 2026-10-02 01:16:25 +08:00
1 parent d72fe63c02
commit 7149ea691e
5 files changed
+181 -2

No files matched your search

+15
View File
@@ -134,6 +134,21 @@ if (-not (Test-Path -LiteralPath $BackupDir)) {
} }
if (-not (Test-Path -LiteralPath $BackupListPath)) { if (-not (Test-Path -LiteralPath $BackupListPath)) {
# 清单缺失有两条语义完全不同的路:
#
# ① 首次运行(没传 -BackupListPath,用的是仓库自带的那份)-> 建一份模板,退出 0。
# 这是「开箱即用」的引导,**不是失败**。
# ② 显式传了 -BackupListPath 却不存在 -> 是调用方的错误(路径写错、计划任务里
# 的相对路径按了别的工作目录解析)。此时绝不能建模板就退出 0:计划任务会读到
# 「上次运行结果 = 成功」,而实际上一个条目都没处理 —— 这个仓库刚把
# 「27 条被静默跳过、退出码仍是 0」当作一类缺陷修过,这条是同一类。
if ($PSBoundParameters.ContainsKey('BackupListPath')) {
Write-BakNRetLog ("指定的清单不存在:{0}" -f $BackupListPath) -Level ERROR
Write-BakNRetLog '显式指定 -BackupListPath 时不会自动创建模板:请检查路径是否写错;要生成一份起步模板,就去掉该参数。' -Level ERROR
Stop-BakNRetLog
exit 1
}
$template = "# BackupList.txt`n" + $template = "# BackupList.txt`n" +
"# 语法: [+|-] <软件名 或 绝对路径> [:: <路径>] [:- <排除模式>[,<排除模式>...]] [:+ <追加项>[,<追加项>...]] [:encrypt | :!encrypt] [@ <Key>='<值>'] [# 说明]`n" + "# 语法: [+|-] <软件名 或 绝对路径> [:: <路径>] [:- <排除模式>[,<排除模式>...]] [:+ <追加项>[,<追加项>...]] [:encrypt | :!encrypt] [@ <Key>='<值>'] [# 说明]`n" +
"# 示例: Edge`n" + "# 示例: Edge`n" +
+1 -1
View File
@@ -39,7 +39,7 @@ $target = Join-Path $PSScriptRoot 'Backup-Data.ps1'
Write-Host '注意:Backup.ps1 已改名为 Backup-Data.ps1(这层转发只保留一轮)。' -ForegroundColor Yellow Write-Host '注意:Backup.ps1 已改名为 Backup-Data.ps1(这层转发只保留一轮)。' -ForegroundColor Yellow
$hostExe = (Get-Process -Id $PID).Path $hostExe = (Get-Process -Id $PID).Path
$forwardArguments = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $target) + @($Rest)+ @(if ($PSBoundParameters.ContainsKey('Verbose')) { '-Verbose' })+ @(if ($PSBoundParameters.ContainsKey('Debug')) { '-Debug' }) $forwardArguments = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $target) + @($Rest) + @(if ($PSBoundParameters.ContainsKey('Verbose')) { '-Verbose' }) + @(if ($PSBoundParameters.ContainsKey('Debug')) { '-Debug' })
$startInfo = New-Object System.Diagnostics.ProcessStartInfo $startInfo = New-Object System.Diagnostics.ProcessStartInfo
$startInfo.FileName = $hostExe $startInfo.FileName = $hostExe
+10
View File
@@ -458,6 +458,16 @@ if (-not (Test-Path -LiteralPath $BackupDir)) {
$manifest = Read-BakNRetManifest -Path $manifestPath $manifest = Read-BakNRetManifest -Path $manifestPath
if (-not (Test-Path -LiteralPath $BackupListPath)) { if (-not (Test-Path -LiteralPath $BackupListPath)) {
# 与 Backup-Data.ps1 同一条约定:显式指定了 -BackupListPath 却不存在,是调用方的错误。
# 恢复的副作用比备份更大(它会真的往磁盘写文件、回放安全描述符),更不能把「路径写错」
# 伪装成「已从备份内容生成清单,退出 0」——那会让调用方以为恢复成功了。
if ($PSBoundParameters.ContainsKey('BackupListPath')) {
Write-BakNRetLog ("指定的清单不存在:{0}" -f $BackupListPath) -Level ERROR
Write-BakNRetLog '显式指定 -BackupListPath 时不会自动生成:请检查路径是否写错;要从备份内容重建清单,就去掉该参数。' -Level ERROR
Stop-BakNRetLog
exit 1
}
Write-BakNRetLog '未找到配置文件,正在从备份内容生成...' -Level INFO Write-BakNRetLog '未找到配置文件,正在从备份内容生成...' -Level INFO
$paths = @() $paths = @()
+1 -1
View File
@@ -39,7 +39,7 @@ $target = Join-Path $PSScriptRoot 'Restore-Data.ps1'
Write-Host '注意:Restore.ps1 已改名为 Restore-Data.ps1(这层转发只保留一轮)。' -ForegroundColor Yellow Write-Host '注意:Restore.ps1 已改名为 Restore-Data.ps1(这层转发只保留一轮)。' -ForegroundColor Yellow
$hostExe = (Get-Process -Id $PID).Path $hostExe = (Get-Process -Id $PID).Path
$forwardArguments = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $target) + @($Rest)+ @(if ($PSBoundParameters.ContainsKey('Verbose')) { '-Verbose' })+ @(if ($PSBoundParameters.ContainsKey('Debug')) { '-Debug' }) $forwardArguments = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $target) + @($Rest) + @(if ($PSBoundParameters.ContainsKey('Verbose')) { '-Verbose' }) + @(if ($PSBoundParameters.ContainsKey('Debug')) { '-Debug' })
$startInfo = New-Object System.Diagnostics.ProcessStartInfo $startInfo = New-Object System.Diagnostics.ProcessStartInfo
$startInfo.FileName = $hostExe $startInfo.FileName = $hostExe
+154
View File
@@ -1472,6 +1472,160 @@ Describe '集成:Backup.ps1 / Restore.ps1 端到端(字面路径条目)' -
} }
$run.ExitCode | Should -Be 1 $run.ExitCode | Should -Be 1
} }
# ---- 回归:显式指定的清单不存在时,绝不能"建一份模板 + 退出 0" ----
#
# 缺陷现象:计划任务里 -BackupListPath 写错(或相对路径按了别的工作目录解析)时,
# 脚本会在那个错误位置凭空造一份模板、打印"已创建,请编辑后重试"、然后 **exit 0**。
# 任务计划程序读到的是"上次运行结果 = 成功",而实际上一个条目都没处理 ——
# 与本仓库已修过的"27 条被静默跳过、退出码仍是 0"是同一类缺陷。
It '[回归] 显式指定的清单不存在时报失败(退出码 1),且不在错误位置建模板' {
$ghostList = Join-Path $script:E2ERoot 'explicitly-missing-list.txt'
if (Test-Path -LiteralPath $ghostList) { Remove-Item -LiteralPath $ghostList -Force }
$run = Invoke-BakNRetScript -Script $script:BackupScript -Parameters @{
BackupListPath = $ghostList
BackupDir = $script:E2EBackupDir
QuietTool = $true
}
$run.ExitCode | Should -Be 1
$run.Output | Should -Match '指定的清单不存在'
Test-Path -LiteralPath $ghostList | Should -BeFalse
}
# 对照:没传 -BackupListPath 时的首次运行引导必须原样保留(那是开箱即用,不是失败)。
# 这两条必须成对存在 —— 只测一条的话,"把所有缺失都改成 exit 1" 也能骗过测试。
#
# 怎么造出"默认清单不存在":把仓库根那份 BackupList.txt 临时改名,跑完在 finally 里还原。
# 用 try/finally 而不是"跑完再删",是为了让断言失败时仓库也一定被还原。
It '[回归] 未显式指定清单时的首次运行仍建模板并退出 0(引导不能被误伤)' {
$realList = Join-Path $script:ProjectRoot 'BackupList.txt'
$parkedList = Join-Path $script:E2ERoot 'BackupList.parked'
$guideRoot = Join-Path $script:E2ERoot 'guide'
New-Item -ItemType Directory -Path $guideRoot -Force | Out-Null
$run = $null
try {
Move-Item -LiteralPath $realList -Destination $parkedList -Force
$run = Invoke-BakNRetScript -Script $script:BackupScript -Parameters @{
BackupDir = Join-Path $guideRoot 'Backups'
ConfigPath = Join-Path $script:ProjectRoot 'BackupConfig.psd1'
}
$run.ExitCode | Should -Be 0
$run.Output | Should -Match '模板 BackupList.txt 已创建'
Test-Path -LiteralPath $realList | Should -BeTrue
(Get-Content -LiteralPath $realList -Raw) | Should -Match '语法'
}
finally {
if (Test-Path -LiteralPath $parkedList) {
Move-Item -LiteralPath $parkedList -Destination $realList -Force
}
}
}
}
# ============================================================================
Describe '归档原子替换与路径解析基准(历史上真出过问题的两处语义)' {
# ============================================================================
# ---- Move-BakNRetArchiveIntoPlace ----
#
# 这条对应 CHANGELOG 里记过的缺陷:5.1 上三参数 File.Move 不存在,于是退化成
# "先删后移" —— Move 一失败,**旧归档已经没了**(而新归档还在 .tmp 里)。
# 断言必须能分辨"替换"与"先删后移":后者的危险恰恰在于"目标短暂不存在"。
Context 'Move-BakNRetArchiveIntoPlace' {
It '目标不存在时:把临时文件移到位,临时文件消失' {
$root = Join-Path $script:Sandbox 'atomic-new'
New-Item -ItemType Directory -Path $root -Force | Out-Null
$temp = Join-Path $root 'new.tmp'
$dest = Join-Path $root 'final.7z'
[System.IO.File]::WriteAllText($temp, 'NEW')
Move-BakNRetArchiveIntoPlace -TempPath $temp -DestinationPath $dest
(Get-Content -LiteralPath $dest -Raw) | Should -Be 'NEW'
Test-Path -LiteralPath $temp | Should -BeFalse
}
It '[回归] 目标已存在时:内容被替换为新内容,且旧内容在替换完成前一直还在' {
$root = Join-Path $script:Sandbox 'atomic-existing'
New-Item -ItemType Directory -Path $root -Force | Out-Null
$temp = Join-Path $root 'next.tmp'
$dest = Join-Path $root 'final.7z'
[System.IO.File]::WriteAllText($dest, 'OLD')
[System.IO.File]::WriteAllText($temp, 'NEW')
Move-BakNRetArchiveIntoPlace -TempPath $temp -DestinationPath $dest
(Get-Content -LiteralPath $dest -Raw) | Should -Be 'NEW'
Test-Path -LiteralPath $temp | Should -BeFalse
# "先删后移" 与 "原子替换" 在成功路径上的结果完全一样,靠结果分不出来。
# 能分辨的是**机制**:这条断言把"替换而非删除"钉住 —— 5.1 上必须走到
# File.Replace(ReplaceFile API),而不是先删掉目标再 Move。
$source = Get-Content -LiteralPath (Join-Path $script:ProjectRoot 'BakNRet\Public\Move-BakNRetArchiveIntoPlace.ps1') -Raw
$source | Should -Match '\[System\.IO\.File\]::Replace'
$source | Should -Not -Match 'Remove-Item'
}
It '[回归] 替换走 File.Replace 时第三个参数必须是 [NullString]::Value(传 $null 会报路径为空)' {
# PowerShell 会把 $null 转成空串,于是 File.Replace 报"路径为空"(两个版本实测都这样)。
$source = Get-Content -LiteralPath (Join-Path $script:ProjectRoot 'BakNRet\Public\Move-BakNRetArchiveIntoPlace.ps1') -Raw
$source | Should -Match '\[NullString\]::Value'
}
}
# ---- Resolve-BakNRetRootedPath ----
#
# 这条对应 README 专门写的一节:「相对路径按仓库根解析,不按当前工作目录」。
# 计划任务的工作目录通常是 C:\Windows\System32,在那里 Test-Path baknret.key 为假 ——
# 如果按工作目录解析,加密条目会以"拿不到口令"失败,而配置看上去毫无问题。
Context 'Resolve-BakNRetRootedPath' {
It '[回归] 相对路径按调用方传进来的 $Root 解析,而不是按当前工作目录' {
Push-Location $env:SystemRoot
try {
$resolved = Resolve-BakNRetRootedPath -Path 'Backups' -Root $script:ProjectRoot
$resolved | Should -Be (Join-Path $script:ProjectRoot 'Backups')
# 反证:如果它按工作目录解析,就会落到 System32 下面
$resolved | Should -Not -Match [regex]::Escape($env:SystemRoot)
}
finally {
Pop-Location
}
}
It '[回归] 模块里不得用 $PSScriptRoot 当根:那会解析到模块目录而不是仓库根' {
# 这是"入口逻辑下沉"里最容易出错的一类:闭包捕获的变量在模块作用域里指向别的东西。
# 函数注释专门解释了为什么 $Root 必须是参数 —— 这条断言把它钉住。
#
# 必须用 AST 判定,不能对源码做文本匹配:这个函数的注释里**正当地**提到了
# $PSScriptRoot(解释为什么不许用),文本匹配会把注释当成违规。
# 第一版就是这么写的,结果被自己的注释绊倒 —— 记在这里。
$ast = [System.Management.Automation.Language.Parser]::ParseFile(
(Join-Path $script:ProjectRoot 'BakNRet\Public\Resolve-BakNRetRootedPath.ps1'), [ref]$null, [ref]$null)
$nodes = @($ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.VariableExpressionAst] }, $true))
$referenced = @($nodes | ForEach-Object { $_.VariablePath.UserPath } | Sort-Object -Unique)
$referenced | Should -Not -Contain 'PSScriptRoot'
$referenced | Should -Contain 'Root' # 对照:确实引用了调用方传进来的根
}
It '绝对路径原样返回' {
$absolute = Join-Path $script:Sandbox 'absolute-dir'
Resolve-BakNRetRootedPath -Path $absolute -Root $script:ProjectRoot | Should -Be $absolute
}
It '$Path 为空时回落到 $Default' {
Resolve-BakNRetRootedPath -Path '' -Default 'logs' -Root $script:ProjectRoot |
Should -Be (Join-Path $script:ProjectRoot 'logs')
}
}
} }
# ============================================================================ # ============================================================================