diff --git a/Common.psm1 b/Common.psm1 index 453174c..2a4e5eb 100644 --- a/Common.psm1 +++ b/Common.psm1 @@ -1541,33 +1541,49 @@ function New-BaknretArchiveStaging { New-Item -ItemType Directory -Path $Root -Force | Out-Null } - foreach ($item in $Items) { - if ([string]::IsNullOrWhiteSpace([string]$item.ArchivePath)) { - throw "归档项缺少归档内路径:$($item.RealPath)" - } - - $linkPath = Join-Path $Root $item.ArchivePath - $parent = Split-Path -Path $linkPath -Parent - if ($parent -and -not (Test-Path -LiteralPath $parent)) { - New-Item -ItemType Directory -Path $parent -Force | Out-Null - } - if (Test-Path -LiteralPath $linkPath) { Remove-BaknretJunction -Path $linkPath } - - if ($item.IsFile) { - try { - New-Item -ItemType HardLink -Path $linkPath -Target $item.RealPath -ErrorAction Stop | Out-Null - } catch { - Write-Log ("暂存:硬链接不可用({0}),改为复制文件 {1}" -f $_.Exception.Message, $item.RealPath) -Level DEBUG - Copy-Item -LiteralPath $item.RealPath -Destination $linkPath -Force -ErrorAction Stop + # 半途失败必须在这里自己清干净,不能把责任留给调用方。 + # + # 原因:调用方拿到的是**返回值**,而抛错时根本没有返回值 —— Backup.ps1 的 finally 里 + # `$stagingRoot` 还是 $null,而 Remove-BaknretArchiveStaging 对 $null 是直接 return。 + # 结果是已经建好的 junction 与临时目录永久留在 %TEMP%,而那些 junction 指向的是真实 + # 数据;临时目录迟早会被某次 Remove-Item -Recurse 扫到,那一下就会走进真实数据。 + try { + foreach ($item in $Items) { + if ([string]::IsNullOrWhiteSpace([string]$item.ArchivePath)) { + throw "归档项缺少归档内路径:$($item.RealPath)" } - } else { - New-Item -ItemType Junction -Path $linkPath -Target $item.RealPath -ErrorAction Stop | Out-Null + + $linkPath = Join-Path $Root $item.ArchivePath + $parent = Split-Path -Path $linkPath -Parent + if ($parent -and -not (Test-Path -LiteralPath $parent)) { + New-Item -ItemType Directory -Path $parent -Force | Out-Null + } + if (Test-Path -LiteralPath $linkPath) { Remove-BaknretJunction -Path $linkPath } + + if ($item.IsFile) { + try { + New-Item -ItemType HardLink -Path $linkPath -Target $item.RealPath -ErrorAction Stop | Out-Null + } catch { + Write-Log ("暂存:硬链接不可用({0}),改为复制文件 {1}" -f $_.Exception.Message, $item.RealPath) -Level DEBUG + Copy-Item -LiteralPath $item.RealPath -Destination $linkPath -Force -ErrorAction Stop + } + } else { + New-Item -ItemType Junction -Path $linkPath -Target $item.RealPath -ErrorAction Stop | Out-Null + } + + Write-Log ("暂存:{0} -> {1}" -f $item.ArchivePath, $item.RealPath) -Level DEBUG } - Write-Log ("暂存:{0} -> {1}" -f $item.ArchivePath, $item.RealPath) -Level DEBUG + return $Root + } catch { + try { + Remove-BaknretArchiveStaging -Root $Root + } catch { + # 清理失败不能盖掉真正的失败原因(那才是排查需要的),所以只告警并点名残留路径 + Write-Log ("暂存目录自清理失败,需要手工删除:{0} —— {1}" -f $Root, $_.Exception.Message) -Level WARN + } + throw } - - return $Root } function Remove-BaknretArchiveStaging { diff --git a/tests/BakNRet.Tests.ps1 b/tests/BakNRet.Tests.ps1 index e88c41e..bbf8dc5 100644 --- a/tests/BakNRet.Tests.ps1 +++ b/tests/BakNRet.Tests.ps1 @@ -615,6 +615,32 @@ Describe '归档项与暂存目录' { (Get-BaknretArchiveTopName -ArchivePath '') | Should -Be '' } + It '暂存半途失败会自己清干净(不留指向真实数据的 junction)' { + # 函数抛错时没有返回值,调用方的 finally 拿到 $null 就什么都不会清 —— 所以清理 + # 必须由函数自己在 catch 里做,不能指望调用方。 + $base = Join-Path $env:TEMP ("bnr-stage-leak-" + [guid]::NewGuid().ToString('N').Substring(0, 8)) + $realDir = Join-Path $base 'real' + $sandbox = Join-Path $base 'sandbox' + New-Item -ItemType Directory -Path $realDir, $sandbox -Force | Out-Null + Set-Content -Encoding UTF8 -LiteralPath (Join-Path $realDir 'inner.txt') -Value 'inner' + + $items = @( + # 第一项会成功挂上,而且是**目录走 junction** —— 这正是危险的那个物件:它指向 + # 真实数据,而临时目录迟早会被某次 Remove-Item -Recurse 扫到。 + New-BaknretArchiveItem -ArchivePath 'Good' -RealPath $realDir -Kind 'slot' -Slot 'Good' -Exists $true -IsFile $false + # 第二项必然失败:源文件不存在,硬链接与复制都会失败 + New-BaknretArchiveItem -ArchivePath 'Missing.txt' -RealPath (Join-Path $base 'does-not-exist.txt') -Kind 'slot' -Slot 'Missing' -Exists $false -IsFile $true + ) + + { New-BaknretArchiveStaging -Items $items -Root (Join-Path $sandbox 'stage') } | Should -Throw + + $left = @(Get-ChildItem -LiteralPath $sandbox -Recurse -Force -ErrorAction SilentlyContinue) + @($left | Where-Object { $_.LinkType -eq 'Junction' }).Count | Should -Be 0 + $left.Count | Should -Be 0 + + Remove-Item -LiteralPath $base -Recurse -Force -ErrorAction SilentlyContinue + } + It '目录项用 junction 挂进暂存目录,文件项用硬链接(名字就是归档内路径)' { $items = @( New-BaknretArchiveItem -ArchivePath 'Alpha' -RealPath $script:StagingDir -Kind 'slot' -Slot 'Alpha' -Exists $true -IsFile $false diff --git a/tests/Run-Tests.ps1 b/tests/Run-Tests.ps1 index f0e3d4a..3f5e996 100644 --- a/tests/Run-Tests.ps1 +++ b/tests/Run-Tests.ps1 @@ -415,6 +415,36 @@ Test-Case '归档项的属性集与契约一致(没有旧的 Sources / Dirs / } } +Test-Case '暂存半途失败会自己清干净(不留指向真实数据的 junction)' { + # 这是"全仓唯一会把 junction 留在真实数据上"那个缺陷的回归断言。 + # 函数抛错时没有返回值,调用方的 finally 拿到 $null 就什么都不会清 —— 所以清理必须 + # 由函数自己在 catch 里做,不能指望调用方。 + $base = Join-Path $env:TEMP ("bnr-stage-leak-" + [guid]::NewGuid().ToString('N').Substring(0, 8)) + $realDir = Join-Path $base 'real' + $sandbox = Join-Path $base 'sandbox' + New-Item -ItemType Directory -Path $realDir, $sandbox -Force | Out-Null + Set-Content -Encoding UTF8 -LiteralPath (Join-Path $realDir 'inner.txt') -Value 'inner' + + $items = @( + # 第一项会成功挂上,而且是**目录走 junction** —— 这正是危险的那个物件:它指向 + # 真实数据,而临时目录迟早会被某次 Remove-Item -Recurse 扫到。 + (New-BaknretArchiveItem -ArchivePath 'Good' -RealPath $realDir -Kind 'slot' -Slot 'Good' -Exists $true -IsFile $false) + # 第二项必然失败:源文件不存在,硬链接与复制都会失败 + (New-BaknretArchiveItem -ArchivePath 'Missing.txt' -RealPath (Join-Path $base 'does-not-exist.txt') -Kind 'slot' -Slot 'Missing' -Exists $false -IsFile $true) + ) + + $threw = $false + try { New-BaknretArchiveStaging -Items $items -Root (Join-Path $sandbox 'stage') | Out-Null } catch { $threw = $true } + Assert-True $threw '第二项应当抛错(源文件不存在,硬链接与复制都会失败)' + + $left = @(Get-ChildItem -LiteralPath $sandbox -Recurse -Force -ErrorAction SilentlyContinue) + $junctions = @($left | Where-Object { $_.LinkType -eq 'Junction' }) + Assert-Equal 0 $junctions.Count ('残留了指向真实数据的连接点:' + ($junctions.FullName -join '、')) + Assert-Equal 0 $left.Count ('暂存目录没有清干净,残留:' + ($left.FullName -join '、')) + + Remove-Item -LiteralPath $base -Recurse -Force -ErrorAction SilentlyContinue +} + Test-Case 'New-BaknretArchiveStaging:目录走 junction、文件走硬链接,按归档内名字挂载' { $root = Join-Path $itemSandbox 'stage-src' New-Item -ItemType Directory -Path (Join-Path $root 'App') -Force | Out-Null