From 151d6077cf418a42628bc7e38bda4f873877f147 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 20 Sep 2026 16:54:46 +0200 Subject: [PATCH] fix(install.ps1): reserve temp paths exclusively instead of adopting them Distilled from #1245 by Andrew Hundt: the two exclusive-reservation helpers and their two call sites, hand-ported onto main's install.ps1. Nothing else from that file is taken -- the PR's copy also carries an older TLS block that would re-break Windows 10 (#1856), so a wholesale take was not an option. Two paths the installer used were guessable, and one of them adopted whatever it found: $TmpDir = Join-Path $env:TEMP "cbm-install-$(Get-Random)" New-Item -ItemType Directory -Path $TmpDir -Force Get-Random is seeded per process and its space is small, and -Force on an existing directory is a no-op that returns it. A directory planted at a guessed name became the staging area -- and the owner-only DACL the installer applies is set only afterwards, on a directory someone else created. The new helper names the directory with a GUID and creates it with -ErrorAction Stop, so a collision is a retry, never an adoption. $InstallerTmp = "$InstallerDest.new" was a fixed path in the install directory, reservable by anyone who could write there first. The sibling is now reserved with FileMode.CreateNew and FileShare.None on an unguessable name, which is atomic, before anything is copied into it. One deliberate difference from the PR: the sibling reservation runs INSIDE the existing try, and the cleanup checks that a path was assigned. That block is documented as best-effort ("a failure here still leaves a working install"), and a reservation that exhausts its 32 attempts should be one more best-effort miss, not a script abort under ErrorActionPreference=Stop. Verified: pure ASCII (PS 5.1 reads a BOM-less .ps1 as ANSI), LF endings matching main, both old call sites gone from live code. NOT verified: this file was not parsed by PowerShell. There is no pwsh on the macOS host, the Windows VM is down, and CI's Windows guards only check that `update` prints the install.ps1 command -- they never execute it. Opened as a draft for that reason; it needs one PowerShell parse before it merges. Co-authored-by: Andrew Hundt Signed-off-by: Martin Vogel --- install.ps1 | 63 +++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 59 insertions(+), 4 deletions(-) diff --git a/install.ps1 b/install.ps1 index 3b087321b..abd203fb2 100644 --- a/install.ps1 +++ b/install.ps1 @@ -134,13 +134,63 @@ Write-Host " arch: $Arch" Write-Host " target: $InstallDir\$BinName" Write-Host "" +# Reserve a temporary path that no other process can already own. +# +# The staging directory used to be "cbm-install-" created with +# -Force. Get-Random is seeded per process and its space is small, and -Force +# ADOPTS a directory that already exists -- so a directory planted at a guessed +# name became the installer's staging area, ahead of the owner-only DACL that +# is applied below. GUID names plus -ErrorAction Stop mean a collision is a +# retry, never an adoption. The same rule applies to the installer's own +# sibling temp file: ".new" was a fixed path in the install directory, +# reservable by anyone who could write there first. FileMode.CreateNew with +# FileShare.None reserves an unguessable sibling atomically instead. +function New-CbmExclusiveSiblingTemp { + param([Parameter(Mandatory=$true)][string]$Destination) + + $directory = [System.IO.Path]::GetDirectoryName($Destination) + $leaf = [System.IO.Path]::GetFileName($Destination) + for ($attempt = 0; $attempt -lt 32; $attempt++) { + $random = [System.IO.Path]::GetRandomFileName() + $candidate = Join-Path $directory ".$leaf.tmp-$random" + try { + $reservation = [System.IO.File]::Open( + $candidate, + [System.IO.FileMode]::CreateNew, + [System.IO.FileAccess]::Write, + [System.IO.FileShare]::None) + $reservation.Dispose() + return $candidate + } catch [System.IO.IOException] { + # A collision belongs to another process; reserve a fresh sibling. + } + } + throw "could not reserve an exclusive temporary sibling for $Destination" +} + +function New-CbmExclusiveTempDirectory { + param([Parameter(Mandatory=$true)][string]$ParentDirectory) + + for ($attempt = 0; $attempt -lt 32; $attempt++) { + $candidate = Join-Path $ParentDirectory ( + "cbm-install-" + [guid]::NewGuid().ToString("N") + ) + try { + New-Item -ItemType Directory -Path $candidate -ErrorAction Stop | Out-Null + return $candidate + } catch [System.IO.IOException] { + # Never adopt or remove a colliding path owned by another process. + } + } + throw "could not reserve an exclusive installer temporary directory" +} + # Build download URL $Archive = "codebase-memory-mcp-windows-$Arch.zip" $Url = "$BaseUrl/$Archive" # Download -$TmpDir = Join-Path ([System.IO.Path]::GetTempPath()) "cbm-install-$(Get-Random)" -New-Item -ItemType Directory -Path $TmpDir -Force | Out-Null +$TmpDir = New-CbmExclusiveTempDirectory -ParentDirectory ([System.IO.Path]::GetTempPath()) # Give the staging directory a protected owner-only DACL. # @@ -364,13 +414,18 @@ if ($LASTEXITCODE -ne 0) { $DownloadedInstaller = Join-Path $TmpDir "install.ps1" if (Test-Path -LiteralPath $DownloadedInstaller -PathType Leaf) { $InstallerDest = Join-Path $InstallDir "install.ps1" - $InstallerTmp = "$InstallerDest.new" + # Reserved inside the try: failing to reserve a sibling is a best-effort + # miss like any other step here, not a reason to abort a finished install. + $InstallerTmp = $null try { + $InstallerTmp = New-CbmExclusiveSiblingTemp -Destination $InstallerDest Copy-Item -LiteralPath $DownloadedInstaller -Destination $InstallerTmp -Force -ErrorAction Stop Move-Item -LiteralPath $InstallerTmp -Destination $InstallerDest -Force -ErrorAction Stop Write-Host "Installed updater -> $InstallerDest" } catch { - Remove-Item -LiteralPath $InstallerTmp -Force -ErrorAction SilentlyContinue + if ($InstallerTmp) { + Remove-Item -LiteralPath $InstallerTmp -Force -ErrorAction SilentlyContinue + } Write-Host "note: could not place install.ps1 in $InstallDir (update will explain where to find it)" } }