From bdb59e5ccce12a300503f4fe99a2bbd74e90c1e6 Mon Sep 17 00:00:00 2001 From: LunaStev Date: Tue, 20 Oct 2026 00:06:05 +0900 Subject: [PATCH] Fix default std lookup for pinned Wave installations Install the matching bundled std at the legacy per-user lookup path and verify ordinary compiler invocations. Preserve independent recovery backups and shared std locks. Closes #487 Signed-off-by: LunaStev --- frontend/public/install.ps1 | 169 +++++++++++++--- frontend/public/install.sh | 90 +++++++-- frontend/tests/install_latest_test.ps1 | 269 +++++++++++++++++++++---- frontend/tests/install_latest_test.py | 160 ++++++++++++++- 4 files changed, 596 insertions(+), 92 deletions(-) diff --git a/frontend/public/install.ps1 b/frontend/public/install.ps1 index bd8293d..ea30062 100644 --- a/frontend/public/install.ps1 +++ b/frontend/public/install.ps1 @@ -88,13 +88,81 @@ function Add-UserPath($Directory) { } Write-Info 'User PATH configured. Open a new terminal to use Wave.' } +function Get-LegacyStdRoot { + $profileRoot = if (-not [string]::IsNullOrEmpty($env:HOME)) { $env:HOME } else { $env:USERPROFILE } + if ([string]::IsNullOrEmpty($profileRoot)) { throw 'HOME or USERPROFILE is required to locate the compiler standard library.' } + $profilePathRoot = [IO.Path]::GetPathRoot($profileRoot) + if (-not [IO.Path]::IsPathRooted($profileRoot) -or + ([Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT -and $profilePathRoot.Length -lt 3)) { + throw 'HOME or USERPROFILE must be an absolute directory path.' + } + return [IO.Path]::GetFullPath((Join-Path $profileRoot '.wave/lib/wave/std')) +} +function Resolve-InstallerPath($Path, [int]$Depth = 0) { + if ($Depth -gt 32) { throw 'Too many links while resolving installation paths.' } + if ([Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT -and + ($Path.StartsWith('\\?\') -or $Path.StartsWith('\\.\'))) { + throw 'Use ordinary filesystem paths instead of Windows device paths for installation.' + } + $full = [IO.Path]::GetFullPath($Path) + $root = [IO.Path]::GetPathRoot($full) + $resolved = $root + $parts = $full.Substring($root.Length).Split([char[]]'\/', [StringSplitOptions]::RemoveEmptyEntries) + foreach ($part in $parts) { + $candidate = Join-Path $resolved $part + $item = Get-Item -LiteralPath $candidate -Force -ErrorAction SilentlyContinue + if ($null -ne $item -and ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { + $targets = @($item.Target) + if ($targets.Count -ne 1 -or [string]::IsNullOrEmpty($targets[0])) { + throw "Cannot safely resolve installation link: $candidate" + } + $target = $targets[0] + if (-not [IO.Path]::IsPathRooted($target)) { $target = Join-Path (Split-Path -Parent $candidate) $target } + $resolved = Resolve-InstallerPath $target ($Depth + 1) + } else { + $resolved = $candidate + } + } + $trimmed = $resolved.TrimEnd([char[]]'\/') + $resolvedRoot = [IO.Path]::GetPathRoot($resolved) + if ($trimmed -eq $resolvedRoot.TrimEnd([char[]]'\/')) { return $resolvedRoot } + return $trimmed +} +function Assert-SeparateInstallationPaths($InstallDirectory, $StdDirectory) { + $compilerPath = Resolve-InstallerPath $InstallDirectory + $stdPath = Resolve-InstallerPath $StdDirectory + $separator = [IO.Path]::DirectorySeparatorChar + $comparison = [StringComparison]::OrdinalIgnoreCase + if ($compilerPath.Equals($stdPath, $comparison) -or + $compilerPath.StartsWith($stdPath.TrimEnd([char[]]'\/') + $separator, $comparison) -or + $stdPath.StartsWith($compilerPath.TrimEnd([char[]]'\/') + $separator, $comparison)) { + throw 'The compiler installation and legacy std directory must not overlap.' + } +} +function Assert-ManagedStdDirectory($Directory) { + $item = Get-Item -LiteralPath $Directory -Force -ErrorAction SilentlyContinue + if ($null -eq $item) { return } + if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'The legacy std directory must not be a link or junction.' } + if (-not $item.PSIsContainer -or -not (Test-Path -LiteralPath (Join-Path $Directory 'manifest.json') -PathType Leaf)) { + throw "Refusing to replace a standard library not managed by Wave: $Directory" + } +} +function Invoke-InstalledTool($Executable, [string[]]$Arguments) { + $output = & $Executable @Arguments + if ($LASTEXITCODE -ne 0) { throw "Installed tool failed ($LASTEXITCODE): $Executable $($Arguments -join ' ')" } + return $output +} function Test-Installation($Directory, $Target, [bool]$InstallVex, $Work) { $wavec = Join-Path $Directory 'wavec.exe' - & $wavec --version - if ($LASTEXITCODE -ne 0) { throw 'wavec --version failed. Check the Visual C++ runtime.' } - $spec = & $wavec print target-spec --format=json + Invoke-InstalledTool $wavec @('--version') | Write-Host + $spec = Invoke-InstalledTool $wavec @('print', 'target-spec', '--format=json') $actual = ($spec | ConvertFrom-Json).triple - if ($LASTEXITCODE -ne 0 -or "$actual".Trim() -ne $Target) { throw "Compiler host target differs from $Target." } + if ("$actual".Trim() -ne $Target) { throw "Compiler host target differs from $Target." } + $stdPath = Invoke-InstalledTool $wavec @('print', 'std-path') + $expectedStd = Resolve-InstallerPath (Get-LegacyStdRoot) + if (-not (Resolve-InstallerPath ("$stdPath".Trim())).Equals($expectedStd, [StringComparison]::OrdinalIgnoreCase)) { + throw 'Compiler default std path differs from the installed standard library.' + } $source = Join-Path $Work 'install-smoke.wave' [IO.File]::WriteAllText($source, @' import("std::mem::layout")::{size_of}; @@ -105,15 +173,11 @@ fun main() -> i32 { '@) Push-Location $Work try { - & $wavec run $source --std-root (Join-Path $Directory 'std') - if ($LASTEXITCODE -ne 0) { - throw 'Bundled std compile/run check failed. Ensure the Windows SDK and MSVC/UCRT libraries are installed and available (for example, in a Visual Studio developer shell).' - } + Invoke-InstalledTool $wavec @('run', $source) | Write-Host + } catch { + throw "Default std compile/run check failed. Ensure the Windows SDK and MSVC/UCRT libraries are installed and available (for example, in a Visual Studio developer shell). $($_.Exception.Message)" } finally { Pop-Location } - if ($InstallVex) { - & (Join-Path $Directory 'vex.exe') --version - if ($LASTEXITCODE -ne 0) { throw 'vex --version failed.' } - } + if ($InstallVex) { Invoke-InstalledTool (Join-Path $Directory 'vex.exe') @('--version') | Write-Host } } function Install-Wave { if ($Help) { @@ -123,6 +187,7 @@ Usage: .\install.ps1 [-Latest] [-WithVex | -WithoutVex] [-NoModifyPath] Default: install Wave and install Vex when its latest release supports this platform. -WithVex requires Vex; -WithoutVex installs Wave only. WAVE_INSTALL_DIR overrides the dedicated installation directory (%LOCALAPPDATA%\Wave\bin). +The matching standard library also installs under HOME (or USERPROFILE) at .wave\lib\wave\std. Older versions and Nightly: download manually from https://github.com/wavefnd/Wave/releases '@ return @@ -134,16 +199,21 @@ Older versions and Nightly: download manually from https://github.com/wavefnd/Wa } if ($WithVex -and $WithoutVex) { throw 'Conflicting Vex options.' } $target = Get-NativeWindowsTarget - $installDir = if ($env:WAVE_INSTALL_DIR) { [IO.Path]::GetFullPath($env:WAVE_INSTALL_DIR) } else { Join-Path $env:LOCALAPPDATA 'Wave\bin' } + $installDir = if ($env:WAVE_INSTALL_DIR) { [IO.Path]::GetFullPath($env:WAVE_INSTALL_DIR) } else { [IO.Path]::GetFullPath((Join-Path $env:LOCALAPPDATA 'Wave\bin')) } + $stdDir = Get-LegacyStdRoot if ($installDir -match '[;\r\n]') { throw 'Use an installation directory without semicolons or newlines.' } - if ($installDir.TrimEnd('\') -eq [IO.Path]::GetPathRoot($installDir).TrimEnd('\') -or - $installDir.TrimEnd('\') -ieq $HOME.TrimEnd('\')) { throw 'Use a dedicated installation directory.' } - if (Test-Path -LiteralPath $installDir) { - if ((Get-Item -LiteralPath $installDir).Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'The installation directory must not be a link or junction.' } + $installPath = Resolve-InstallerPath $installDir + if ($installDir.TrimEnd([char[]]'\/') -eq [IO.Path]::GetPathRoot($installDir).TrimEnd([char[]]'\/') -or + $installPath.Equals((Resolve-InstallerPath $HOME), [StringComparison]::OrdinalIgnoreCase)) { throw 'Use a dedicated installation directory.' } + Assert-SeparateInstallationPaths $installDir $stdDir + $existingInstall = Get-Item -LiteralPath $installDir -Force -ErrorAction SilentlyContinue + if ($null -ne $existingInstall) { + if ($existingInstall.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'The installation directory must not be a link or junction.' } if (-not (Test-Path -LiteralPath (Join-Path $installDir 'wavec.exe') -PathType Leaf)) { throw "Refusing to replace a directory not managed by Wave: $installDir" } } + Assert-ManagedStdDirectory $stdDir $wave = Get-PinnedWaveRelease $waveName = "wave-$($wave.tag_name)-$target.zip" $waveAsset = Get-ReleaseAsset $wave $waveName @@ -159,16 +229,26 @@ Older versions and Nightly: download manually from https://github.com/wavefnd/Wa } Write-Info "Wave $($wave.tag_name) / $target" Write-Info "Install directory: $installDir" + Write-Info "Compiler default standard library: $stdDir" $parent = Split-Path -Parent $installDir - New-Item -ItemType Directory -Force -Path $parent | Out-Null + $stdParent = Split-Path -Parent $stdDir $lockPath = "$installDir.install-lock" - # CreateNew prevents simultaneous installers from moving the same installation. - $lock = [IO.File]::Open($lockPath, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) + $stdLockPath = "$stdDir.install-lock" + $lock = $null; $stdLock = $null $temp = Join-Path $parent ('.wave-install-' + [Guid]::NewGuid().ToString('N')) + $stdTemp = Join-Path $stdParent ('.wave-std-' + [Guid]::NewGuid().ToString('N')) $stage = Join-Path $temp 'stage'; $backup = Join-Path $temp 'previous' - $activated = $false; $committed = $false; $keepBackup = $false + $stdStage = Join-Path $stdTemp 'stage'; $stdBackup = Join-Path $stdTemp 'previous' + $activated = $false; $stdActivated = $false; $committed = $false + $keepBackup = $false; $keepStdBackup = $false try { - New-Item -ItemType Directory -Path $temp | Out-Null + New-Item -ItemType Directory -Force -Path $parent, $stdParent | Out-Null + # Different custom compiler directories still share the compiler's per-user std. + $lock = [IO.File]::Open($lockPath, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) + $stdLock = [IO.File]::Open($stdLockPath, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None) + Assert-SeparateInstallationPaths $installDir $stdDir + Assert-ManagedStdDirectory $stdDir + New-Item -ItemType Directory -Path $temp, $stdTemp | Out-Null Write-Info '[1/4] Downloading and verifying packages' $waveZip = Join-Path $temp 'wave.zip' Save-Download "https://github.com/wavefnd/Wave/releases/download/$($wave.tag_name)/$waveName" $waveZip @@ -187,6 +267,8 @@ Older versions and Nightly: download manually from https://github.com/wavefnd/Wa } New-Item -ItemType Directory -Path $stage | Out-Null Get-ChildItem -LiteralPath $package -Force | Copy-Item -Destination $stage -Recurse -Force + # Stage beside the legacy root, so activation and restore stay on its volume. + Copy-Item -LiteralPath (Join-Path $stage 'std') -Destination $stdStage -Recurse if ($vexAsset) { $vexRoot = Join-Path $temp 'vex' Expand-Archive -LiteralPath $vexZip -DestinationPath $vexRoot @@ -201,9 +283,12 @@ Older versions and Nightly: download manually from https://github.com/wavefnd/Wa } Write-Info '[3/4] Activating installation' if (Test-Path -LiteralPath $installDir) { Move-Item -LiteralPath $installDir -Destination $backup } - Move-Item -LiteralPath $stage -Destination $installDir + if (Test-Path -LiteralPath $stdDir) { Move-Item -LiteralPath $stdDir -Destination $stdBackup } + $stdActivated = $true + Move-Item -LiteralPath $stdStage -Destination $stdDir $activated = $true - Write-Info '[4/4] Checking compiler, bundled std and runtime' + Move-Item -LiteralPath $stage -Destination $installDir + Write-Info '[4/4] Checking compiler, default std and runtime' Test-Installation $installDir $target ($null -ne $vexAsset) $temp $committed = $true if (-not $NoModifyPath) { @@ -213,20 +298,44 @@ Older versions and Nightly: download manually from https://github.com/wavefnd/Wa Write-Info "Installed Wave $($wave.tag_name)." if ($vexAsset) { Write-Info "Installed Vex $($vex.tag_name)." } } finally { + $restoreErrors = @() try { if (-not $committed) { - if ($activated) { Remove-Item -LiteralPath $installDir -Recurse -Force } - if (Test-Path -LiteralPath $backup) { - try { Move-Item -LiteralPath $backup -Destination $installDir } - catch { $keepBackup = $true; throw "Restore failed. Previous installation retained at $backup. $($_.Exception.Message)" } + # Attempt both restores even when one destination is inaccessible. + try { + if ($activated -and (Test-Path -LiteralPath $installDir)) { Remove-Item -LiteralPath $installDir -Recurse -Force } + if (Test-Path -LiteralPath $backup) { Move-Item -LiteralPath $backup -Destination $installDir } + } catch { + $keepBackup = Test-Path -LiteralPath $backup + $restoreErrors += "Previous compiler retained at $backup. $($_.Exception.Message)" + } + try { + if ($stdActivated -and (Test-Path -LiteralPath $stdDir)) { Remove-Item -LiteralPath $stdDir -Recurse -Force } + if (Test-Path -LiteralPath $stdBackup) { Move-Item -LiteralPath $stdBackup -Destination $stdDir } + } catch { + $keepStdBackup = Test-Path -LiteralPath $stdBackup + $restoreErrors += "Previous standard library retained at $stdBackup. $($_.Exception.Message)" } } if (-not $keepBackup -and (Test-Path -LiteralPath $temp)) { Remove-Item -LiteralPath $temp -Recurse -Force } + if (-not $keepStdBackup -and (Test-Path -LiteralPath $stdTemp)) { Remove-Item -LiteralPath $stdTemp -Recurse -Force } + if ($restoreErrors.Count) { throw "Restore failed. $($restoreErrors -join ' ')" } } finally { - $lock.Dispose() - Remove-Item -LiteralPath $lockPath -Force + # A failed CreateNew never gives this invocation ownership of that lock. + try { + if ($null -ne $stdLock) { + $stdLock.Dispose() + Remove-Item -LiteralPath $stdLockPath -Force + } + } finally { + if ($null -ne $lock) { + $lock.Dispose() + Remove-Item -LiteralPath $lockPath -Force + } + } } } + } # Dot-sourcing exposes helpers without installing anything. if ($MyInvocation.InvocationName -ne '.') { diff --git a/frontend/public/install.sh b/frontend/public/install.sh index 39d01a9..a829940 100755 --- a/frontend/public/install.sh +++ b/frontend/public/install.sh @@ -16,6 +16,7 @@ Usage: bash install.sh [latest] [--with-vex | --without-vex] [--no-modify-path] --without-vex Install Wave only. --no-modify-path Leave shell configuration unchanged. WAVE_INSTALL_DIR overrides the dedicated installation directory (~/.wave/bin). + Matching std is also installed at ~/.wave/lib/wave/std for the pinned compiler. Older versions and Nightly: download manually from https://github.com/wavefnd/Wave/releases HELP } @@ -116,8 +117,8 @@ fun main() -> i32 { return 0; } WAVE - # Keep output outside the user's working directory and ignore ambient std overrides. - (cd "$work" && "$directory/wavec" run install-smoke.wave --std-root "$directory/std") || return 1 + # Exercise the same default std lookup used by an ordinary compiler invocation. + (cd "$work" && "$directory/wavec" run install-smoke.wave) || return 1 if [[ "$with_vex" == true ]]; then "$directory/vex" --version || return 1; fi } main() { @@ -139,6 +140,7 @@ main() { os="$(uname -s)"; arch="$(uname -m)" if [[ "$os" == Darwin && "$arch" == x86_64 ]] && [[ "$(sysctl -in sysctl.proc_translated 2>/dev/null || true)" == 1 ]]; then arch=arm64; fi resolve_platform "$os" "$arch" + [[ "${HOME:-}" == /* ]] || fail 'HOME must be an absolute path for the pinned compiler standard library.' INSTALL_DIR="${WAVE_INSTALL_DIR:-$HOME/.wave/bin}" INSTALL_DIR="${INSTALL_DIR%/}" [[ "$INSTALL_DIR" != *:* && "$INSTALL_DIR" == /* && "$INSTALL_DIR" != / && "$INSTALL_DIR" != "$HOME" && "$INSTALL_DIR" != *$'\n'* && "$INSTALL_DIR" != *$'\r'* ]] || fail 'Use an absolute, dedicated installation directory.' @@ -159,15 +161,50 @@ main() { fi info "Wave $wave_version / $WAVE_TARGET" info "Install directory: $INSTALL_DIR" - local parent - parent="$(dirname "$INSTALL_DIR")"; mkdir -p "$parent" + local parent std_parent std_lock compiler_compare std_compare + STD_DIR="$HOME/.wave/lib/wave/std" + [[ ! -L "$STD_DIR" ]] || fail 'The std installation directory must not be a symbolic link.' + if [[ -e "$STD_DIR" && ! -f "$STD_DIR/manifest.json" ]]; then + fail "Refusing to replace a directory not managed by Wave std: $STD_DIR" + fi + parent="$(dirname "$INSTALL_DIR")" + std_parent="$(dirname "$STD_DIR")" + mkdir -p "$parent" "$std_parent" + parent="$(cd "$parent" && pwd -P)" + std_parent="$(cd "$std_parent" && pwd -P)" + INSTALL_DIR="$parent/${INSTALL_DIR##*/}" + STD_DIR="$std_parent/std" + compiler_compare="$INSTALL_DIR/" + std_compare="$STD_DIR/" + # macOS volumes may ignore case, including a not-yet-created final component. + if [[ "$os" == Darwin ]]; then + compiler_compare="$(printf '%s' "$compiler_compare" | LC_ALL=C tr '[:upper:]' '[:lower:]')" + std_compare="$(printf '%s' "$std_compare" | LC_ALL=C tr '[:upper:]' '[:lower:]')" + fi + case "$compiler_compare" in + "$std_compare"*) fail 'Compiler and std installation directories must not overlap.' ;; + esac + case "$std_compare" in + "$compiler_compare"*) fail 'Compiler and std installation directories must not overlap.' ;; + esac + case "$INSTALL_DIR" in + /bin|/sbin|/usr/bin|/usr/sbin|/usr/local/bin|/usr/local/sbin) + fail 'Do not replace a shared system binary directory.' ;; + esac + info "Std directory: $STD_DIR" LOCK_DIR="$INSTALL_DIR.install-lock" mkdir "$LOCK_DIR" 2>/dev/null || fail "Another install may be running ($LOCK_DIR). If it was interrupted, inspect the directory before retrying." - TMP_DIR=''; ACTIVATING=false; COMMITTED=false + TMP_DIR=''; STD_TMP_DIR=''; STD_LOCK_DIR='' + ACTIVATING=false; STD_ACTIVATING=false; COMMITTED=false trap cleanup EXIT trap 'exit 130' INT trap 'exit 143' TERM + # Different custom compiler directories still share the pinned compiler's std. + std_lock="$STD_DIR.install-lock" + mkdir "$std_lock" 2>/dev/null || fail "Another std install may be running ($std_lock). If it was interrupted, inspect the directory before retrying." + STD_LOCK_DIR="$std_lock" TMP_DIR="$(mktemp -d "$parent/.wave-install.XXXXXX")" + STD_TMP_DIR="$(mktemp -d "$std_parent/.wave-std.XXXXXX")" info '[1/4] Downloading and verifying packages' fetch "https://github.com/wavefnd/Wave/releases/download/$wave_version/$wave_name" -o "$TMP_DIR/wave.tar.gz" verify_hash "$TMP_DIR/wave.tar.gz" "$(jq -r '.digest | sub("^sha256:"; "")' <<< "$wave_asset")" @@ -181,6 +218,9 @@ main() { local package="$TMP_DIR/wave/${wave_name%.tar.gz}" [[ -f "$package/wavec" && -d "$package/llvm/bin" && -f "$package/std/manifest.json" ]] || fail 'Wave package is missing compiler, LLVM or bundled std.' cp -R "$package"/. "$TMP_DIR/stage/" + # Stage std beside its destination so activation and recovery stay on one filesystem. + mkdir "$STD_TMP_DIR/stage" + cp -R "$package/std"/. "$STD_TMP_DIR/stage/" if [[ "$install_vex" == true ]]; then mkdir "$TMP_DIR/vex"; tar -xzf "$TMP_DIR/vex.tar.gz" -C "$TMP_DIR/vex" package="$TMP_DIR/vex/${vex_name%.tar.gz}" @@ -196,7 +236,10 @@ main() { if [[ -d "$INSTALL_DIR" ]]; then mv "$INSTALL_DIR" "$TMP_DIR/previous"; fi ACTIVATING=true mv "$TMP_DIR/stage" "$INSTALL_DIR" - info '[4/4] Checking compiler, bundled std and runtime' + if [[ -d "$STD_DIR" ]]; then mv "$STD_DIR" "$STD_TMP_DIR/previous"; fi + STD_ACTIVATING=true + mv "$STD_TMP_DIR/stage" "$STD_DIR" + info '[4/4] Checking compiler, default std and runtime' verify_installation "$INSTALL_DIR" "$WAVE_TARGET" "$install_vex" "$TMP_DIR" \ || fail 'Installation check failed; restoring the previous installation. Check system prerequisites (glibc/system libraries, Apple Command Line Tools, or a compatible FreeBSD base).' COMMITTED=true @@ -206,20 +249,35 @@ main() { info "Installed Wave $wave_version." if [[ "$install_vex" == true ]]; then info "Installed Vex $vex_version."; fi } +restore_tree() { + local activated="$1" directory="$2" temporary="$3" + if [[ "$activated" == true ]] && ! rm -rf "$directory"; then + printf '[error] Cannot remove failed installation: %s. Recovery data retained at %s\n' "$directory" "$temporary" >&2 + return 1 + fi + if [[ -n "$temporary" && -d "$temporary/previous" ]]; then + if [[ -e "$directory" || -L "$directory" ]] || ! mv "$temporary/previous" "$directory"; then + printf '[error] Restore failed. Previous installation retained at %s/previous\n' "$temporary" >&2 + return 1 + fi + fi +} cleanup() { - local status=$? + local status=$? keep_compiler=false keep_std=false trap - EXIT - if [[ "$ACTIVATING" == true && "$COMMITTED" != true ]]; then - rm -rf "$INSTALL_DIR" + if [[ "$COMMITTED" != true ]]; then + # Try both recoveries even if one fails; never discard an unrestored backup. + restore_tree "$STD_ACTIVATING" "$STD_DIR" "$STD_TMP_DIR" || keep_std=true + restore_tree "$ACTIVATING" "$INSTALL_DIR" "$TMP_DIR" || keep_compiler=true fi - if [[ "$COMMITTED" != true && -n "$TMP_DIR" && -d "$TMP_DIR/previous" ]]; then - if ! mv "$TMP_DIR/previous" "$INSTALL_DIR"; then - printf '[error] Restore failed. Previous installation retained at %s/previous\n' "$TMP_DIR" >&2 - rmdir "$LOCK_DIR" 2>/dev/null || true - exit 1 - fi + if [[ "$keep_std" == true || "$keep_compiler" == true ]]; then status=1; fi + if [[ -n "$STD_TMP_DIR" && "$keep_std" == false ]]; then + rm -rf "$STD_TMP_DIR" || status=1 + fi + if [[ -n "$TMP_DIR" && "$keep_compiler" == false ]]; then + rm -rf "$TMP_DIR" || status=1 fi - if [[ -n "$TMP_DIR" ]]; then rm -rf "$TMP_DIR"; fi + if [[ -n "$STD_LOCK_DIR" ]]; then rmdir "$STD_LOCK_DIR" 2>/dev/null || true; fi rmdir "$LOCK_DIR" 2>/dev/null || true exit "$status" } diff --git a/frontend/tests/install_latest_test.ps1 b/frontend/tests/install_latest_test.ps1 index e249cae..c3030ef 100644 --- a/frontend/tests/install_latest_test.ps1 +++ b/frontend/tests/install_latest_test.ps1 @@ -4,7 +4,11 @@ $tokens = $null; $errors = $null $ast = [Management.Automation.Language.Parser]::ParseFile((Resolve-Path $installer), [ref]$tokens, [ref]$errors) if ($errors.Count) { throw $errors } . $installer -function Assert($Condition, $Message) { if (-not $Condition) { throw $Message } } +$script:Assertions = 0 +function Assert($Condition, $Message) { + $script:Assertions += 1 + if (-not $Condition) { throw $Message } +} function Must-Fail([scriptblock]$Action, $Pattern='*') { $failed = $false try { & $Action } catch { $failed = $_.Exception.Message -like $Pattern } @@ -34,9 +38,18 @@ foreach ($wrong in @(@{tag_name='nightly';draft=$false}, @{tag_name='v0.2.1-pre- } $temp = Join-Path ([IO.Path]::GetTempPath()) ('wave installer tests ' + [Guid]::NewGuid().ToString('N')) New-Item -ItemType Directory -Path $temp | Out-Null -$previousDir = $env:WAVE_INSTALL_DIR +$previousEnvironment = @{} +foreach ($name in @('HOME','USERPROFILE','LOCALAPPDATA','WAVE_INSTALL_DIR','WAVE_VERSION','VEX_VERSION')) { + $previousEnvironment[$name] = [Environment]::GetEnvironmentVariable($name, 'Process') +} try { - $path = Join-Path $temp 'data'; [IO.File]::WriteAllText($path, 'fixture') + $env:HOME = Join-Path $temp 'home' + $env:USERPROFILE = Join-Path $temp 'profile' + $env:LOCALAPPDATA = Join-Path $temp 'local app data' + $env:WAVE_VERSION = $null + $env:VEX_VERSION = $null + $path = Join-Path $temp 'data' + [IO.File]::WriteAllText($path, 'fixture') $hash = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash Assert-Hash $path $hash.ToLowerInvariant() Must-Fail { Assert-Hash $path ('0'*64) } @@ -44,70 +57,248 @@ try { Assert ((Get-ReleaseAsset $release 'wave.zip').name -eq 'wave.zip') 'asset selection' Assert ($null -eq (Get-ReleaseAsset $release 'absent' $true)) 'optional asset' foreach ($bad in @($null,'sha256:bad',"sha512:$hash")) { - $release.assets[0].digest=$bad + $release.assets[0].digest = $bad Must-Fail { Get-ReleaseAsset $release 'wave.zip' } } - $release.assets[0].digest="sha256:$hash"; $release.assets += $release.assets[0] + $release.assets[0].digest = "sha256:$hash" + $release.assets += $release.assets[0] Must-Fail { Get-ReleaseAsset $release 'wave.zip' } - # Exercise the real preparation/swap/rollback against local ZIPs. - # Only network, host detection, process execution and user PATH writes are mocked. + # Use local archives and isolated profile paths. Preparation, path checks, + # swaps, rollback, and the default-lookup smoke routine remain real. function Get-NativeWindowsTarget { return $script:Target } function Save-Download($Uri, $Path) { if ($script:Failure -eq 'download') { throw 'download fixture failure' } Copy-Item -LiteralPath $script:Archives[$Uri.Split('/')[-1]] -Destination $Path } - function Test-Installation($Directory, $Target, $InstallVex, $Work) { - Assert (Test-Path -LiteralPath (Join-Path $Directory 'std/manifest.json')) 'bundled std absent' - Assert ((Get-Content -LiteralPath (Join-Path $Directory 'wavec.exe')) -eq 'new compiler') 'new compiler absent' - if ($script:Failure -eq 'smoke') { throw 'smoke fixture failure' } + function Invoke-InstalledTool($Executable, [string[]]$Arguments) { + $script:ToolInvocations += ,@($Executable, $Arguments) + if ($Arguments[0] -eq '--version') { return 'fixture version' } + if ($Arguments[0] -eq 'print' -and $Arguments[1] -eq 'target-spec') { + return ('{"triple":"' + $script:Target + '"}') + } + if ($Arguments[0] -eq 'print' -and $Arguments[1] -eq 'std-path') { + if ($script:Failure -eq 'std-path') { return (Join-Path $temp 'wrong std') } + return Get-LegacyStdRoot + } + Assert ($Arguments[0] -eq 'run' -and $Arguments.Count -eq 2) 'smoke must use default std without an override' + Assert ((Get-Content -LiteralPath (Join-Path (Get-LegacyStdRoot) 'manifest.json')) -eq '{"fixture":"packaged std"}') 'default std does not match package' + Assert ((Get-Content -LiteralPath (Join-Path (Split-Path -Parent $Executable) 'std/manifest.json')) -eq '{"fixture":"packaged std"}') 'bundled std absent' + Assert ((Get-Content -LiteralPath $Executable) -eq 'new compiler') 'new compiler absent' + if ($script:Failure -in @('smoke','restore-compiler','restore-std')) { throw 'smoke fixture failure' } + } + function Move-Item($LiteralPath, $Destination) { + $leaf = Split-Path -Leaf $LiteralPath + if ($leaf -eq 'stage') { + if ($Destination -eq (Get-LegacyStdRoot)) { + Assert ((Split-Path -Parent (Split-Path -Parent $LiteralPath)) -eq (Split-Path -Parent (Get-LegacyStdRoot))) 'std staged on the wrong filesystem' + if ($script:Failure -eq 'activate-std') { throw 'std activation fixture failure' } + } elseif ($Destination -eq $env:WAVE_INSTALL_DIR -and $script:Failure -eq 'activate-compiler') { + throw 'compiler activation fixture failure' + } + } + if ($leaf -eq 'previous') { + if (($script:Failure -eq 'restore-std' -and $Destination -eq (Get-LegacyStdRoot)) -or + ($script:Failure -eq 'restore-compiler' -and $Destination -eq $env:WAVE_INSTALL_DIR)) { + $script:RetainedBackup = $LiteralPath + throw 'restore fixture failure' + } + } + Microsoft.PowerShell.Management\Move-Item -LiteralPath $LiteralPath -Destination $Destination + if ($leaf -eq 'stage' -and + (($Destination -eq (Get-LegacyStdRoot) -and $script:Failure -eq 'activate-std-after') -or + ($Destination -eq $env:WAVE_INSTALL_DIR -and $script:Failure -eq 'activate-compiler-after'))) { + throw 'post-move activation fixture failure' + } } function Add-UserPath($Directory) { throw 'fixture PATH failure' } function Get-LatestRelease($Repository) { return $script:Releases[$Repository] } function Get-PinnedWaveRelease { return $script:Releases['wavefnd/Wave'] } - $script:Archives=@{}; $script:Releases=@{} + function Set-TestLocations($Name) { + $caseRoot = Join-Path $temp $Name + $env:HOME = Join-Path $caseRoot 'preferred home' + $env:USERPROFILE = Join-Path $caseRoot 'fallback profile' + $env:LOCALAPPDATA = Join-Path $caseRoot 'local app data' + $env:WAVE_INSTALL_DIR = Join-Path $caseRoot 'custom install/bin' + $script:ToolInvocations = @() + $script:RetainedBackup = $null + return $caseRoot + } + function Seed-PreviousInstallation { + New-Item -ItemType Directory -Force -Path $env:WAVE_INSTALL_DIR, (Get-LegacyStdRoot) | Out-Null + [IO.File]::WriteAllText((Join-Path $env:WAVE_INSTALL_DIR 'wavec.exe'), 'previous compiler') + [IO.File]::WriteAllText((Join-Path $env:WAVE_INSTALL_DIR 'compiler-sentinel'), 'previous compiler sentinel') + [IO.File]::WriteAllText((Join-Path (Get-LegacyStdRoot) 'manifest.json'), '{"fixture":"previous std"}') + [IO.File]::WriteAllText((Join-Path (Get-LegacyStdRoot) 'std-sentinel'), 'previous std sentinel') + } + function Assert-PreviousCompiler { + Assert ((Get-Content -LiteralPath (Join-Path $env:WAVE_INSTALL_DIR 'wavec.exe')) -eq 'previous compiler') 'compiler rollback failed' + Assert (Test-Path -LiteralPath (Join-Path $env:WAVE_INSTALL_DIR 'compiler-sentinel')) 'compiler sentinel lost' + } + function Assert-PreviousStd { + Assert ((Get-Content -LiteralPath (Join-Path (Get-LegacyStdRoot) 'manifest.json')) -eq '{"fixture":"previous std"}') 'std rollback failed' + Assert (Test-Path -LiteralPath (Join-Path (Get-LegacyStdRoot) 'std-sentinel')) 'std sentinel lost' + } + function Assert-NoLocks { + Assert (-not (Test-Path -LiteralPath "$env:WAVE_INSTALL_DIR.install-lock")) 'compiler lock leaked' + Assert (-not (Test-Path -LiteralPath "$(Get-LegacyStdRoot).install-lock")) 'std lock leaked' + } + $script:Archives = @{}; $script:Releases = @{} foreach ($target in @('x86_64-pc-windows-msvc','aarch64-pc-windows-msvc')) { - $script:Target=$target + $script:Target = $target foreach ($repo in @('Wave','Vex')) { $tag = if ($repo -eq 'Wave') { 'v0.2.1-pre-beta' } else { 'v1.0.0' } - $name="$($repo.ToLower())-$tag-$target" - $folder=Join-Path $temp $name + $name = "$($repo.ToLower())-$tag-$target" + $folder = Join-Path $temp $name New-Item -ItemType Directory -Force -Path (Join-Path $folder 'llvm/bin'),(Join-Path $folder 'std') | Out-Null - [IO.File]::WriteAllText((Join-Path $folder 'std/manifest.json'),'{}') - [IO.File]::WriteAllText((Join-Path $folder 'wavec.exe'),'new compiler') - if ($repo -eq 'Vex') { [IO.File]::WriteAllText((Join-Path $folder 'vex.exe'),'new vex') } - $zip=Join-Path $temp "$name.zip" + [IO.File]::WriteAllText((Join-Path $folder 'std/manifest.json'), '{"fixture":"packaged std"}') + [IO.File]::WriteAllText((Join-Path $folder 'wavec.exe'), 'new compiler') + if ($repo -eq 'Vex') { [IO.File]::WriteAllText((Join-Path $folder 'vex.exe'), 'new vex') } + $zip = Join-Path $temp "$name.zip" Compress-Archive -LiteralPath $folder -DestinationPath $zip - $script:Archives["$name.zip"]=$zip - $script:Releases["wavefnd/$repo"]=@{tag_name=$tag;assets=@(@{name="$name.zip";state='uploaded';digest=('sha256:'+(Get-FileHash -LiteralPath $zip -Algorithm SHA256).Hash)})} + $script:Archives["$name.zip"] = $zip + $script:Releases["wavefnd/$repo"] = @{tag_name=$tag;assets=@(@{name="$name.zip";state='uploaded';digest=('sha256:'+(Get-FileHash -LiteralPath $zip -Algorithm SHA256).Hash)})} } - $env:WAVE_INSTALL_DIR=Join-Path $temp "install $target/bin" - foreach ($failure in @('download','digest','smoke','none')) { - $script:Failure=$failure - New-Item -ItemType Directory -Force -Path $env:WAVE_INSTALL_DIR | Out-Null - [IO.File]::WriteAllText((Join-Path $env:WAVE_INSTALL_DIR 'wavec.exe'),'previous compiler') - $saved=$script:Releases['wavefnd/Wave'].assets[0].digest - if ($failure -eq 'digest') { $script:Releases['wavefnd/Wave'].assets[0].digest='sha256:'+('0'*64) } + foreach ($failure in @('download','digest','smoke','std-path','activate-std','activate-compiler','activate-std-after','activate-compiler-after','restore-std','restore-compiler','none')) { + $caseRoot = Set-TestLocations "$target-$failure" + $script:Failure = $failure + Seed-PreviousInstallation + $saved = $script:Releases['wavefnd/Wave'].assets[0].digest + if ($failure -eq 'digest') { $script:Releases['wavefnd/Wave'].assets[0].digest = 'sha256:'+('0'*64) } if ($failure -eq 'none') { Install-Wave - Assert ((Get-Content (Join-Path $env:WAVE_INSTALL_DIR 'wavec.exe')) -eq 'new compiler') 'activation failed' + Assert ((Get-Content -LiteralPath (Join-Path $env:WAVE_INSTALL_DIR 'wavec.exe')) -eq 'new compiler') 'activation failed' + Assert ((Get-Content -LiteralPath (Join-Path (Get-LegacyStdRoot) 'manifest.json')) -eq '{"fixture":"packaged std"}') 'std activation failed' + Assert (-not (Test-Path -LiteralPath (Join-Path $env:USERPROFILE '.wave/lib/wave/std'))) 'USERPROFILE overrode nonempty HOME' } else { Must-Fail { Install-Wave } - Assert ((Get-Content (Join-Path $env:WAVE_INSTALL_DIR 'wavec.exe')) -eq 'previous compiler') 'rollback failed' + if ($failure -eq 'restore-compiler') { + Assert (Test-Path -LiteralPath (Join-Path $script:RetainedBackup 'compiler-sentinel')) 'compiler backup lost after restore failure' + } else { Assert-PreviousCompiler } + if ($failure -eq 'restore-std') { + Assert (Test-Path -LiteralPath (Join-Path $script:RetainedBackup 'std-sentinel')) 'std backup lost after restore failure' + } else { Assert-PreviousStd } } - Assert (-not (Test-Path -LiteralPath "$env:WAVE_INSTALL_DIR.install-lock")) 'lock leaked' - $script:Releases['wavefnd/Wave'].assets[0].digest=$saved + Assert-NoLocks + $script:Releases['wavefnd/Wave'].assets[0].digest = $saved } - $script:Releases['wavefnd/Vex'].assets=@() - $WithVex=$true; Must-Fail { Install-Wave } '*Latest Vex has no package*'; $WithVex=$false - $NoModifyPath=$true; Install-Wave - Assert (-not (Test-Path -LiteralPath (Join-Path $env:WAVE_INSTALL_DIR 'vex.exe'))) 'Vex should be absent' + $null = Set-TestLocations "$target-fresh" + $script:Failure = 'none' + Install-Wave + Assert (Test-Path -LiteralPath (Join-Path (Get-LegacyStdRoot) 'manifest.json')) 'fresh std absent' + Assert-NoLocks + $null = Set-TestLocations "$target-fresh-smoke-failure" + $script:Failure = 'smoke' + Must-Fail { Install-Wave } + Assert (-not (Test-Path -LiteralPath $env:WAVE_INSTALL_DIR)) 'fresh compiler left after smoke failure' + Assert (-not (Test-Path -LiteralPath (Get-LegacyStdRoot))) 'fresh std left after smoke failure' + Assert-NoLocks + $script:Failure = 'none' + $null = Set-TestLocations "$target-profile-fallback" + $env:HOME = $null + $env:WAVE_INSTALL_DIR = $null + Install-Wave + Assert (Test-Path -LiteralPath (Join-Path $env:USERPROFILE '.wave/lib/wave/std/manifest.json')) 'USERPROFILE fallback absent' + Assert (Test-Path -LiteralPath (Join-Path $env:LOCALAPPDATA 'Wave/bin/wavec.exe')) 'default compiler path absent' + $script:Releases['wavefnd/Vex'].assets = @() + $WithVex = $true + Must-Fail { Install-Wave } '*Latest Vex has no package*' + $WithVex = $false + $NoModifyPath = $true + Install-Wave + Assert (-not (Test-Path -LiteralPath (Join-Path $env:LOCALAPPDATA 'Wave/bin/vex.exe'))) 'Vex should be absent' foreach ($old in @('1.0.0','nightly')) { - $Version=$old; Must-Fail { Install-Wave } '*manually*'; $Version='' + $Version = $old + Must-Fail { Install-Wave } '*manually*' + $Version = '' } } + + $null = Set-TestLocations 'shared-std-lock' + Seed-PreviousInstallation + $stdLockPath = "$(Get-LegacyStdRoot).install-lock" + [IO.File]::WriteAllText($stdLockPath, 'another installer') + Must-Fail { Install-Wave } + Assert ((Get-Content -LiteralPath $stdLockPath) -eq 'another installer') 'foreign std lock removed' + Assert (-not (Test-Path -LiteralPath "$env:WAVE_INSTALL_DIR.install-lock")) 'own compiler lock leaked after std lock conflict' + Assert-PreviousCompiler + Assert-PreviousStd + $env:WAVE_INSTALL_DIR = Join-Path $temp 'another custom bin' + Must-Fail { Install-Wave } + Assert ((Get-Content -LiteralPath $stdLockPath) -eq 'another installer') 'custom root bypassed shared std lock' + Remove-Item -LiteralPath $stdLockPath + $null = Set-TestLocations 'compiler-lock' + Seed-PreviousInstallation + [IO.File]::WriteAllText("$env:WAVE_INSTALL_DIR.install-lock", 'another compiler installer') + Must-Fail { Install-Wave } + Assert ((Get-Content -LiteralPath "$env:WAVE_INSTALL_DIR.install-lock") -eq 'another compiler installer') 'foreign compiler lock removed' + Assert (-not (Test-Path -LiteralPath "$(Get-LegacyStdRoot).install-lock")) 'std lock acquired before compiler lock conflict' + Assert-PreviousCompiler + Assert-PreviousStd + + foreach ($kind in @('directory','file')) { + $null = Set-TestLocations "unmanaged-std-$kind" + $stdDir = Get-LegacyStdRoot + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $stdDir) | Out-Null + if ($kind -eq 'directory') { + New-Item -ItemType Directory -Path $stdDir | Out-Null + [IO.File]::WriteAllText((Join-Path $stdDir 'sentinel'), 'unmanaged') + } else { [IO.File]::WriteAllText($stdDir, 'unmanaged') } + Must-Fail { Install-Wave } '*not managed by Wave*' + Assert-NoLocks + } + $null = Set-TestLocations 'overlap' + $stdDir = Get-LegacyStdRoot + foreach ($unsafe in @($stdDir, (Join-Path $stdDir 'bin'), (Split-Path -Parent $stdDir), (Join-Path $stdDir '../std/bin'))) { + $env:WAVE_INSTALL_DIR = $unsafe + Must-Fail { Install-Wave } '*must not overlap*' + } + $caseRoot = Set-TestLocations 'link-overlap' + $stdParent = Split-Path -Parent (Get-LegacyStdRoot) + New-Item -ItemType Directory -Force -Path $stdParent | Out-Null + $aliasPath = Join-Path $caseRoot 'alias' + $linkType = if ([Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT) { 'Junction' } else { 'SymbolicLink' } + New-Item -ItemType $linkType -Path $aliasPath -Target $stdParent | Out-Null + $env:WAVE_INSTALL_DIR = Join-Path $aliasPath 'std/bin' + Must-Fail { Install-Wave } '*must not overlap*' + $caseRoot = Set-TestLocations 'compiler-link' + $targetDir = Join-Path $caseRoot 'compiler link target' + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $env:WAVE_INSTALL_DIR), $targetDir | Out-Null + New-Item -ItemType $linkType -Path $env:WAVE_INSTALL_DIR -Target $targetDir | Out-Null + Must-Fail { Install-Wave } '*link or junction*' + Remove-Item -LiteralPath $targetDir + Must-Fail { Install-Wave } '*link or junction*' + $caseRoot = Set-TestLocations 'std-link' + $stdDir = Get-LegacyStdRoot + $targetDir = Join-Path $caseRoot 'separate target' + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $stdDir), $targetDir | Out-Null + [IO.File]::WriteAllText((Join-Path $targetDir 'manifest.json'), '{}') + [IO.File]::WriteAllText((Join-Path $targetDir 'sentinel'), 'target preserved') + New-Item -ItemType $linkType -Path $stdDir -Target $targetDir | Out-Null + Must-Fail { Install-Wave } '*link or junction*' + Assert ((Get-Content -LiteralPath (Join-Path $targetDir 'sentinel')) -eq 'target preserved') 'std link target modified' + $env:HOME = 'relative-home' + Must-Fail { Install-Wave } '*must be an absolute*' + $env:HOME = $null + $env:USERPROFILE = 'relative-profile' + Must-Fail { Install-Wave } '*must be an absolute*' + if ([Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT) { + foreach ($relativeProfile in @('C:profile', 'C:', '\profile')) { + $env:USERPROFILE = $relativeProfile + Must-Fail { Get-LegacyStdRoot } '*must be an absolute*' + } + foreach ($devicePath in @('\\?\C:\Wave', '\\.\C:\Wave')) { + Must-Fail { Resolve-InstallerPath $devicePath } '*Windows device paths*' + } + Assert ((Resolve-InstallerPath 'C:\') -eq 'C:\') 'drive root was made drive-relative' + } + $env:HOME = $null + $env:USERPROFILE = $null + Must-Fail { Get-LegacyStdRoot } '*HOME or USERPROFILE*' } finally { - $env:WAVE_INSTALL_DIR=$previousDir + foreach ($name in $previousEnvironment.Keys) { + [Environment]::SetEnvironmentVariable($name, $previousEnvironment[$name], 'Process') + } Remove-Item -LiteralPath $temp -Recurse -Force } -Write-Host 'Pinned Wave and latest Vex PowerShell tests passed' +Write-Host "Pinned Wave and latest Vex PowerShell tests passed ($script:Assertions assertions)" diff --git a/frontend/tests/install_latest_test.py b/frontend/tests/install_latest_test.py index 63bfa52..f32831b 100644 --- a/frontend/tests/install_latest_test.py +++ b/frontend/tests/install_latest_test.py @@ -20,6 +20,7 @@ def setUp(self): self.bin = self.root / 'mock-bin'; self.bin.mkdir() self.home = self.root / 'home'; self.home.mkdir() self.install = self.home / 'Wave tools/bin' + self.std = self.home / '.wave/lib/wave/std' self.config = self.root / 'http.json' self.urls = {} self.env = dict(os.environ, HOME=str(self.home), SHELL='/bin/bash', @@ -38,7 +39,27 @@ def setUp(self): ''') self.script('uname', '#!/bin/sh\nif [ "$1" = -s ]; then echo "$FIXTURE_OS"; else echo "$FIXTURE_ARCH"; fi\n') self.script('sysctl', '#!/bin/sh\necho "${FIXTURE_ROSETTA:-0}"\n') - self.script('mv', '#!/bin/sh\ncase "$1" in */stage) if [ "${FAIL_ACTIVATE:-0}" = 1 ]; then exit 1; fi;; esac\nexec '+shutil.which('mv')+' "$@"\n') + self.script('mv', '''#!/bin/sh +case "$1" in + */.wave-std.*/stage) + if [ "${FAIL_STD_ACTIVATE:-0}" = 1 ]; then exit 1; fi;; + */.wave-std.*/previous) + if [ "${FAIL_STD_RESTORE:-0}" = 1 ]; then exit 1; fi;; + */.wave-install.*/previous) + if [ "${FAIL_COMPILER_RESTORE:-0}" = 1 ]; then exit 1; fi;; +esac +case "$1" in + */stage) if [ "${FAIL_ACTIVATE:-0}" = 1 ]; then exit 1; fi;; +esac +exec '''+shutil.which('mv')+''' "$@" +''') + self.script('cp', '''#!/bin/sh +case "${3:-}" in + */.wave-std.*/stage/) + if [ "${FAIL_STD_COPY:-0}" = 1 ]; then exit 1; fi;; +esac +exec '''+shutil.which('cp')+''' "$@" +''') def script(self, name, text): path=self.bin/name; path.write_text(text); path.chmod(0o755) @@ -51,15 +72,20 @@ def archive(self, repo, name, target, bad=False): script='#!/bin/sh\n' if is_wave: script += f'''case "$*" in ---version) echo 'Wave 1.0.0';; +--version) echo 'Wave 0.2.1-pre-beta';; 'print target-spec --format=json') echo '{{"triple":"{target}"}}';; -run*) {'exit 7' if bad else '[ "$3" = --std-root ] && [ -f "$4/manifest.json" ]'};; +'print std-path') printf '%s\\n' "$HOME/.wave/lib/wave/std";; +run*) + {'exit 7' if bad else ':'} + std="$HOME/.wave/lib/wave/std" + if [ "${{3:-}}" = --std-root ]; then std="$4"; fi + [ -f "$std/manifest.json" ] && [ -f "$std/packaged.wave" ];; *) exit 2;; esac ''' else: script += "echo 'Vex 1.0.0'\n" files={exe:script, 'LICENSE':'notice'} - if is_wave: files.update({'std/manifest.json':'{}','llvm/bin/llvm-config':'fixture'}) + if is_wave: files.update({'std/manifest.json':'{"name":"std"}', 'std/packaged.wave':'packaged std', 'llvm/bin/llvm-config':'fixture'}) with tarfile.open(path,'w:gz') as archive: for file,text in files.items(): data=text.encode(); entry=tarfile.TarInfo(prefix+'/'+file) @@ -90,15 +116,18 @@ def releases(self, os_name='Linux', arch='x86_64', bad=False, vex=True): def run_install(self, *args, success=True): self.config.write_text(json.dumps(self.urls)) - result=subprocess.run(['bash',str(INSTALLER),*args],env=self.env,text=True,capture_output=True,timeout=20) + result=subprocess.run(['bash',str(INSTALLER),*args],env=self.env,cwd=self.root,text=True,capture_output=True,timeout=20) if success: self.assertEqual(result.returncode,0,result.stdout+result.stderr) else: self.assertNotEqual(result.returncode,0,result.stdout+result.stderr) return result def seed(self): - self.install.mkdir(parents=True) + self.install.mkdir(parents=True, exist_ok=True) (self.install/'wavec').write_text('previous compiler') (self.install/'keep.txt').write_text('previous data') + self.std.mkdir(parents=True, exist_ok=True) + (self.std/'manifest.json').write_text('{"name":"std"}') + (self.std/'keep.wave').write_text('previous std') def test_all_unix_targets(self): for os_name,arch in [('Linux','x86_64'),('Linux','aarch64'),('Linux','riscv64'),('Linux','loongarch64'),('Darwin','x86_64'),('Darwin','arm64'),('FreeBSD','amd64')]: @@ -107,6 +136,17 @@ def test_all_unix_targets(self): result=self.run_install('--no-modify-path') self.assertIn('installing Wave only',result.stdout) self.assertTrue((self.install/'std/manifest.json').is_file()) + self.assertEqual((self.std/'packaged.wave').read_text(), 'packaged std') + ordinary = subprocess.run( + [str(self.install/'wavec'), 'run', 'user.wave'], env=self.env, + text=True, capture_output=True, + ) + self.assertEqual(ordinary.returncode, 0, ordinary.stdout+ordinary.stderr) + selected = subprocess.run( + [str(self.install/'wavec'), 'print', 'std-path'], env=self.env, + text=True, capture_output=True, + ) + self.assertEqual(selected.stdout.strip(), str(self.std)) self.assertFalse((self.home/'.bashrc').exists()) def test_vex_and_path_are_idempotent(self): @@ -135,17 +175,123 @@ def test_rosetta_selects_native_arm64(self): def test_failures_preserve_previous_installation(self): self.seed() - for failure in ['digest','download','smoke','activate','vex-required']: + for failure in ['digest','download','smoke','activate','std-copy','std-activate','vex-required']: with self.subTest(failure=failure): self.releases(bad=failure=='smoke',vex=failure!='vex-required') wave=self.urls['https://api.github.com/repos/wavefnd/Wave/releases/tags/v0.2.1-pre-beta']['json']['assets'][0] if failure=='digest': wave['digest']='sha256:'+'0'*64 if failure=='download': del self.urls['https://github.com/wavefnd/Wave/releases/download/v0.2.1-pre-beta/'+wave['name']] self.env['FAIL_ACTIVATE']='1' if failure=='activate' else '0' + self.env['FAIL_STD_ACTIVATE']='1' if failure=='std-activate' else '0' + self.env['FAIL_STD_COPY']='1' if failure=='std-copy' else '0' self.run_install('--with-vex','--no-modify-path',success=False) self.assertEqual((self.install/'wavec').read_text(),'previous compiler') self.assertEqual((self.install/'keep.txt').read_text(),'previous data') + self.assertEqual((self.std/'keep.wave').read_text(), 'previous std') + self.assertFalse((self.std/'packaged.wave').exists()) + self.assertFalse(Path(str(self.install)+'.install-lock').exists()) + self.assertFalse(Path(str(self.std)+'.install-lock').exists()) + self.assertFalse(list(self.std.parent.glob('.wave-std.*'))) + + def test_existing_std_is_replaced_by_the_matching_package(self): + self.seed() + self.releases(vex=False) + self.run_install('--without-vex', '--no-modify-path') + self.assertEqual((self.std/'packaged.wave').read_text(), 'packaged std') + self.assertFalse((self.std/'keep.wave').exists()) + self.assertFalse(Path(str(self.std)+'.install-lock').exists()) + self.assertFalse(list(self.std.parent.glob('.wave-std.*'))) + + def test_std_lock_is_shared_and_not_removed_by_another_install(self): + self.seed() + self.releases(vex=False) + std_lock = Path(str(self.std)+'.install-lock') + std_lock.mkdir() + result = self.run_install('--without-vex', '--no-modify-path', success=False) + self.assertIn('Another std install may be running', result.stderr) + self.assertEqual((self.install/'wavec').read_text(), 'previous compiler') + self.assertEqual((self.std/'keep.wave').read_text(), 'previous std') + self.assertTrue(std_lock.is_dir()) + self.assertFalse(Path(str(self.install)+'.install-lock').exists()) + + def test_failed_restoration_retains_each_backup_and_tries_both_trees(self): + for failed_tree in ['std', 'compiler']: + with self.subTest(tree=failed_tree): + self.seed() + self.releases(bad=True, vex=False) + self.env['FAIL_STD_RESTORE'] = '1' if failed_tree == 'std' else '0' + self.env['FAIL_COMPILER_RESTORE'] = '1' if failed_tree == 'compiler' else '0' + result = self.run_install('--without-vex', '--no-modify-path', success=False) + self.assertIn('Previous installation retained', result.stderr) + if failed_tree == 'std': + backups = list(self.std.parent.glob('.wave-std.*/previous/keep.wave')) + self.assertTrue(backups) + self.assertEqual(backups[-1].read_text(), 'previous std') + self.assertEqual((self.install/'wavec').read_text(), 'previous compiler') + else: + backups = list(self.install.parent.glob('.wave-install.*/previous/wavec')) + self.assertTrue(backups) + self.assertEqual(backups[-1].read_text(), 'previous compiler') + self.assertEqual((self.std/'keep.wave').read_text(), 'previous std') self.assertFalse(Path(str(self.install)+'.install-lock').exists()) + self.assertFalse(Path(str(self.std)+'.install-lock').exists()) + + def test_overlapping_and_symlinked_std_destinations_are_rejected(self): + self.seed() + self.releases(vex=False) + # Canonicalize a parent alias before comparing compiler/std destinations. + alias = self.root/'std parent alias' + alias.symlink_to(self.std.parent, target_is_directory=True) + self.env['WAVE_INSTALL_DIR'] = str(alias/'std/toolchain') + result = self.run_install('--without-vex', '--no-modify-path', success=False) + self.assertIn('must not overlap', result.stderr) + self.assertEqual((self.std/'keep.wave').read_text(), 'previous std') + self.assertEqual((self.install/'wavec').read_text(), 'previous compiler') + + self.env['WAVE_INSTALL_DIR'] = str(self.home/'fresh/../.wave/lib/wave/std/compiler') + result = self.run_install('--without-vex', '--no-modify-path', success=False) + self.assertIn('must not overlap', result.stderr) + self.assertEqual((self.std/'keep.wave').read_text(), 'previous std') + + self.env['WAVE_INSTALL_DIR'] = str(self.install) + target = self.root/'linked std' + self.std.rename(target) + self.std.symlink_to(target, target_is_directory=True) + result = self.run_install('--without-vex', '--no-modify-path', success=False) + self.assertIn('must not be a symbolic link', result.stderr) + self.assertEqual((target/'keep.wave').read_text(), 'previous std') + self.assertTrue(self.std.is_symlink()) + + def test_relative_or_missing_home_is_rejected_before_installation(self): + self.releases(vex=False) + for home_value in ['relative-home', None]: + with self.subTest(home=home_value): + if home_value is None: + self.env.pop('HOME', None) + else: + self.env['HOME'] = home_value + result = self.run_install('--without-vex', '--no-modify-path', success=False) + self.assertIn('HOME must be an absolute path', result.stderr) + self.assertFalse(self.install.exists()) + self.assertFalse((self.root/'relative-home').exists()) + + def test_macos_rejects_case_variant_std_overlap(self): + self.seed() + self.releases('Darwin', 'arm64', vex=False) + self.env['WAVE_INSTALL_DIR'] = str(self.std.parent/'STD/toolchain') + result = self.run_install('--without-vex', '--no-modify-path', success=False) + self.assertIn('must not overlap', result.stderr) + self.assertEqual((self.std/'keep.wave').read_text(), 'previous std') + self.assertEqual((self.install/'wavec').read_text(), 'previous compiler') + + def test_unmanaged_std_directory_is_preserved(self): + self.std.mkdir(parents=True) + (self.std/'keep.txt').write_text('unrelated data') + self.releases(vex=False) + result = self.run_install('--without-vex', '--no-modify-path', success=False) + self.assertIn('not managed by Wave std', result.stderr) + self.assertEqual((self.std/'keep.txt').read_text(), 'unrelated data') + self.assertFalse(self.install.exists()) def test_no_fallback_when_pinned_release_lacks_target(self): self.releases()