#Requires -Version 5.1 <# .SYNOPSIS Install a published Wax release on Windows. .DESCRIPTION The Windows counterpart of Tools/Release/install.sh, with the same trust model: the release index names the archive's size and SHA-256, and this script installs only an archive that matches the record it selected. The archive is never trusted on its own. Layout under the install root (a junction, not a symbolic link, so no administrator rights or developer mode are needed): releases\--\ the verified archive contents current -> releases\<...> the active release previous -> releases\<...> the one it replaced bin -> current\bin the directory added to PATH -NoPathUpdate installs without touching the user PATH, for a caller that manages it itself. -NoFileAssociation leaves the per-user .wxs registration unchanged. -Uninstall removes what this installer wrote: the install root, its user PATH entry, and its .wxs registration. -WaitForProcessId names a process that is running from inside the install root -- `wax uninstall` passes its own -- whose executable Windows will not delete until it exits, so the install root is removed once it does. #> [CmdletBinding()] param( [string]$Version = "", [string]$InstallRoot = "", [switch]$NoPathUpdate, [switch]$NoFileAssociation, [switch]$Uninstall, [switch]$Yes, [int]$WaitForProcessId = 0 ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" $MaximumIndexSize = 8192 $MaximumArchiveSize = 536870912 $MaximumArchiveEntries = 20000 $SupportedTargets = @("linux-arm64", "linux-x64", "macos-arm64", "windows-arm64", "windows-x64") # Every diagnostic names the command the user actually ran. $Label = if ($Uninstall) { "uninstall" } else { "install" } # Every failure throws so the one handler at the bottom reports it and the # cleanup in its finally always runs: an `exit` from the middle of the install # would leave the lock directory and a staging tree behind. function Fail([string]$Message) { throw $Message } function Test-ReleaseVersion([string]$Candidate) { $match = [regex]::Match($Candidate, '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$') if (-not $match.Success) { return $false } if (-not $match.Groups[4].Success) { return $true } foreach ($identifier in $match.Groups[4].Value.Split(".")) { if ($identifier -match '^[0-9]+$' -and $identifier.Length -gt 1 -and $identifier.StartsWith("0")) { return $false } } return $true } function Compare-ReleaseVersion([string]$Left, [string]$Right) { $leftParts = $Left.Split(".") $rightParts = $Right.Split(".") for ($index = 0; $index -lt 3; $index++) { $difference = [int]$leftParts[$index] - [int]$rightParts[$index] if ($difference -ne 0) { return $difference } } return 0 } function Get-EnvironmentValue([string]$Name, [string]$Default) { $value = [Environment]::GetEnvironmentVariable($Name) if ([string]::IsNullOrEmpty($value)) { return $Default } return $value } # Everything from here down runs inside the handler, argument validation # included. A Fail that escaped it would still stop the install, but it would # print PowerShell's wrapped error record -- the CategoryInfo block the catch # below exists to avoid -- so the same mistake would be reported two different # ways depending on which one the user made. $exitCode = 0 $lock = $null $staging = $null try { $allowFixture = (Get-EnvironmentValue "WAX_INSTALL_ALLOW_INSECURE_FIXTURE" "0") -eq "1" $baseUrl = (Get-EnvironmentValue "WAX_INSTALL_BASE_URL" "https://downloads.waxlang.dev").TrimEnd("/") $indexBaseUrl = (Get-EnvironmentValue "WAX_INSTALL_INDEX_BASE_URL" "https://packages.waxlang.dev/v1/releases").TrimEnd("/") $classesSubkey = "Software\Classes" $classesOverride = Get-EnvironmentValue "WAX_INSTALL_CLASSES_SUBKEY" "" if ($classesOverride -ne "") { if (-not $allowFixture -or $classesOverride -notmatch '^Software\\WaxInstallFixture\\[A-Za-z0-9._-]+\\Classes$') { Fail "WAX_INSTALL_CLASSES_SUBKEY is reserved for installer fixtures" } $classesSubkey = $classesOverride } if ($InstallRoot -eq "") { $InstallRoot = Get-EnvironmentValue "WAX_INSTALL_ROOT" (Join-Path $HOME ".wax") } if ($Version -ne "" -and -not (Test-ReleaseVersion $Version)) { Fail "invalid release version: $Version" } foreach ($url in @($baseUrl, $indexBaseUrl)) { if ($allowFixture) { if ($url -notmatch '^http://(127\.0\.0\.1|localhost):[1-9][0-9]{0,4}(/[A-Za-z0-9._~-]+)*$') { Fail "fixture downloads require an HTTP loopback URL" } } elseif ($url -notmatch '^https://[A-Za-z0-9][A-Za-z0-9.-]*(/[A-Za-z0-9._~-]+)*$') { Fail "release URLs must be HTTPS without credentials, a port, query, or fragment" } } if (-not [System.IO.Path]::IsPathRooted($InstallRoot)) { Fail "install root must be an absolute path" } # PATH is semicolon-separated, so a root containing one could never be written as # a single entry. Normalizing the spelling here is what makes the PATH membership # test below a string comparison that actually holds: C:/x/.wax and C:\x\.wax # name one directory but are two different entries. if ($InstallRoot.Contains(";")) { Fail "install root must not contain a semicolon: $InstallRoot" } $InstallRoot = [System.IO.Path]::GetFullPath($InstallRoot).TrimEnd("\") # The emulated architecture is the wrong answer: an x64 PowerShell on an ARM64 # machine reports AMD64 in PROCESSOR_ARCHITECTURE and the native ARM64 only in # PROCESSOR_ARCHITEW6432, and installing the emulated build would leave every # tool running under emulation. $target = Get-EnvironmentValue "WAX_INSTALL_TARGET" "" if ($target -eq "") { $architecture = Get-EnvironmentValue "PROCESSOR_ARCHITEW6432" (Get-EnvironmentValue "PROCESSOR_ARCHITECTURE" "") switch ($architecture.ToUpperInvariant()) { "ARM64" { $target = "windows-arm64" } "AMD64" { $target = "windows-x64" } default { Fail "unsupported platform: $architecture" } } } if ($target -ne "windows-arm64" -and $target -ne "windows-x64") { Fail "unsupported release target: $target" } $tar = (Get-Command tar.exe -ErrorAction SilentlyContinue) if ($null -eq $tar) { Fail "required command is unavailable: tar.exe (Windows 10 1803 or newer ships it)" } $curl = (Get-Command curl.exe -ErrorAction SilentlyContinue) if ($null -eq $curl) { Fail "required command is unavailable: curl.exe (Windows 10 1803 or newer ships it)" } function Test-WaxJunction([string]$Path) { $item = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue if ($null -eq $item) { return $null } if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) { Fail "$Path exists and is not a Wax-managed junction" } # A junction stores its destination as an NT object path, so the raw target # is `\??\C:\...`. Compare Win32 paths, not that spelling. $destination = @($item.Target)[0] if ([string]::IsNullOrEmpty($destination)) { Fail "$Path is a reparse point this installer cannot read; remove it and reinstall" } if ($destination.StartsWith("\??\")) { $destination = $destination.Substring(4) } return $destination.TrimEnd("\") } function Assert-ManagedReleaseLink([string]$Name) { $link = Join-Path $InstallRoot $Name $destination = Test-WaxJunction $link if ($null -eq $destination) { return } $leaf = Split-Path -Leaf $destination $parent = Split-Path -Leaf (Split-Path -Parent $destination) if ($parent -ne "releases" -or $leaf -notmatch '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?-(windows-arm64|windows-x64)-[0-9a-f]{16}$') { Fail "$link is not a Wax-managed release junction" } } # Windows cannot rename one directory junction over another, so a swap is # delete-then-create. The window where `current` is absent is sub-millisecond and # a tool started inside it fails to launch rather than reading a half-installed # release; the install lock keeps two installers out of it entirely. function Set-Junction([string]$Path, [string]$TargetPath) { # Get-Item -Force, not Test-Path: a junction whose target has been deleted # still exists, but Test-Path reports it absent, and New-Item would then fail # with "already exists" and no way to repair the install by re-running. $existing = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue if ($null -ne $existing) { if (($existing.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) { Fail "$Path exists and is not a Wax-managed junction" } [System.IO.Directory]::Delete($Path) } New-Item -ItemType Junction -Path $Path -Target $TargetPath | Out-Null } function Set-RegistryDefaultString([string]$Subkey, [string]$Value) { $key = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey($Subkey) try { $key.SetValue("", $Value, [Microsoft.Win32.RegistryValueKind]::String) } finally { $key.Dispose() } } function Notify-ShellAssociationChanged { try { if ($null -eq ("WaxShellAssociationNotification" -as [type])) { Add-Type -TypeDefinition @' using System; using System.Runtime.InteropServices; public static class WaxShellAssociationNotification { [DllImport("shell32.dll")] public static extern void SHChangeNotify(uint eventId, uint flags, IntPtr item1, IntPtr item2); } '@ } [WaxShellAssociationNotification]::SHChangeNotify(0x08000000, 0, [IntPtr]::Zero, [IntPtr]::Zero) } catch { Write-Host "registered .wxs files; Explorer will refresh the icon at its next cache update" } } function Register-WaxRecordingFileType { $progId = "Wax.Recording" $iconPath = Join-Path $InstallRoot 'current\share\icons\wax-recording.ico' $waxdbgPath = Join-Path $InstallRoot 'bin\waxdbg.exe' if (-not (Test-Path -LiteralPath $iconPath -PathType Leaf)) { Fail "release archive has no .wxs file icon" } if (-not (Test-Path -LiteralPath $waxdbgPath -PathType Leaf)) { Fail "release archive has no waxdbg executable for .wxs files" } $extension = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey("$classesSubkey\.wxs") try { $extension.SetValue("Content Type", "application/x-wax-recording", [Microsoft.Win32.RegistryValueKind]::String) $openWith = $extension.CreateSubKey("OpenWithProgids") try { $openWith.SetValue($progId, [byte[]]@(), [Microsoft.Win32.RegistryValueKind]::None) } finally { $openWith.Dispose() } } finally { $extension.Dispose() } Set-RegistryDefaultString "$classesSubkey\$progId" "Wax Recording" Set-RegistryDefaultString "$classesSubkey\.wxs\DefaultIcon" ('"' + $iconPath + '",0') Set-RegistryDefaultString "$classesSubkey\$progId\DefaultIcon" ('"' + $iconPath + '",0') Set-RegistryDefaultString "$classesSubkey\$progId\shell\open\command" ('"' + $waxdbgPath + '" gui "%1"') Notify-ShellAssociationChanged Write-Host "registered the .wxs icon and added Wax Inspector to Open With; kept the default application unchanged" } function Remove-WaxPathEntry([string]$BinLink) { $environmentKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey("Environment", $true) if ($null -eq $environmentKey) { return } try { $currentPath = [string]$environmentKey.GetValue("Path", "", [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) $entries = @($currentPath.Split(";") | Where-Object { $_ -ne "" }) if ($entries -notcontains $BinLink) { return } # Same reason as the install side: an entry spelled with %VAR% stops # expanding if the value is rewritten as REG_SZ. $kind = if ($currentPath -like "*%*") { [Microsoft.Win32.RegistryValueKind]::ExpandString } else { $environmentKey.GetValueKind("Path") } $environmentKey.SetValue("Path", (@($entries | Where-Object { $_ -ne $BinLink }) -join ";"), $kind) Write-Host "removed $BinLink from your user PATH; open a new terminal to pick it up" } finally { $environmentKey.Dispose() } } function Unregister-WaxRecordingFileType { $progId = "Wax.Recording" $waxdbgPath = Join-Path $InstallRoot 'bin\waxdbg.exe' $classes = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey($classesSubkey, $true) if ($null -eq $classes) { return } try { $command = "" $openCommand = $classes.OpenSubKey("$progId\shell\open\command") # The open command is the only key that names an install root, so # without it nothing here can be shown to be ours to delete. if ($null -eq $openCommand) { Write-Host "left the .wxs registry keys in place because they are not the ones this installer writes" return } try { $command = [string]$openCommand.GetValue("") } finally { $openCommand.Dispose() } # A second install root keeps its own registration: only the ProgID that # launches this root's debugger is this uninstall's to delete. if (-not $command.Contains($waxdbgPath)) { Write-Host "left the .wxs file association in place because it points at another Wax installation" return } $classes.DeleteSubKeyTree($progId, $false) $classes.DeleteSubKeyTree(".wxs\DefaultIcon", $false) $extension = $classes.OpenSubKey(".wxs", $true) if ($null -ne $extension) { try { $extension.DeleteValue("Content Type", $false) $openWith = $extension.OpenSubKey("OpenWithProgids", $true) if ($null -ne $openWith) { try { $openWith.DeleteValue($progId, $false) } finally { $openWith.Dispose() } } } finally { $extension.Dispose() } } Notify-ShellAssociationChanged Write-Host "removed the .wxs file association" } finally { $classes.Dispose() } } # The junctions come out before the tree does: Remove-Item -Recurse follows a # junction into its target, and these point back inside the install root. function Remove-WaxManagedLinks { foreach ($name in @("bin", "current", "previous")) { $link = Join-Path $InstallRoot $name $item = Get-Item -LiteralPath $link -Force -ErrorAction SilentlyContinue if ($null -ne $item -and ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { [System.IO.Directory]::Delete($link) } } } # Windows refuses to delete a running executable, so a `wax uninstall` started # from inside the install root cannot remove its own release directory. Hand the # last step to a detached process that waits for both this script and the wax # process that asked for the uninstall, under a deadline so a wedged process # cannot leave it waiting forever. Everything it does happens there and not # here: a removal that fails must leave an installation that still runs and can # still be uninstalled again, not a rooted-out one that neither works nor # repairs. function Start-WaxDeferredRemoval { $powershell = Join-Path ([Environment]::GetFolderPath("System")) "WindowsPowerShell\v1.0\powershell.exe" $quotedRoot = $InstallRoot.Replace("'", "''") $removalScript = @" `$deadline = (Get-Date).AddMinutes(5) while ((Get-Date) -lt `$deadline -and (Get-Process -Id $WaitForProcessId, $PID -ErrorAction SilentlyContinue)) { Start-Sleep -Milliseconds 200 } foreach (`$name in @('bin', 'current', 'previous')) { `$link = Join-Path '$quotedRoot' `$name `$item = Get-Item -LiteralPath `$link -Force -ErrorAction SilentlyContinue # A junction that cannot be deleted throws, which ends this script before # the removal below: an install root that keeps its junctions still runs. if (`$null -ne `$item -and (`$item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { [System.IO.Directory]::Delete(`$link) } } Remove-Item -LiteralPath '$quotedRoot' -Recurse -Force -ErrorAction SilentlyContinue "@ # -EncodedCommand, because -ArgumentList joins its elements with single # spaces and quotes nothing, which mangles any command it is handed. $encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($removalScript)) # -WorkingDirectory, because a process holds its current directory open: # started from inside the install root -- bin is on PATH, so people do stand # there -- the remover would be unable to delete the tree it is deleting. Start-Process -FilePath $powershell -WorkingDirectory ([Environment]::GetFolderPath("System")) -WindowStyle Hidden -ArgumentList "-NoProfile", "-EncodedCommand", $encoded | Out-Null } function Invoke-WaxUninstall { $binLink = Join-Path $InstallRoot "bin" if ($InstallRoot -eq ([System.IO.Path]::GetFullPath($HOME).TrimEnd("\"))) { Fail "refusing to remove $InstallRoot" } if ($InstallRoot -eq ([System.IO.Path]::GetPathRoot($InstallRoot).TrimEnd("\"))) { Fail "refusing to remove $InstallRoot" } if (-not (Test-Path -LiteralPath (Join-Path $InstallRoot "releases") -PathType Container)) { Fail "$InstallRoot is not an installer-managed Wax install root" } $lockPid = (Get-Content -LiteralPath (Join-Path $InstallRoot ".install-lock\pid") -ErrorAction SilentlyContinue | Select-Object -First 1) if ($lockPid -match '^[1-9][0-9]*$' -and (Get-Process -Id ([int]$lockPid) -ErrorAction SilentlyContinue)) { Fail "a Wax installation is running (process $lockPid); wait for it to finish" } if (-not $Yes) { $answer = Read-Host "Remove the Wax toolchain in $InstallRoot, its PATH entry, and its file associations? [y/N]" if ($answer -notmatch '^(y|yes)$') { Fail "cancelled" } } if (-not $NoPathUpdate) { Remove-WaxPathEntry $binLink } if (-not $NoFileAssociation) { Unregister-WaxRecordingFileType } if ($WaitForProcessId -gt 0) { Start-WaxDeferredRemoval Write-Host "removing $InstallRoot once this wax process exits; delete it by hand if it is still there afterwards" } else { Remove-WaxManagedLinks Remove-Item -LiteralPath $InstallRoot -Recurse -Force Write-Host "removed $InstallRoot" } Write-Host "the editor extension installed by 'wax vscode' was left in place; remove it from your editor" } # Safe to exit from here: no install lock or staging tree exists yet. if ($Uninstall) { Invoke-WaxUninstall exit 0 } # .NET rather than Get-FileHash: that cmdlet lives in a module a stripped or # policy-restricted PowerShell need not have loaded, and an installer that cannot # verify a digest must not be one command away from not running at all. function Get-Sha256([string]$Path) { $sha256 = [Security.Cryptography.SHA256]::Create() try { $stream = [IO.File]::OpenRead($Path) try { return [BitConverter]::ToString($sha256.ComputeHash($stream)).Replace("-", "").ToLowerInvariant() } finally { $stream.Dispose() } } finally { $sha256.Dispose() } } # curl rather than Invoke-WebRequest, and the same arguments install.sh uses. # --max-filesize aborts mid-transfer; checking the file afterwards only notices # once a hostile origin has already written it, which on an unbounded response # means filling the disk. No redirects: the origins were validated above, and # following one would move that decision to the server. function Get-Download([string]$Url, [string]$Output, [int]$MaximumSize, [string]$RequestedRelease = "") { $arguments = @("--fail", "--silent", "--show-error", "--location", "--max-redirs", "0", "--connect-timeout", "15", "--max-time", "900", "--max-filesize", "$MaximumSize", "--retry", "3", "--output", $Output) if (-not $allowFixture) { $arguments += @("--proto", "=https", "--proto-redir", "=https", "--tlsv1.2") } # Windows PowerShell 5.1 turns redirected native stderr into a non-terminating # error record. The script-wide Stop preference would promote curl's useful # diagnostic to a throw before we can classify the failed request below. $previousErrorActionPreference = $ErrorActionPreference try { $ErrorActionPreference = "Continue" & $curl.Source --disable @arguments $Url 2>&1 | ForEach-Object { [Console]::Error.WriteLine($_) } $curlExitCode = $LASTEXITCODE } finally { $ErrorActionPreference = $previousErrorActionPreference } if ($curlExitCode -ne 0) { if ($RequestedRelease -ne "") { Fail "requested Wax version $RequestedRelease is unavailable (release index request failed)" } Fail "download failed: $Url" } $size = (Get-Item -LiteralPath $Output).Length if ($size -le 0 -or $size -gt $MaximumSize) { Fail "download exceeds its size limit: $Url" } } New-Item -ItemType Directory -Path (Join-Path $InstallRoot "releases") -Force | Out-Null Assert-ManagedReleaseLink "current" Assert-ManagedReleaseLink "previous" $binLink = Join-Path $InstallRoot "bin" $binDestination = Test-WaxJunction $binLink if ($null -ne $binDestination -and (Split-Path -Leaf $binDestination) -ne "bin") { Fail "$binLink is not a Wax-managed junction" } $lockPath = Join-Path $InstallRoot ".install-lock" if (Test-Path -LiteralPath $lockPath) { $lockPid = (Get-Content -LiteralPath (Join-Path $lockPath "pid") -ErrorAction SilentlyContinue | Select-Object -First 1) if ($lockPid -match '^[1-9][0-9]*$' -and (Get-Process -Id ([int]$lockPid) -ErrorAction SilentlyContinue)) { Fail "another Wax installation is already running (process $lockPid)" } Remove-Item -LiteralPath $lockPath -Recurse -Force } # $lock is what the cleanup removes, so it names the directory only once this # process owns it. Setting it earlier would make a second installer's refusal -- # or its lost race to create the directory -- delete the lock the first one is # still holding, which is the one thing the lock has to prevent. New-Item -ItemType Directory -Path $lockPath | Out-Null $lock = $lockPath Set-Content -LiteralPath (Join-Path $lock "pid") -Value $PID $staging = Join-Path $InstallRoot (".staging." + [guid]::NewGuid().ToString("N").Substring(0, 8)) New-Item -ItemType Directory -Path $staging | Out-Null $indexUrl = if ($Version -ne "") { "$indexBaseUrl/v$Version/index-v1.txt" } else { "$indexBaseUrl/stable/index-v1.txt" } $indexFile = Join-Path $staging "index-v1.txt" Get-Download $indexUrl $indexFile $MaximumIndexSize $Version $indexText = [IO.File]::ReadAllText($indexFile) if ($indexText -notmatch '^[\x20-\x7e\n]+$' -or -not $indexText.EndsWith("`n")) { Fail "release index is not bounded portable text" } # Exactly one trailing newline is removed, never a run of them: TrimEnd would # accept padding that release-index.mjs and install.sh both reject, so the # byte sequence a signature covers would stop being a function of the record # set. EndsWith above guarantees there is one to remove. $lines = $indexText.Substring(0, $indexText.Length - 1).Split("`n") if ($lines.Count -lt 4 -or $lines.Count -gt (3 + $SupportedTargets.Count)) { Fail "release index has an invalid line count" } if ($lines[0] -ne "wax-release-index-v1") { Fail "release index has an invalid format" } if ($lines[1] -notmatch '^version=(.+)$') { Fail "release index has an invalid version" } $indexVersion = $Matches[1] if (-not (Test-ReleaseVersion $indexVersion)) { Fail "release index has an invalid version" } if ($lines[2] -notmatch '^commit=([0-9a-f]{40})$') { Fail "release index has an invalid commit" } $indexCommit = $Matches[1] if ($Version -ne "" -and $indexVersion -ne $Version) { Fail "release index returned version $indexVersion instead of $Version" } $stableVersionFile = Join-Path $InstallRoot ".stable-version" if ($Version -eq "") { if ($indexVersion -notmatch '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$') { Fail "stable channel returned a prerelease" } if (Test-Path -LiteralPath $stableVersionFile) { $installedStable = (Get-Content -LiteralPath $stableVersionFile | Select-Object -First 1) if ($installedStable -notmatch '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$') { Fail "installed stable version state is invalid" } if ((Compare-ReleaseVersion $indexVersion $installedStable) -lt 0) { Fail "stable channel would downgrade from $installedStable to $indexVersion" } } } # Every record is validated, including the POSIX ones this script cannot # install: a malformed index is rejected rather than skipped past. $selected = $null $previousRank = -1 foreach ($line in $lines[3..($lines.Count - 1)]) { $fields = $line.Split("|") if ($fields.Count -ne 5 -or $fields[0] -notmatch '^target=(.+)$') { Fail "release index has an invalid target record" } $recordTarget = $Matches[1] $rank = $SupportedTargets.IndexOf($recordTarget) if ($rank -lt 0) { Fail "release index has an unsupported target: $recordTarget" } if ($rank -le $previousRank) { Fail "release index has out-of-order target records" } $previousRank = $rank if ($fields[1] -notmatch '^[1-9][0-9]{0,8}$' -or [int64]$fields[1] -gt $MaximumArchiveSize) { Fail "release index has an invalid $recordTarget size" } if ($fields[2] -notmatch '^[0-9a-f]{64}$') { Fail "release index has an invalid $recordTarget SHA-256" } if ($fields[3] -notmatch '^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$') { Fail "release index has an invalid $recordTarget archive root" } if ($fields[4] -ne "$baseUrl/releases/v$indexVersion/$($fields[3]).tar.gz") { Fail "release index has an invalid $recordTarget URL" } if ($recordTarget -eq $target) { $selected = [pscustomobject]@{ Root = $fields[3]; Sha256 = $fields[2]; Size = [int64]$fields[1]; Url = $fields[4] } } } if ($null -eq $selected) { Fail "release index does not contain $target" } $archive = Join-Path $staging "$($selected.Root).tar.gz" Get-Download $selected.Url $archive $MaximumArchiveSize if ((Get-Item -LiteralPath $archive).Length -ne $selected.Size) { Fail "release archive size mismatch" } if ((Get-Sha256 $archive) -ne $selected.Sha256) { Fail "release archive SHA-256 mismatch" } $listing = @(& tar.exe -tzf $archive) if ($LASTEXITCODE -ne 0 -or $listing.Count -eq 0) { Fail "release archive is empty" } if ($listing.Count -gt $MaximumArchiveEntries) { Fail "release archive contains too many entries" } $bundle = $selected.Root foreach ($entry in $listing) { if ($entry -notmatch '^[\x20-\x7e]+$') { Fail "release archive contains a non-portable path" } # Compare before trimming: tar lists the bundle's own directory entry as # "/", and trimming that first turns the one entry every archive # has into a path that fails the prefix test. if (-not $entry.StartsWith("$bundle/") -or $entry.Contains("\")) { Fail "release archive contains an unsafe path" } $components = $entry.TrimEnd("/").Split("/") if ($components -contains ".." -or $components -contains "." -or $components -contains "") { Fail "release archive contains an unsafe path" } } $extract = Join-Path $staging "extract" New-Item -ItemType Directory -Path $extract | Out-Null & tar.exe -xzf $archive -C $extract if ($LASTEXITCODE -ne 0) { Fail "release archive could not be extracted" } $extractedBundle = Join-Path $extract $bundle if (-not (Test-Path -LiteralPath $extractedBundle -PathType Container)) { Fail "release archive has an invalid root" } if (@(Get-ChildItem -LiteralPath $extract -Force).Count -ne 1) { Fail "release archive has an invalid root" } $reparsePoints = @(Get-ChildItem -LiteralPath $extractedBundle -Recurse -Force | Where-Object { ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 }) if ($reparsePoints.Count -ne 0) { Fail "release archive contains a symbolic link" } $manifestFile = Join-Path $extractedBundle "release.json" if (-not (Test-Path -LiteralPath $manifestFile -PathType Leaf)) { Fail "release archive has no release.json" } if (-not (Test-Path -LiteralPath (Join-Path $extractedBundle "bin") -PathType Container)) { Fail "release archive has no bin directory" } $manifest = Get-Content -LiteralPath $manifestFile -Raw | ConvertFrom-Json if ($manifest.version -ne $indexVersion) { Fail "release manifest version does not match" } if ($manifest.target -ne $target) { Fail "release manifest target does not match" } if ($manifest.commit -ne $indexCommit) { Fail "release manifest commit does not match" } if ($manifest.state -ne "release") { Fail "release archive is not a canonical signed release" } $releaseName = "$indexVersion-$target-$($selected.Sha256.Substring(0, 16))" $releaseDirectory = Join-Path (Join-Path $InstallRoot "releases") $releaseName if (Test-Path -LiteralPath $releaseDirectory) { # A directory that already carries this name claims to be these exact # verified bytes. Prove it rather than assume it. if (-not (Test-Path -LiteralPath (Join-Path $releaseDirectory "release.json") -PathType Leaf)) { Fail "existing release directory is invalid: $releaseDirectory" } $installedItems = @(Get-ChildItem -LiteralPath $releaseDirectory -Recurse -Force) if (@($installedItems | Where-Object { ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0 }).Count -ne 0) { Fail "existing release directory contains a symbolic link: $releaseDirectory" } $expectedFiles = @(Get-ChildItem -LiteralPath $extractedBundle -Recurse -File -Force) $installedFiles = @($installedItems | Where-Object { -not $_.PSIsContainer }) if ($expectedFiles.Count -ne $installedFiles.Count) { Fail "existing release directory does not match the verified archive: $releaseDirectory" } foreach ($expected in $expectedFiles) { $relative = $expected.FullName.Substring($extractedBundle.Length + 1) $installed = Join-Path $releaseDirectory $relative if (-not (Test-Path -LiteralPath $installed -PathType Leaf)) { Fail "existing release directory does not match the verified archive: $releaseDirectory" } if ((Get-Sha256 $installed) -ne (Get-Sha256 $expected.FullName)) { Fail "existing release directory does not match the verified archive: $releaseDirectory" } } } else { Move-Item -LiteralPath $extractedBundle -Destination $releaseDirectory } $releaseDirectory = [IO.Path]::GetFullPath($releaseDirectory).TrimEnd("\") $currentLink = Join-Path $InstallRoot "current" $currentDestination = Test-WaxJunction $currentLink if ($null -ne $currentDestination -and $currentDestination -ne $releaseDirectory) { Set-Junction (Join-Path $InstallRoot "previous") $currentDestination } Set-Junction $currentLink $releaseDirectory Set-Junction $binLink (Join-Path $currentLink "bin") if ($Version -eq "") { Set-Content -LiteralPath $stableVersionFile -Value $indexVersion } if (-not $NoFileAssociation) { Register-WaxRecordingFileType } # setx truncates the value it writes at 1024 characters, which silently # destroys a long PATH. Write the registry value directly, preserving its # REG_EXPAND_SZ kind so entries spelled with %USERPROFILE% keep expanding. if ($NoPathUpdate) { Write-Host "add $binLink to PATH" } else { $environmentKey = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey("Environment", $true) try { $currentPath = [string]$environmentKey.GetValue("Path", "", [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) $entries = @($currentPath.Split(";") | Where-Object { $_ -ne "" }) if ($entries -notcontains $binLink) { # A PATH that already spells an entry with %VAR% must stay # REG_EXPAND_SZ; rewriting it as REG_SZ turns those entries into # literal directory names that resolve to nothing. $kind = if ($entries.Count -eq 0 -or $currentPath -like "*%*") { [Microsoft.Win32.RegistryValueKind]::ExpandString } else { $environmentKey.GetValueKind("Path") } $environmentKey.SetValue("Path", (($entries + $binLink) -join ";"), $kind) Write-Host "added $binLink to your user PATH; open a new terminal to pick it up" } } finally { $environmentKey.Dispose() } } Write-Host "installed Wax $indexVersion for $target in $releaseDirectory" Write-Host "run 'wax --version' in a new terminal to verify the installed tools and native compile-and-link path" Write-Host "run 'wax vscode' to install the editor extension" } catch { # Console.Error rather than Write-Error: PowerShell renders an error record # as a wrapped block with CategoryInfo and FullyQualifiedErrorId lines, which # breaks a one-line message across the console width. Users read a mangled # sentence, and anything matching on the output matches nothing. [Console]::Error.WriteLine("wax $($Label): $($_.Exception.Message)") $exitCode = 1 } finally { if ($null -ne $staging) { Remove-Item -LiteralPath $staging -Recurse -Force -ErrorAction SilentlyContinue } if ($null -ne $lock) { Remove-Item -LiteralPath $lock -Recurse -Force -ErrorAction SilentlyContinue } } exit $exitCode