From 8736bb2c6784b3a9e318754266b9aa475c6bcb89 Mon Sep 17 00:00:00 2001 From: Shuery <2463253700@qq.com> Date: Sat, 26 Sep 2026 22:26:45 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=A1=8C=E9=A6=96=E6=96=B9=E5=90=91?= =?UTF-8?q?=E6=A0=87=E8=AE=B0=E8=B4=B4=E5=9C=A8=E7=9B=AE=E6=A0=87=E4=B8=8A?= =?UTF-8?q?=E6=97=B6=E5=A4=B1=E6=95=88=EF=BC=88=E7=9C=9F=E5=AE=9E=E6=B8=85?= =?UTF-8?q?=E5=8D=95=2029=20=E6=9D=A1=E9=87=8C=2024=20=E6=9D=A1=E8=A2=AB?= =?UTF-8?q?=E9=9D=99=E9=BB=98=E8=B7=B3=E8=BF=87=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 量到的事实:用真实清单只读干跑,28 个条目里 27 个被当成"源不存在"跳过、退出码 0, 只有不写方向标记的 Scoop 真的被备份 —— 磁盘上最新那份归档正是 Scoop.7z(4.6 GB / 09-24)。 修复后同一批条目:29 条全部分解出方向(26 仅备份 / 2 仅恢复 / 1 双向),零个标记残留。 原因:解析器只认"独立记号"形态的方向标记(+ Name),而清单里 24 条贴在目标上 (+WindowsTerminal)。后者被当成一个名叫 +WindowsTerminal 的软件名,名录里查不到就退回 当目录名,目录又不存在 → 静默记成 missing-source。它隐形的理由是两件本身正确的设计叠在 一起:"源不存在只算跳过不算失败" 加上两种写法只差一个空格。 README 的方向标记表格写的是"行首 + / -",并没有要求标记后面跟空格;要求带空白的是 修饰符(:: / :- / :+ / @)那一节。所以让解析器接受行首贴在一起的形态,而不是去改清单。 只放宽"行首"这一个位置:记号中间的 + / - 仍是普通字符(C:\a:-b 那条断言继续盯着)。 新增 tests\Run-RealSmoke.ps1:真实清单 + 真实归档上的只读冒烟。它存在的理由就是这个 缺陷 —— 夹具测试全绿,只有拿真实清单跑才看得见。它检查四件事:方向标记全部被剥掉、没有 软件名退化成"名录里没有"、两个只读模式退出 0、manifest.json 的 SHA256 前后不变。 验收:test.ps1 9/9 全绿(7 与 5.1);tests\Run-RealSmoke.ps1 4/4 全绿。 --- Common.psm1 | 20 ++++- tests/BakNRet.Tests.ps1 | 22 ++++++ tests/Run-RealSmoke.ps1 | 163 ++++++++++++++++++++++++++++++++++++++++ tests/Run-Tests.ps1 | 33 ++++++++ 4 files changed, 236 insertions(+), 2 deletions(-) create mode 100644 tests/Run-RealSmoke.ps1 diff --git a/Common.psm1 b/Common.psm1 index 85dfd34..453174c 100644 --- a/Common.psm1 +++ b/Common.psm1 @@ -494,7 +494,9 @@ function ConvertFrom-BackupListLine { 与旧实现的区别: * `::` 现在表示"覆盖 Path"(旧版是 `:-` 的历史别名),排除一律写 `:-`; * 新增行首 `+` / `-` 方向、`:encrypt` / `:!encrypt`、`@ Key='Value'` 覆盖; - * 修饰符必须是独立记号(前后加空格),所以 `C:\a:-b` 仍然是路径。 + * 修饰符必须是独立记号(前后加空格),所以 `C:\a:-b` 仍然是路径; + * 行首方向标记是唯一例外:`+` / `-` 贴在目标上(`+Edge`)或独立成记号 + (`+ Edge`)都认。详见下面判定处的注释。 #> param([Parameter(ValueFromPipeline = $true)][AllowEmptyString()][string]$Line) @@ -539,7 +541,15 @@ function ConvertFrom-BackupListLine { } } - # 行首方向标记:`+` 仅备份、`-` 仅恢复 + # 行首方向标记:`+` 仅备份、`-` 仅恢复。 + # + # 两种写法都认:独立成记号(`+ Edge`)与贴在目标上(`+Edge`)。后者是本仓库清单 + # 里的主流写法,而过去只认前者 —— 于是 `+WindowsTerminal` 被当成一个名叫 + # `+WindowsTerminal` 的软件名,名录里查不到就退回当目录名,目录又不存在, + # 整条静默记成 missing-source 跳过;备份按"跳过不算失败"退出 0,所以一直没暴露。 + # + # 只放宽"行首"这一个位置:修饰符(:: / :- / :+ / @)仍然必须是独立记号, + # 否则 `C:\a:-b` 这类路径会被切坏 —— 那是另一条已经钉住的行为。 $direction = 'both' if ($tokens[0] -eq '+') { $direction = 'backup' @@ -547,6 +557,12 @@ function ConvertFrom-BackupListLine { } elseif ($tokens[0] -eq '-') { $direction = 'restore' $tokens = @($tokens | Select-Object -Skip 1) + } elseif ($tokens[0].Length -gt 1 -and $tokens[0][0] -eq '+') { + $direction = 'backup' + $tokens[0] = $tokens[0].Substring(1) + } elseif ($tokens[0].Length -gt 1 -and $tokens[0][0] -eq '-') { + $direction = 'restore' + $tokens[0] = $tokens[0].Substring(1) } if ($tokens.Count -eq 0) { return $null } diff --git a/tests/BakNRet.Tests.ps1 b/tests/BakNRet.Tests.ps1 index 81d8e11..e88c41e 100644 --- a/tests/BakNRet.Tests.ps1 +++ b/tests/BakNRet.Tests.ps1 @@ -192,6 +192,28 @@ Describe 'BackupList.txt 解析' { ConvertFrom-BackupListLine -Line '-' | Should -BeNullOrEmpty } + It '行首标记贴在目标上也算(+Name / -Path),且标记不进入目标' { + # 本仓库清单里的主流写法,也是曾经被静默忽略的那种:标记与目标之间没有空格。 + # 过去只认独立记号,于是 `+WindowsTerminal` 被当成"名叫 +WindowsTerminal 的软件名", + # 名录查不到、目录不存在,整条静默记成 missing-source 跳过,而备份仍然退出 0。 + $attached = ConvertFrom-BackupListLine -Line '+FooClolor' + $attached.Direction | Should -Be 'backup' + $attached.Path | Should -Be 'FooClolor' + $attached.IsName | Should -BeTrue + + $restoreAttached = ConvertFrom-BackupListLine -Line '-C:\Programs\Foo' + $restoreAttached.Direction | Should -Be 'restore' + $restoreAttached.Path | Should -Be 'C:\Programs\Foo' + $restoreAttached.IsName | Should -BeFalse + } + + It '非行首的 + / - 不是方向标记' { + # 只放宽"行首"这一个位置:记号中间仍然只是普通字符。 + (ConvertFrom-BackupListLine -Line 'C:\a+b').Path | Should -Be 'C:\a+b' + (ConvertFrom-BackupListLine -Line 'C:\a+b').Direction | Should -Be 'both' + (ConvertFrom-BackupListLine -Line 'foo-bar').Path | Should -Be 'foo-bar' + } + # ---- 新契约的核心::: 表示覆盖 Path,不再是 :- 的别名 ---- It ':: 现在表示"覆盖 Path",与 :- 彻底分开' { $result = ConvertFrom-BackupListLine -Line 'Foo :: D:\bar' diff --git a/tests/Run-RealSmoke.ps1 b/tests/Run-RealSmoke.ps1 new file mode 100644 index 0000000..770a5e1 --- /dev/null +++ b/tests/Run-RealSmoke.ps1 @@ -0,0 +1,163 @@ +<# +.SYNOPSIS + 只读冒烟:在**真实清单与真实归档**上验证解析,以及"一个字节都不写"的承诺。 + +.DESCRIPTION + 与其它套件的分工:Pester、零依赖、E2E 都在临时目录里自造夹具,跑得快、可重复; + 这个冒烟专门跑真实清单,用来挡住"夹具全绿、真实数据全废"这一类问题。 + + 它存在的理由是一件真事:清单里有 24 条把方向标记贴在目标上(`+WindowsTerminal`), + 而解析器当时只认独立记号(`+ WindowsTerminal`)。于是那些条目被当成"名叫 + +WindowsTerminal 的软件名",名录里查不到就退回当目录名,目录又不存在,整条静默 + 记成 missing-source 跳过 —— 备份照常退出 0,所有夹具测试也都是绿的。 + 只有拿真实清单跑一遍才看得见。 + + 检查三件事: + 1) 真实清单里没有"标记没被剥掉"的条目; + 2) 真实清单里没有软件名在名录里查不到(那正是上面那种静默退化); + 3) 两个只读模式跑完,manifest.json 的 SHA256 一个字都没变。 + + 全程只读:Backup 只用 -DryRun,Restore 只用 -VerifyOnly。 + 真实清单或归档不存在时**明确跳过**(退出码 0 并打印原因),因为机器状态不在被测范围内。 + +.EXAMPLE + .\tests\Run-RealSmoke.ps1 +#> +[CmdletBinding()] +param( + [string]$ModulePath, + + # 被抽查的条目:挑体积小、静态、不会因为程序运行而变化的 + [string[]]$SampleEntries = @('WindowsTerminal', 'OpenSSH', 'PowerShell'), + + # Restore 端用来验证"行首 + 会被识别成仅备份"的条目 + [string]$RestoreSample = 'WindowsPowerShell' +) + +$ErrorActionPreference = 'Stop' + +$projectRoot = Split-Path -Parent $PSScriptRoot +if (-not $ModulePath) { $ModulePath = Join-Path $projectRoot 'Common.psm1' } +Import-Module $ModulePath -Force +Import-Module (Join-Path $PSScriptRoot 'TestHelpers.psm1') -Force + +$listPath = Join-Path $projectRoot 'BackupList.txt' +$configPath = Join-Path $projectRoot 'BackupConfig.psd1' +$manifestPath = Join-Path $projectRoot 'Backups\manifest.json' +$backupScript = Join-Path $projectRoot 'Backup.ps1' +$restoreScript = Join-Path $projectRoot 'Restore.ps1' + +Reset-TestResult + +if (-not (Test-Path -LiteralPath $listPath)) { + Write-Host "跳过:找不到真实清单 $listPath" -ForegroundColor Yellow + exit 0 +} +if (-not (Test-Path -LiteralPath $manifestPath)) { + Write-Host "跳过:找不到真实 manifest $manifestPath(这台机器还没跑过备份)" -ForegroundColor Yellow + exit 0 +} + +$config = Get-BaknretConfig -Path $configPath +$catalogPath = Resolve-CatalogPath -Configured $config.SoftwareCatalog -Root $projectRoot +$listLines = @(Get-Content -Encoding UTF8 -LiteralPath $listPath) + +# 用 cmd 批次文件把子进程输出落盘再读回。为什么不用 `& pwsh ... 2>&1` 直接捕获: +# 那会给子进程建管道,在受限环境 / 沙箱里会被直接拒绝(见 tests\BakNRet.Tests.ps1 里的同款注释)。 +function Invoke-BaknretCaptured { + param([string]$Script, [string[]]$Arguments) + + $hostExe = if ($PSVersionTable.PSEdition -eq 'Core') { 'pwsh' } else { 'powershell' } + $stamp = [guid]::NewGuid().ToString('N') + $outFile = Join-Path ([System.IO.Path]::GetTempPath()) "bnr-smoke-$stamp.txt" + $cmdFile = Join-Path ([System.IO.Path]::GetTempPath()) "bnr-smoke-$stamp.cmd" + + $quoted = (@($Script) + $Arguments | ForEach-Object { '"{0}"' -f $_ }) -join ' ' + $batch = "@echo off`r`n$hostExe $quoted > `"$outFile`" 2>&1`r`nexit /b %ERRORLEVEL%`r`n" + # .cmd 必须无 BOM:cmd.exe 会被 BOM 弄坏 + [System.IO.File]::WriteAllText($cmdFile, $batch, [System.Text.UTF8Encoding]::new($false)) + + try { + # 原生命令 + Stop 在 5.1 上会把 stderr 升级成终止错误,这里只要退出码 + $previous = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + try { + & cmd.exe /c $cmdFile | Out-Null + $code = $LASTEXITCODE + } finally { + $ErrorActionPreference = $previous + } + $text = if (Test-Path -LiteralPath $outFile) { + Get-Content -Encoding UTF8 -LiteralPath $outFile -Raw + } else { '' } + return [pscustomobject]@{ ExitCode = $code; Output = $text } + } finally { + Remove-Item -LiteralPath $outFile, $cmdFile -Force -ErrorAction SilentlyContinue + } +} + +function Get-ManifestHash { + if (Test-Path -LiteralPath $manifestPath) { + return (Get-FileHash -LiteralPath $manifestPath -Algorithm SHA256).Hash + } + return '<不存在>' +} + +Write-Host "`n== 真实清单解析 ==" -ForegroundColor Cyan + +Test-Case '真实清单里没有"方向标记没被剥掉"的条目' { + $leftOver = @() + foreach ($line in $listLines) { + $item = ConvertFrom-BackupListLine -Line $line + if ($item -and $item.Path -match '^[+-]') { $leftOver += $item.Path } + } + Assert-Equal 0 $leftOver.Count ("这些条目的方向标记没被剥掉:" + ($leftOver -join '、')) +} + +Test-Case '真实清单里每个条目都能解析出方向,且软件名都能在名录里查到' { + $entries = 0 + $backupOnly = 0 + $restoreOnly = 0 + $catalogMiss = @() + + foreach ($line in $listLines) { + $item = ConvertFrom-BackupListLine -Line $line + if (-not $item) { continue } + $entries++ + if ($item.Direction -eq 'backup') { $backupOnly++ } + if ($item.Direction -eq 'restore') { $restoreOnly++ } + + $resolved = Resolve-BackupEntry -Entry $item -CatalogPath $catalogPath -MaxDepth $config.CatalogMaxDepth + if ($resolved.Error -and $resolved.Error -like '*名录里没有*') { $catalogMiss += $resolved.Error } + } + + Assert-True ($entries -gt 0) '真实清单一条都没解析出来,检查范围不对' + Assert-Equal 0 $catalogMiss.Count ("这些条目静默退化成了目录名:" + ($catalogMiss -join ';')) + Write-Host (" 条目 {0} 个:仅备份 {1}、仅恢复 {2}、双向 {3}" -f $entries, $backupOnly, $restoreOnly, ($entries - $backupOnly - $restoreOnly)) -ForegroundColor DarkGray +} + +Write-Host "`n== 只读模式不写一个字节 ==" -ForegroundColor Cyan + +Test-Case 'Backup.ps1 -DryRun 退出 0、不再有"名录里没有"告警、manifest 不变' { + $before = Get-ManifestHash + $arguments = @('-BackupListPath', $listPath, '-DryRun', '-QuietTool', '-Only') + $SampleEntries + $run = Invoke-BaknretCaptured -Script $backupScript -Arguments $arguments + + Assert-Equal 0 $run.ExitCode ("DryRun 退出码不是 0;输出尾部:" + ($run.Output -split "`n" | Select-Object -Last 4 | Out-String)) + Assert-False ($run.Output -match "名录里没有 '") "DryRun 仍然把某些条目当成查不到的软件名" + Assert-Equal $before (Get-ManifestHash) 'DryRun 改动了 manifest.json' +} + +Test-Case 'Restore.ps1 -VerifyOnly 退出 0、把行首 + 识别成"仅备份"、manifest 不变' { + $before = Get-ManifestHash + $run = Invoke-BaknretCaptured -Script $restoreScript -Arguments @( + '-BackupListPath', $listPath, '-VerifyOnly', '-Only', $RestoreSample, '-Verbose') + + Assert-Equal 0 $run.ExitCode ("VerifyOnly 退出码不是 0;输出尾部:" + ($run.Output -split "`n" | Select-Object -Last 4 | Out-String)) + Assert-False ($run.Output -match "名录里没有 '") 'VerifyOnly 仍然把某些条目当成查不到的软件名' + # 这句跳过原因是 -Level DEBUG 的,要 -Verbose 才会打印 —— 顺带也验了那条路径 + Assert-True ($run.Output -match '跳过(行首 \+,仅备份)') 'VerifyOnly 没有把行首 + 识别成"仅备份"' + Assert-Equal $before (Get-ManifestHash) 'VerifyOnly 改动了 manifest.json' +} + +exit (Write-TestSummary -Title '真实清单只读冒烟') diff --git a/tests/Run-Tests.ps1 b/tests/Run-Tests.ps1 index 224508e..f0e3d4a 100644 --- a/tests/Run-Tests.ps1 +++ b/tests/Run-Tests.ps1 @@ -85,6 +85,39 @@ Test-Case '行首 - 表示仅恢复' { Assert-Equal 'C:\Programs\FooClolor' $r.Path } +Test-Case '行首标记贴在目标上也算(+Name / -Path),且标记不进入目标' { + # 这是本仓库清单里的主流写法,也是曾经被静默忽略的那种:标记与目标之间没有空格。 + # 过去只认独立记号,于是 `+WindowsTerminal` 被当成"名叫 +WindowsTerminal 的软件名", + # 名录查不到、目录不存在,整条静默记成 missing-source 跳过,而备份仍然退出 0。 + $attached = ConvertFrom-BackupListLine -Line '+FooClolor' + Assert-Equal 'backup' $attached.Direction + Assert-Equal 'FooClolor' $attached.Path + Assert-True $attached.IsName + + $restoreAttached = ConvertFrom-BackupListLine -Line '-C:\Programs\Foo' + Assert-Equal 'restore' $restoreAttached.Direction + Assert-Equal 'C:\Programs\Foo' $restoreAttached.Path + Assert-False $restoreAttached.IsName + + # 贴在一起的写法照样能带修饰符 + $withOverride = ConvertFrom-BackupListLine -Line '+FooClolor @ Encrypt=''$false''' + Assert-Equal 'backup' $withOverride.Direction + Assert-Equal 'FooClolor' $withOverride.Path + Assert-False $withOverride.Overrides['Encrypt'] +} + +Test-Case '非行首的 + / - 不是方向标记' { + # 只放宽"行首"这一个位置:记号中间的 + / - 仍然只是普通字符, + # 否则 C:\a:-b 这类路径会被切坏(这条一直有断言盯着)。 + $plusInside = ConvertFrom-BackupListLine -Line 'C:\a+b' + Assert-Equal 'both' $plusInside.Direction + Assert-Equal 'C:\a+b' $plusInside.Path + + $dashInside = ConvertFrom-BackupListLine -Line 'foo-bar' + Assert-Equal 'both' $dashInside.Direction + Assert-Equal 'foo-bar' $dashInside.Path +} + Test-Case '没有方向标记时是 both(同时备份与恢复)' { Assert-Equal 'both' (ConvertFrom-BackupListLine -Line 'C:\x').Direction }