diff --git a/Common.psm1 b/Common.psm1 index 332cc97..ad18a3e 100644 --- a/Common.psm1 +++ b/Common.psm1 @@ -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,7 +2784,15 @@ function Write-BaknretAtomicText { $temp = "$Path.tmp" [System.IO.File]::WriteAllText($temp, $Text, $script:LogEncoding) - Move-BaknretArchiveIntoPlace -TempPath $temp -DestinationPath $Path + + 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 } diff --git a/tests/BakNRet.Tests.ps1 b/tests/BakNRet.Tests.ps1 index 59f5b53..47e650f 100644 --- a/tests/BakNRet.Tests.ps1 +++ b/tests/BakNRet.Tests.ps1 @@ -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 } diff --git a/tests/Run-Tests.ps1 b/tests/Run-Tests.ps1 index e2e8ba3..2120272 100644 --- a/tests/Run-Tests.ps1 +++ b/tests/Run-Tests.ps1 @@ -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