fix: manifest 先删后移会丢账本;5.1 拿不到原子替换;空目录让空间守卫静默失效

四件事都在"原子替换与空间守卫"这条线上:

1) Write-BaknretManifest 自己写了"写 .tmp → 删旧 → Move-Item"。Move-Item 一失败,
   旧 manifest 就已经没了 —— 而 manifest 是"这块归档是谁的"的唯一账本。改成复用
   Write-BaknretAtomicText。

2) Write-BaknretAtomicText 原本也是走 Move-BaknretArchiveIntoPlace,而后者在 5.1 上
   必然退化成"先删后移"(三参数 File.Move 是 .NET Core 3.0+ 才有的重载)。现在目标存在时
   改用 File.Replace(ReplaceFile API):要么换成新内容、要么保持旧内容,两个都不会消失。
   实测目标只读时替换失败、旧内容完好、.tmp 保留便于排查。

3) Move-BaknretArchiveIntoPlace 的 5.1 降级路径同样改成 File.Replace —— 之前那条
   "先删后移"会在中途失败时让归档消失(旧归档没了、新归档还在 .tmp 里)。
   注意第三个参数必须传 [NullString]::Value:PowerShell 会把 $null 转成空串,Replace 于是
   报"路径为空"(两个版本实测都这样,我第一版就踩了)。

4) Get-FolderSummary 对空目录返回的 TotalSize 是 $null 而不是 0(Measure-Object 空
   输入的行为,两版一致)。$null / 1GB 得 0,而备份前的空间守卫判的是 -gt 0 —— 空间不足时
   不再拦截,静默失效。现在补成 0。

回归断言(零依赖与 Pester 各一份):空目录的摘要必须是整数 0;原子写成功时内容到位
且不留 .tmp、失败时旧内容完好(用只读目标强制失败)。

验收:test.ps1 9/9 全绿 —— 5.1 那一遍的通过同时证明了 File.Replace 这条新路径真的
在 5.1 上成立;tests\Run-RealSmoke.ps1 4/4 全绿。
This commit is contained in:
Shuery committed 2026-09-26 22:47:14 +08:00
1 parent e17cdcda79
commit 79f83f6760
3 files changed
+119 -14

No files matched your search

+40
View File
@@ -1139,6 +1139,46 @@ Describe '外部命令退出码(旧实现的核心缺陷)' {
Remove-Item -LiteralPath $sandbox -Recurse -Force -ErrorAction SilentlyContinue
}
It '空目录的摘要给 0 而不是 $null(否则空间守卫会静默失效)' {
# Measure-Object 对空输入返回 $null 而不是 0,于是 .Sum 也是 $null;而 $null / 1GB
# 得 0,空间守卫判的是 -gt 0 —— 这一档就不再拦截了。
$empty = Join-Path $env:TEMP ("bnr-empty-" + [guid]::NewGuid().ToString('N').Substring(0, 8))
New-Item -ItemType Directory -Path $empty -Force | Out-Null
try {
$summary = Get-FolderSummary -FolderPath $empty
$summary.FileCount | Should -Be 0
$summary.TotalSize | Should -Not -BeNullOrEmpty
$summary.TotalSize | Should -Be 0
} finally {
Remove-Item -LiteralPath $empty -Recurse -Force -ErrorAction SilentlyContinue
}
}
It '原子替换:成功时新内容到位且不留 .tmp;失败时旧内容完好' {
$base = Join-Path $env:TEMP ("bnr-atomic-" + [guid]::NewGuid().ToString('N').Substring(0, 8))
New-Item -ItemType Directory -Path $base -Force | Out-Null
$target = Join-Path $base 'text.json'
try {
Write-BaknretAtomicText -Path $target -Text 'FIRST' | Out-Null
(Get-Content -Encoding UTF8 -LiteralPath $target -Raw) | Should -Be 'FIRST'
# 目标已存在时走 File.Replace
Write-BaknretAtomicText -Path $target -Text 'SECOND' | Out-Null
(Get-Content -Encoding UTF8 -LiteralPath $target -Raw) | Should -Be 'SECOND'
"$target.tmp" | Should -Not -Exist
# 目标只读 -> 替换必然失败 -> 旧内容必须还在。这正是 File.Replace 与"先删后移"
# 的区别:后者失败时旧文件已经没了,而 manifest 丢了只能靠文件名反推。
(Get-Item -LiteralPath $target).IsReadOnly = $true
{ Write-BaknretAtomicText -Path $target -Text 'SHOULD-NOT-LAND' } | Should -Throw
(Get-Content -Encoding UTF8 -LiteralPath $target -Raw) | Should -Be 'SECOND'
} finally {
$file = Get-Item -LiteralPath $target -ErrorAction SilentlyContinue
if ($file) { $file.IsReadOnly = $false }
Remove-Item -LiteralPath $base -Recurse -Force -ErrorAction SilentlyContinue
}
}
It 'Invoke-ExternalCommand 能拿到真实退出码' {
(Invoke-ExternalCommand -FilePath 'cmd.exe' -ArgumentList @('/c', 'exit 7')) | Should -Be 7
}