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:
1 parent
e17cdcda79
commit
79f83f6760
3 files changed
+118
-13
No files matched your search
+31
-13
@@ -2022,9 +2022,15 @@ function Get-FolderSummary {
|
||||
$items = @(Get-ChildItem -LiteralPath $FolderPath -Recurse -Force -ErrorAction SilentlyContinue)
|
||||
$files = @($items | Where-Object { -not $_.PSIsContainer })
|
||||
|
||||
# 空目录时 Measure-Object 的 .Sum 是 $null 而不是 0(7.x 与 5.1 实测都一样)。
|
||||
# 这个 $null 会一路传到备份前的空间守卫:$null / 1GB 得 0,而守卫判的是 -gt 0,
|
||||
# 于是"空间不够"时不再拦截 —— 静默失效。所以在这里就把 0 补上。
|
||||
$totalSize = ($files | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
|
||||
if ($null -eq $totalSize) { $totalSize = 0 }
|
||||
|
||||
return [pscustomobject]@{
|
||||
FileCount = $files.Count
|
||||
TotalSize = ($files | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue).Sum
|
||||
TotalSize = [long]$totalSize
|
||||
LatestModifiedTime = ($items | Measure-Object -Property LastWriteTime -Maximum -ErrorAction SilentlyContinue).Maximum
|
||||
}
|
||||
} catch {
|
||||
@@ -2146,13 +2152,9 @@ function Write-BaknretManifest {
|
||||
New-Item -ItemType Directory -Path $directory -Force | Out-Null
|
||||
}
|
||||
|
||||
$temp = "$Path.tmp"
|
||||
[System.IO.File]::WriteAllText($temp, $json, $script:LogEncoding)
|
||||
|
||||
if (Test-Path -LiteralPath $Path) {
|
||||
Remove-Item -LiteralPath $Path -Force
|
||||
}
|
||||
Move-Item -LiteralPath $temp -Destination $Path -Force
|
||||
# 复用原子写,而不是自己"删旧再改名":后者一旦在中途失败,旧 manifest 已经没了 ——
|
||||
# 而 manifest 是"这块归档是谁的"的唯一账本,丢了它只能靠文件名反推。
|
||||
Write-BaknretAtomicText -Path $Path -Text $json
|
||||
return $Path
|
||||
}
|
||||
|
||||
@@ -2174,17 +2176,25 @@ function Move-BaknretArchiveIntoPlace {
|
||||
[Parameter(Mandatory = $true)][string]$DestinationPath
|
||||
)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $DestinationPath)) {
|
||||
Move-Item -LiteralPath $TempPath -Destination $DestinationPath -Force
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
# 7.x:单次原子替换(MoveFileEx + REPLACE_EXISTING)
|
||||
[System.IO.File]::Move($TempPath, $DestinationPath, $true)
|
||||
return
|
||||
} catch {
|
||||
Write-Log "原子替换失败,退化为先删后移:$_" -Level DEBUG
|
||||
Write-Log "File.Move(overwrite) 不可用(5.1 没有这个重载),改用 File.Replace:$_" -Level DEBUG
|
||||
}
|
||||
|
||||
if (Test-Path -LiteralPath $DestinationPath) {
|
||||
Remove-Item -LiteralPath $DestinationPath -Force
|
||||
}
|
||||
Move-Item -LiteralPath $TempPath -Destination $DestinationPath -Force
|
||||
# 5.1 走的这条。以前是"先删后移"—— 中途失败会让目标文件消失(旧归档没了、新归档还在
|
||||
# .tmp 里)。File.Replace 走 ReplaceFile API,在 .NET Framework 上同样可用:要么换成
|
||||
# 新内容、要么保持旧内容,两个都不会消失。
|
||||
# 第三个参数必须传 [NullString]::Value —— PowerShell 会把 $null 转成空串,于是 Replace
|
||||
# 报"路径为空"(两个版本实测都这样)。
|
||||
[System.IO.File]::Replace($TempPath, $DestinationPath, [NullString]::Value)
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
@@ -2774,10 +2784,18 @@ function Write-BaknretAtomicText {
|
||||
|
||||
$temp = "$Path.tmp"
|
||||
[System.IO.File]::WriteAllText($temp, $Text, $script:LogEncoding)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $Path)) {
|
||||
Move-BaknretArchiveIntoPlace -TempPath $temp -DestinationPath $Path
|
||||
return $Path
|
||||
}
|
||||
|
||||
# 目标已存在:用 File.Replace。失败时旧内容完好、.tmp 留着便于排查(两版实测一致)——
|
||||
# 这正是"宁可这次没换成,也不能让目标消失"。
|
||||
[System.IO.File]::Replace($temp, $Path, [NullString]::Value)
|
||||
return $Path
|
||||
}
|
||||
|
||||
function Save-BaknretSecuritySidecar {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1221,6 +1221,53 @@ Test-Case 'Invoke-ExternalCommand 能拿到真实退出码(旧实现用 Start-
|
||||
Assert-Equal 7 $code
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
Write-Host "`n== 原子写与空间守卫的输入 ==" -ForegroundColor Cyan
|
||||
# ============================================================================
|
||||
|
||||
Test-Case '空目录的摘要给 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
|
||||
Assert-Equal 0 $summary.FileCount
|
||||
Assert-True ($null -ne $summary.TotalSize) 'TotalSize 不能是 $null'
|
||||
Assert-True ($summary.TotalSize -is [long]) 'TotalSize 应当是整数'
|
||||
Assert-Equal 0 $summary.TotalSize
|
||||
} finally {
|
||||
Remove-Item -LiteralPath $empty -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
Test-Case '原子替换:成功时新内容到位且不留 .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
|
||||
Assert-Equal 'FIRST' (Get-Content -Encoding UTF8 -LiteralPath $target -Raw)
|
||||
|
||||
# 目标已存在时走 File.Replace
|
||||
Write-BaknretAtomicText -Path $target -Text 'SECOND' | Out-Null
|
||||
Assert-Equal 'SECOND' (Get-Content -Encoding UTF8 -LiteralPath $target -Raw)
|
||||
Assert-FileMissing "$target.tmp"
|
||||
|
||||
# 目标只读 -> 替换必然失败 -> 旧内容必须还在。这正是 File.Replace 与"先删后移"的区别:
|
||||
# 后者失败时旧文件已经没了,而 manifest 丢了就只能靠文件名反推。
|
||||
(Get-Item -LiteralPath $target).IsReadOnly = $true
|
||||
$threw = $false
|
||||
try { Write-BaknretAtomicText -Path $target -Text 'SHOULD-NOT-LAND' | Out-Null } catch { $threw = $true }
|
||||
Assert-True $threw '目标只读时替换应当抛错'
|
||||
Assert-Equal 'SECOND' (Get-Content -Encoding UTF8 -LiteralPath $target -Raw)
|
||||
} finally {
|
||||
$file = Get-Item -LiteralPath $target -ErrorAction SilentlyContinue
|
||||
if ($file) { $file.IsReadOnly = $false }
|
||||
Remove-Item -LiteralPath $base -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
Remove-Item -LiteralPath $sandbox -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item -LiteralPath $itemSandbox -Recurse -Force -ErrorAction SilentlyContinue
|
||||
|
||||
Reference in new issue
Block a user