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:
1 parent
d72fe63c02
commit
7149ea691e
5 files changed
+181
-2
No files matched your search
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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')
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
Reference in new issue
Block a user