From 05daeeb56869698c0f7596e2a72c495ba49d2183 Mon Sep 17 00:00:00 2001 From: PureWeen <223556219+Copilot@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:37:43 -0500 Subject: [PATCH 1/2] Address post-merge review findings on the PR finalize apply step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #36849 merged with two review findings still open. Both are fixed here. 1. Sanitize the STEP 5.5 failure path (Copilot, round 2 — Review-PR.ps1:2416) The apply step's catch block wrote the raw exception to the console. That exception can carry agent-authored text from content.md, so an AzDO logging command embedded in a PR title could reach stdout through the error path — the exact injection class the rest of #36849 closed. Every other PR-derived console value in this script already goes through ConvertTo-AzdoSafeConsole; this one now does too. Verified: "##vso[" is rewritten to "## vso[", which AzDO no longer parses as a command. 2. Do not write the body through a predictable temp path (kubaflo, non-blocking) The body file was named pr-finalize-body-.md in the system temp dir. Set-Content -LiteralPath follows a pre-existing symlink and writes through to its target, so anything able to pre-plant that path could redirect the write. New-ExclusiveTempFile now creates a randomly-named file with New-Item and no -Force, which fails closed if the path already exists. Confirmed empirically that New-Item raises IOException on a pre-planted symlink and leaves the target untouched. It also prefers AGENT_TEMPDIRECTORY when the pipeline sets it, keeping the file on agent-scoped storage. This is defence in depth, not a live exploit: the precondition (arbitrary filesystem write as the agent user before Task 4) already confers strictly greater capability than the vector itself. Tests: 30 -> 35 in Apply-PRFinalize.Tests.ps1, covering AGENT_TEMPDIRECTORY placement and fallback, path uniqueness, a missing AGENT_TEMPDIRECTORY, and a symlink-write-through regression. 97/97 pass across the three affected suites. Also exercised end to end against real PR data with a stubbed gh, confirming the randomized file reaches --body-file with the right content and is cleaned up. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 40f61a36-c005-42d8-af25-e1228194d196 --- .github/scripts/Apply-PRFinalize.Tests.ps1 | 84 +++++++++++++++++++++- .github/scripts/Review-PR.ps1 | 4 +- .github/scripts/apply-pr-finalize.ps1 | 47 +++++++++++- 3 files changed, 131 insertions(+), 4 deletions(-) diff --git a/.github/scripts/Apply-PRFinalize.Tests.ps1 b/.github/scripts/Apply-PRFinalize.Tests.ps1 index 5d3d6faa4717..82520e63b586 100644 --- a/.github/scripts/Apply-PRFinalize.Tests.ps1 +++ b/.github/scripts/Apply-PRFinalize.Tests.ps1 @@ -37,7 +37,8 @@ BeforeAll { 'Test-FinalizeIsNoOp', 'Get-FinalizeRecommendation', 'Merge-PreservedTitlePrefix', - 'Merge-PreservedBodyPreamble' + 'Merge-PreservedBodyPreamble', + 'New-ExclusiveTempFile' )) { $function = $ast.Find({ $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] -and @@ -317,3 +318,84 @@ Describe 'Security regressions — AzDO logging-command injection' { Should -Be '[iOS] new' } } + +Describe 'New-ExclusiveTempFile' { + BeforeAll { + $script:SandboxDir = Join-Path ([System.IO.Path]::GetTempPath()) "apply-prfinalize-tests-$([System.IO.Path]::GetRandomFileName())" + New-Item -ItemType Directory -Path $script:SandboxDir -Force | Out-Null + $script:OriginalAgentTemp = $env:AGENT_TEMPDIRECTORY + $env:AGENT_TEMPDIRECTORY = $script:SandboxDir + } + + AfterAll { + $env:AGENT_TEMPDIRECTORY = $script:OriginalAgentTemp + Remove-Item -LiteralPath $script:SandboxDir -Recurse -Force -ErrorAction SilentlyContinue + } + + It 'creates the file inside AGENT_TEMPDIRECTORY when it is set' { + $path = New-ExclusiveTempFile -Prefix 'pr-finalize-body-123' + try { + Test-Path -LiteralPath $path | Should -BeTrue + (Split-Path -Parent $path) | Should -Be $script:SandboxDir + } finally { + Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue + } + } + + It 'produces a unique path on each call, so the name is not predictable' { + $a = New-ExclusiveTempFile -Prefix 'pr-finalize-body-123' + $b = New-ExclusiveTempFile -Prefix 'pr-finalize-body-123' + try { + $a | Should -Not -Be $b + } finally { + Remove-Item -LiteralPath $a -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $b -Force -ErrorAction SilentlyContinue + } + } + + It 'writes to the new file rather than through a pre-planted symlink' { + # Regression for the round-2 hardening note: Set-Content follows an existing symlink + # and writes through to its target. A fresh randomly-named file cannot be pre-planted. + $secret = Join-Path $script:SandboxDir 'secret.txt' + Set-Content -LiteralPath $secret -Value 'ORIGINAL' -Encoding UTF8 + + $path = New-ExclusiveTempFile -Prefix 'pr-finalize-body-123' + try { + 'REPLACEMENT BODY' | Set-Content -LiteralPath $path -Encoding UTF8 + (Get-Content -Raw -LiteralPath $secret).Trim() | Should -Be 'ORIGINAL' + (Get-Content -Raw -LiteralPath $path).Trim() | Should -Be 'REPLACEMENT BODY' + } finally { + Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue + } + } + + It 'falls back to the system temp directory when AGENT_TEMPDIRECTORY is unset' { + $saved = $env:AGENT_TEMPDIRECTORY + $env:AGENT_TEMPDIRECTORY = $null + try { + $path = New-ExclusiveTempFile -Prefix 'pr-finalize-body-123' + try { + Test-Path -LiteralPath $path | Should -BeTrue + } finally { + Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue + } + } finally { + $env:AGENT_TEMPDIRECTORY = $saved + } + } + + It 'ignores AGENT_TEMPDIRECTORY when it points at a missing directory' { + $saved = $env:AGENT_TEMPDIRECTORY + $env:AGENT_TEMPDIRECTORY = Join-Path $script:SandboxDir 'does-not-exist' + try { + $path = New-ExclusiveTempFile -Prefix 'pr-finalize-body-123' + try { + Test-Path -LiteralPath $path | Should -BeTrue + } finally { + Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue + } + } finally { + $env:AGENT_TEMPDIRECTORY = $saved + } + } +} diff --git a/.github/scripts/Review-PR.ps1 b/.github/scripts/Review-PR.ps1 index 2814a1fc5d46..c2729126956b 100644 --- a/.github/scripts/Review-PR.ps1 +++ b/.github/scripts/Review-PR.ps1 @@ -2413,7 +2413,9 @@ if ($env:SKIP_PR_FINALIZE_APPLY -eq 'true') { if ($DryRun) { $applyArgs.DryRun = $true } & $applyFinalizeScript @applyArgs } catch { - Write-Host " ⚠️ Failed to apply PR title/description (non-fatal): $_" -ForegroundColor Yellow + # The exception can carry agent-authored text from content.md, so it goes through + # the sanitizer like every other PR-derived console value in this script. + Write-Host " ⚠️ Failed to apply PR title/description (non-fatal): $(ConvertTo-AzdoSafeConsole "$_")" -ForegroundColor Yellow } } else { Write-Host " ⚠️ apply-pr-finalize.ps1 not found — skipping" -ForegroundColor Yellow diff --git a/.github/scripts/apply-pr-finalize.ps1 b/.github/scripts/apply-pr-finalize.ps1 index b7d317836529..b012adca79b2 100644 --- a/.github/scripts/apply-pr-finalize.ps1 +++ b/.github/scripts/apply-pr-finalize.ps1 @@ -254,6 +254,48 @@ function Merge-PreservedBodyPreamble { return "$preamble`n`n$RecommendedBody" } +function New-ExclusiveTempFile { + <# + .SYNOPSIS + Creates a new, uniquely-named temp file, failing if the path already exists. + .DESCRIPTION + `Set-Content` follows a pre-existing symlink and writes through to its target, so a + predictable temp path (pr-finalize-body-.md) is a write-through primitive if + anything can pre-create it. Reaching that requires arbitrary filesystem write as the + agent user, which already grants strictly more capability — but the fix is cheap, so + close it anyway rather than relying on that argument holding. + + Prefers the AzDO agent temp directory over the shared system temp when available. + Creating with New-Item (no -Force) fails when the path exists, including when it is a + dangling or pre-planted symlink, so the write cannot be redirected. + .OUTPUTS + Full path to the newly created file. + #> + param( + [Parameter(Mandatory = $true)] + [string]$Prefix + ) + + $baseDir = if ($env:AGENT_TEMPDIRECTORY -and (Test-Path -LiteralPath $env:AGENT_TEMPDIRECTORY)) { + $env:AGENT_TEMPDIRECTORY + } else { + [System.IO.Path]::GetTempPath() + } + + # Retry only guards against an astronomically unlikely name collision. + for ($attempt = 0; $attempt -lt 5; $attempt++) { + $candidate = Join-Path $baseDir "$Prefix-$([System.IO.Path]::GetRandomFileName()).md" + try { + $file = New-Item -ItemType File -Path $candidate -ErrorAction Stop + return $file.FullName + } catch { + continue + } + } + + throw "Could not create a temp file under '$baseDir' after 5 attempts." +} + # ─── Main ─────────────────────────────────────────────────────────────────────── # Dot-sourced by the Pester suite to test the helpers above without executing the flow. if ($MyInvocation.InvocationName -eq '.') { return } @@ -340,8 +382,9 @@ if ($DryRun) { exit 0 } -$bodyFile = Join-Path ([System.IO.Path]::GetTempPath()) "pr-finalize-body-$PRNumber.md" +$bodyFile = $null try { + $bodyFile = New-ExclusiveTempFile -Prefix "pr-finalize-body-$PRNumber" $newBody | Set-Content -LiteralPath $bodyFile -Encoding UTF8 $ghArgs = @('pr', 'edit', "$PRNumber", '--repo', $Repo) @@ -359,7 +402,7 @@ try { } catch { Write-Host " ⚠️ Failed to apply the PR finalize recommendation (non-fatal): $(ConvertTo-AzdoSafeConsole "$_")" -ForegroundColor Yellow } finally { - Remove-Item -LiteralPath $bodyFile -Force -ErrorAction SilentlyContinue + if ($bodyFile) { Remove-Item -LiteralPath $bodyFile -Force -ErrorAction SilentlyContinue } } exit 0 From 8dd0f7ff5acadc74a8442f5b914f182c3d719596 Mon Sep 17 00:00:00 2001 From: PureWeen <223556219+Copilot@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:08:25 -0500 Subject: [PATCH 2/2] Make the symlink regression test actually catch the regression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit kubaflo proved the symlink test added in this PR was vacuous, and Copilot's inline review flagged the same thing independently. He was right, and I reproduced it: swapping New-ExclusiveTempFile for a predictable-name Set-Content write-through implementation — the exact vulnerability the helper exists to close — still passed the suite 35/35. The test created an unrelated secret.txt and never planted a symlink at a path the helper would try, so "two different paths" proved uniqueness, not unpredictability. Add a -NameGenerator seam so a test can force known candidate names and plant a symlink at the precise path the helper will attempt. Six new tests now cover: an existing pre-planted symlink (target untouched, link still a link), a dangling one (target never created), exhaustion throwing with no predictable fallback, exactly MaxAttempts attempts, a non-collision error surfacing immediately, and a stale AGENT_TEMPDIRECTORY pointing at a file. The same mutant now fails all six. That is the point of the change: the test protecting finding #2 previously would not have caught a regression of finding #2. Also from Copilot's inline review: - Test-Path now uses -PathType Container, so an AGENT_TEMPDIRECTORY pointing at a file falls back cleanly instead of failing later inside New-Item. - Retry is narrowed to IOException (an occupied path). DirectoryNotFoundException derives from IOException, so it is caught first and rethrown — a missing base directory never resolves by picking another name. Everything else (access denied, invalid path) propagates unwrapped instead of being masked by the generic "after N attempts" message. Exception types confirmed empirically. - .SYNOPSIS no longer says the helper "fails if the path already exists"; it skips an occupied path and only throws once attempts are exhausted. Also corrected the STEP 5.5 comment in Review-PR.ps1. kubaflo noted it overstated reachability, and he is right: the child sanitizes its own console output and its one throw carries no PR-derived text today. The sanitization is a backstop worth keeping, but the comment now says so rather than implying a live path. Tests: 35 -> 40 in Apply-PRFinalize.Tests.ps1; 102/102 across the three affected suites. All three scripts parse clean, and the end-to-end stubbed-gh run still reaches --body-file with the right content and cleans up. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 40f61a36-c005-42d8-af25-e1228194d196 --- .github/scripts/Apply-PRFinalize.Tests.ps1 | 119 ++++++++++++++++++++- .github/scripts/Review-PR.ps1 | 6 +- .github/scripts/apply-pr-finalize.ps1 | 38 +++++-- 3 files changed, 146 insertions(+), 17 deletions(-) diff --git a/.github/scripts/Apply-PRFinalize.Tests.ps1 b/.github/scripts/Apply-PRFinalize.Tests.ps1 index 82520e63b586..0459aa630e3f 100644 --- a/.github/scripts/Apply-PRFinalize.Tests.ps1 +++ b/.github/scripts/Apply-PRFinalize.Tests.ps1 @@ -353,19 +353,128 @@ Describe 'New-ExclusiveTempFile' { } } - It 'writes to the new file rather than through a pre-planted symlink' { - # Regression for the round-2 hardening note: Set-Content follows an existing symlink - # and writes through to its target. A fresh randomly-named file cannot be pre-planted. - $secret = Join-Path $script:SandboxDir 'secret.txt' + # These pin the actual finding-#2 behaviour. The earlier version of this test was + # vacuous: it never planted a symlink at a path the helper would try, so a + # predictable-name Set-Content write-through implementation still passed it. The + # -NameGenerator seam forces known candidates so a symlink can be planted precisely. + It 'refuses a pre-planted symlink and leaves its target untouched' { + $secret = Join-Path $script:SandboxDir 'secret-existing.txt' Set-Content -LiteralPath $secret -Value 'ORIGINAL' -Encoding UTF8 - $path = New-ExclusiveTempFile -Prefix 'pr-finalize-body-123' + $planted = Join-Path $script:SandboxDir 'pr-finalize-body-123-forced0.md' + New-Item -ItemType SymbolicLink -Path $planted -Target $secret | Out-Null + + # $script: scope is required — a plain $i++ inside the scriptblock would mutate a + # local copy, so every attempt would re-request the planted name. + $script:ForcedIndex = 0 + $path = New-ExclusiveTempFile -Prefix 'pr-finalize-body-123' -NameGenerator { + $n = "forced$($script:ForcedIndex)"; $script:ForcedIndex++; $n + } try { + # It must have skipped the planted path entirely... + $path | Should -Not -Be $planted 'REPLACEMENT BODY' | Set-Content -LiteralPath $path -Encoding UTF8 + # ...so the symlink target is untouched, and the link is still a link. (Get-Content -Raw -LiteralPath $secret).Trim() | Should -Be 'ORIGINAL' + (Get-Item -LiteralPath $planted).LinkType | Should -Be 'SymbolicLink' (Get-Content -Raw -LiteralPath $path).Trim() | Should -Be 'REPLACEMENT BODY' } finally { Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $planted -Force -ErrorAction SilentlyContinue + } + } + + It 'refuses a dangling pre-planted symlink rather than creating its target' { + $missingTarget = Join-Path $script:SandboxDir 'never-created.txt' + $planted = Join-Path $script:SandboxDir 'pr-finalize-body-123-dangle0.md' + New-Item -ItemType SymbolicLink -Path $planted -Target $missingTarget | Out-Null + + $script:DangleIndex = 0 + $path = New-ExclusiveTempFile -Prefix 'pr-finalize-body-123' -NameGenerator { + $n = "dangle$($script:DangleIndex)"; $script:DangleIndex++; $n + } + try { + $path | Should -Not -Be $planted + 'REPLACEMENT BODY' | Set-Content -LiteralPath $path -Encoding UTF8 + # Writing through a dangling link would have created the target. + Test-Path -LiteralPath $missingTarget | Should -BeFalse + } finally { + Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $planted -Force -ErrorAction SilentlyContinue + } + } + + It 'throws instead of falling back to a predictable path when every candidate is occupied' { + $secret = Join-Path $script:SandboxDir 'secret-exhaust.txt' + Set-Content -LiteralPath $secret -Value 'ORIGINAL' -Encoding UTF8 + + $planted = 0..4 | ForEach-Object { + $link = Join-Path $script:SandboxDir "pr-finalize-body-123-exhaust$_.md" + New-Item -ItemType SymbolicLink -Path $link -Target $secret | Out-Null + $link + } + + try { + $script:ExhaustIndex = 0 + { New-ExclusiveTempFile -Prefix 'pr-finalize-body-123' -NameGenerator { + $n = "exhaust$($script:ExhaustIndex)"; $script:ExhaustIndex++; $n + } } | Should -Throw -ExpectedMessage '*after 5 attempts*' + # No fallback path was written, so the symlink target is still intact. + (Get-Content -Raw -LiteralPath $secret).Trim() | Should -Be 'ORIGINAL' + } finally { + $planted | ForEach-Object { Remove-Item -LiteralPath $_ -Force -ErrorAction SilentlyContinue } + } + } + + It 'tries exactly MaxAttempts candidates before giving up' { + $secret = Join-Path $script:SandboxDir 'secret-count.txt' + Set-Content -LiteralPath $secret -Value 'ORIGINAL' -Encoding UTF8 + $planted = 0..4 | ForEach-Object { + $link = Join-Path $script:SandboxDir "pr-finalize-body-123-count$_.md" + New-Item -ItemType SymbolicLink -Path $link -Target $secret | Out-Null + $link + } + + try { + $script:Calls = 0 + { New-ExclusiveTempFile -Prefix 'pr-finalize-body-123' -NameGenerator { $n = "count$($script:Calls)"; $script:Calls++; $n } } | + Should -Throw + $script:Calls | Should -Be 5 + } finally { + $planted | ForEach-Object { Remove-Item -LiteralPath $_ -Force -ErrorAction SilentlyContinue } + } + } + + It 'surfaces a non-collision failure immediately instead of retrying it away' { + # A missing base directory can never be resolved by picking another name, so it must + # propagate rather than be masked by the generic "after N attempts" message. + $saved = $env:AGENT_TEMPDIRECTORY + $env:AGENT_TEMPDIRECTORY = $script:SandboxDir + try { + $script:Calls = 0 + { New-ExclusiveTempFile -Prefix 'missing-dir/nope/body' -NameGenerator { $script:Calls++; 'x' } } | + Should -Throw -ExpectedMessage '*Could not find a part of the path*' + $script:Calls | Should -Be 1 + } finally { + $env:AGENT_TEMPDIRECTORY = $saved + } + } + + It 'ignores AGENT_TEMPDIRECTORY when it points at a file rather than a directory' { + $saved = $env:AGENT_TEMPDIRECTORY + $asFile = Join-Path $script:SandboxDir 'not-a-directory.txt' + Set-Content -LiteralPath $asFile -Value 'x' -Encoding UTF8 + $env:AGENT_TEMPDIRECTORY = $asFile + try { + $path = New-ExclusiveTempFile -Prefix 'pr-finalize-body-123' + try { + Test-Path -LiteralPath $path | Should -BeTrue + (Split-Path -Parent $path) | Should -Not -Be $asFile + } finally { + Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue + } + } finally { + $env:AGENT_TEMPDIRECTORY = $saved } } diff --git a/.github/scripts/Review-PR.ps1 b/.github/scripts/Review-PR.ps1 index c2729126956b..4aa2a325ca34 100644 --- a/.github/scripts/Review-PR.ps1 +++ b/.github/scripts/Review-PR.ps1 @@ -2413,8 +2413,10 @@ if ($env:SKIP_PR_FINALIZE_APPLY -eq 'true') { if ($DryRun) { $applyArgs.DryRun = $true } & $applyFinalizeScript @applyArgs } catch { - # The exception can carry agent-authored text from content.md, so it goes through - # the sanitizer like every other PR-derived console value in this script. + # Backstop, not a live path: the child sanitizes its own console output and its + # one throw carries no PR-derived text today. Kept because a future throw that + # quotes the recommendation would otherwise reach stdout unsanitized, and every + # other console sink in this script already goes through the sanitizer. Write-Host " ⚠️ Failed to apply PR title/description (non-fatal): $(ConvertTo-AzdoSafeConsole "$_")" -ForegroundColor Yellow } } else { diff --git a/.github/scripts/apply-pr-finalize.ps1 b/.github/scripts/apply-pr-finalize.ps1 index b012adca79b2..fdd3851a31e2 100644 --- a/.github/scripts/apply-pr-finalize.ps1 +++ b/.github/scripts/apply-pr-finalize.ps1 @@ -257,7 +257,7 @@ function Merge-PreservedBodyPreamble { function New-ExclusiveTempFile { <# .SYNOPSIS - Creates a new, uniquely-named temp file, failing if the path already exists. + Creates a temp file at a fresh, unpredictable path, never writing to one that exists. .DESCRIPTION `Set-Content` follows a pre-existing symlink and writes through to its target, so a predictable temp path (pr-finalize-body-.md) is a write-through primitive if @@ -266,34 +266,52 @@ function New-ExclusiveTempFile { close it anyway rather than relying on that argument holding. Prefers the AzDO agent temp directory over the shared system temp when available. - Creating with New-Item (no -Force) fails when the path exists, including when it is a - dangling or pre-planted symlink, so the write cannot be redirected. + Creating with New-Item (no -Force) refuses any path that already exists, including a + pre-planted or dangling symlink, so the write cannot be redirected. An occupied path + is skipped for a fresh random name; after $MaxAttempts the helper throws rather than + falling back to a predictable path, so exhaustion can never reopen the vector. + .PARAMETER NameGenerator + Test seam only. Lets a test force known candidate names so it can pre-plant a symlink + at the exact path the helper will try. Production uses random names. .OUTPUTS Full path to the newly created file. #> param( [Parameter(Mandatory = $true)] - [string]$Prefix + [string]$Prefix, + + [scriptblock]$NameGenerator = { [System.IO.Path]::GetRandomFileName() }, + + [int]$MaxAttempts = 5 ) - $baseDir = if ($env:AGENT_TEMPDIRECTORY -and (Test-Path -LiteralPath $env:AGENT_TEMPDIRECTORY)) { + # -PathType Container so a stale AGENT_TEMPDIRECTORY pointing at a *file* falls back + # cleanly instead of failing later inside New-Item. + $baseDir = if ($env:AGENT_TEMPDIRECTORY -and (Test-Path -LiteralPath $env:AGENT_TEMPDIRECTORY -PathType Container)) { $env:AGENT_TEMPDIRECTORY } else { [System.IO.Path]::GetTempPath() } - # Retry only guards against an astronomically unlikely name collision. - for ($attempt = 0; $attempt -lt 5; $attempt++) { - $candidate = Join-Path $baseDir "$Prefix-$([System.IO.Path]::GetRandomFileName()).md" + for ($attempt = 0; $attempt -lt $MaxAttempts; $attempt++) { + $candidate = Join-Path $baseDir "$Prefix-$(& $NameGenerator).md" try { $file = New-Item -ItemType File -Path $candidate -ErrorAction Stop return $file.FullName - } catch { + } catch [System.IO.DirectoryNotFoundException] { + # Derives from IOException, so it must be caught ahead of the collision case — + # a missing base directory will never resolve by picking another name. + throw + } catch [System.IO.IOException] { + # The path is occupied (regular file, or a pre-planted/dangling symlink). Skip it + # rather than write through, and try a different name. continue } + # Anything else (access denied, invalid path) is a real fault: let it surface + # unwrapped instead of being retried into a generic "after N attempts" message. } - throw "Could not create a temp file under '$baseDir' after 5 attempts." + throw "Could not create a temp file under '$baseDir' after $MaxAttempts attempts." } # ─── Main ───────────────────────────────────────────────────────────────────────