diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index a66f6cd..804448f 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -144,6 +144,35 @@ jobs: version: v2.12 working-directory: sdk + # The Windows installer cannot be driven from the Linux gate: PowerShell, the + # per-user environment variables it writes, and Expand-Archive only exist here. + # This is the one place the single-seam goal cannot be met, and leaving the + # platform untested is the worse option. The scenarios are the ones the Unix + # runs cover, against the same fixture release. + install-windows: + name: Windows Installer + needs: changes + if: needs.changes.outputs.shell == 'true' + runs-on: windows-latest + steps: + - name: Check out repository + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version: '1.25.x' + cache-dependency-path: | + go.mod + sdk/go.mod + + # Only the installer runs. The rest of the acceptance suite is a Linux gate + # by design, and running it here would be proving something else. + - name: Drive the Windows installer + run: go test ./test/acceptance/ -run TestInstallScript -count=1 -v + acceptance: name: Architecture Proof needs: changes diff --git a/scripts/install.ps1 b/scripts/install.ps1 new file mode 100644 index 0000000..6eb6d9e --- /dev/null +++ b/scripts/install.ps1 @@ -0,0 +1,315 @@ +# Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +# +# WSO2 LLC. licenses this file to you under the Apache License, +# Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +<# +.SYNOPSIS +Installs the wso2 shell on Windows. + +.DESCRIPTION +Downloads the published archive for this machine, verifies it against the +checksum file published beside it, and installs the binary under the WSO2 state +root with its directory added to the per-user PATH. + +Nothing here needs administrator rights: no symbolic link is created, no machine +level environment variable is written, and no installer is registered. A run that +cannot verify what it downloaded installs nothing. + +The artifact names, the checksum file, and the tag resolution this depends on are +documented in docs/reference/release-artifacts.md. + +.PARAMETER Version +The release tag to install, such as v0.1.0. Defaults to the newest stable +release. + +.EXAMPLE +iwr -useb | iex + +.EXAMPLE +&([scriptblock]::Create((iwr -useb))) v0.1.0 + +.NOTES +Environment variables it reads: + + WSO2_HOME State root to install into. Default ~\.wso2. + WSO2_CLI_PRERELEASE=true Resolve the newest prerelease, not the newest + stable release. + WSO2_CLI_NO_PROFILE=1 Install without changing any environment variable. + WSO2_CLI_RELEASE_BASE_URL Where releases are downloaded from. Overridden by + WSO2_CLI_RELEASE_API_URL the tests; users have no reason to set either. +#> +param( + [string] $Version +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +# Invoke-WebRequest on Windows PowerShell 5.1 renders a progress bar that +# throttles the download itself, badly enough to dominate the transfer time. +$ProgressPreference = 'SilentlyContinue' + +$BlockName = 'wso2 cli' + +function Get-ReleaseBaseUrl { + if ($env:WSO2_CLI_RELEASE_BASE_URL) { return $env:WSO2_CLI_RELEASE_BASE_URL } + return 'https://github.com/wso2/wso2-cli/releases' +} + +function Get-ReleaseApiUrl { + if ($env:WSO2_CLI_RELEASE_API_URL) { return $env:WSO2_CLI_RELEASE_API_URL } + return 'https://api.github.com/repos/wso2/wso2-cli/releases' +} + +function Stop-WithError { + param([string] $Message) + # Written to the error stream and exited non-zero, so a script that pipes this + # installer can tell a refusal from a success. + [Console]::Error.WriteLine("error: $Message") + exit 1 +} + +# Resolve-Architecture maps what Windows calls this machine onto the architecture +# names the release artifacts use. +# +# PROCESSOR_ARCHITEW6432 is read first: a 32-bit PowerShell on 64-bit Windows +# reports x86 in PROCESSOR_ARCHITECTURE and the real architecture only there, so +# reading the latter alone would install a 32-bit binary on a 64-bit machine. +function Resolve-Architecture { + $machine = $env:PROCESSOR_ARCHITEW6432 + if (-not $machine) { $machine = $env:PROCESSOR_ARCHITECTURE } + if (-not $machine) { Stop-WithError 'could not determine this machine''s architecture.' } + + switch ($machine.ToUpperInvariant()) { + 'AMD64' { return 'amd64' } + 'ARM64' { return 'arm64' } + 'X86' { return '386' } + default { + Stop-WithError "unsupported architecture: $machine. Supported: AMD64, ARM64, x86." + } + } +} + +# Resolve-Version reports the release tag to install. An explicit argument wins. +# Otherwise the newest stable tag comes from the redirect on the release page's +# /latest, which needs no API token; the prerelease opt-in has to read the release +# listing instead, because /latest deliberately skips prereleases. +function Resolve-Version { + param([string] $Requested) + + if ($Requested) { return $Requested } + + if ($env:WSO2_CLI_PRERELEASE -eq 'true') { + $url = Get-ReleaseApiUrl + try { + $releases = Invoke-RestMethod -Uri $url -UseBasicParsing + } catch { + Stop-WithError "could not read the release listing at ${url}: $($_.Exception.Message)" + } + # The listing is newest first, so the first prerelease in it is the newest. + foreach ($release in $releases) { + if ($release.prerelease) { return $release.tag_name } + } + Stop-WithError "no prerelease was found at $url." + } + + $url = "$(Get-ReleaseBaseUrl)/latest" + try { + $response = Invoke-WebRequest -Uri $url -UseBasicParsing + } catch { + Stop-WithError "could not reach $url to find the newest release: $($_.Exception.Message)" + } + + # The property that carries the URL after redirects differs between Windows + # PowerShell and PowerShell 7, and this script supports both. + $final = $null + if ($response.BaseResponse.PSObject.Properties['ResponseUri']) { + $final = $response.BaseResponse.ResponseUri + } + if (-not $final -and $response.BaseResponse.PSObject.Properties['RequestMessage']) { + $final = $response.BaseResponse.RequestMessage.RequestUri + } + if (-not $final) { Stop-WithError "could not determine the latest release from $url." } + + $tag = ($final.AbsoluteUri.TrimEnd('/') -split '/')[-1] + if (-not $tag -or $tag -eq 'latest') { + Stop-WithError "could not determine the latest release from $url." + } + return $tag +} + +# Assert-Checksum refuses anything whose SHA-256 does not match the checksum +# published beside it, before the archive is extracted. +# +# The published name is compared exactly rather than searched for: a name that +# merely ends with this archive's would otherwise supply the wrong digest and +# refuse a release that is perfectly good. +function Assert-Checksum { + param([string] $ArchivePath, [string] $ChecksumPath, [string] $ArchiveName) + + $expected = $null + foreach ($line in Get-Content -LiteralPath $ChecksumPath) { + $fields = -split $line + if ($fields.Count -lt 2) { continue } + $name = $fields[1].TrimStart('*') + if ($name -eq $ArchiveName) { + $expected = $fields[0] + break + } + } + if (-not $expected) { + Stop-WithError "checksums.txt does not list $ArchiveName, so the download cannot be verified." + } + + $actual = (Get-FileHash -LiteralPath $ArchivePath -Algorithm SHA256).Hash + # Hex case is not part of the value: Get-FileHash reports upper case and the + # published file is lower case. + if ($expected -ine $actual) { + [Console]::Error.WriteLine("error: checksum mismatch for $ArchiveName") + [Console]::Error.WriteLine(" expected $expected") + [Console]::Error.WriteLine(" actual $actual") + Stop-WithError 'refusing to install an archive that failed verification.' + } + Write-Output 'Checksum verified.' +} + +# Add-ToUserPath puts the binary directory on the per-user PATH, which needs no +# elevation, and on the current session's PATH so the command works without +# reopening the terminal. +# +# An entry that is already there is left alone rather than appended again. +function Add-ToUserPath { + param([string] $Directory) + + $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') + if (-not $userPath) { $userPath = '' } + + $present = $false + foreach ($entry in $userPath -split ';') { + if ($entry.Trim().TrimEnd('\') -ieq $Directory.TrimEnd('\')) { + $present = $true + break + } + } + + if ($present) { + Write-Output "PATH already contains $Directory." + } else { + $updated = if ($userPath.TrimEnd(';')) { "$($userPath.TrimEnd(';'));$Directory" } else { $Directory } + [Environment]::SetEnvironmentVariable('Path', $updated, 'User') + Write-Output "Added $Directory to your user PATH." + } + + # The current session's PATH, so the command works without reopening the + # terminal. The array subexpression is required rather than decorative: a + # pipeline that matches one entry or none returns a scalar or $null, and + # reading .Count off either is an error under Set-StrictMode. + $sessionPath = if ($env:Path) { $env:Path } else { '' } + $alreadyThere = @($sessionPath -split ';' | + Where-Object { $_.Trim().TrimEnd('\') -ieq $Directory.TrimEnd('\') }) + if ($alreadyThere.Count -eq 0) { + $env:Path = if ($sessionPath.TrimEnd(';')) { "$($sessionPath.TrimEnd(';'));$Directory" } else { $Directory } + } +} + +function Write-ManualPathInstructions { + param([string] $StateRoot, [string] $BinDir, [string] $Reason) + Write-Output '' + Write-Output $Reason + Write-Output 'Set these for yourself to run wso2 by name:' + Write-Output '' + Write-Output " `$env:WSO2_HOME = '$StateRoot'" + Write-Output " `$env:Path += ';$BinDir'" +} + +function Invoke-Install { + param([string] $Requested) + + $arch = Resolve-Architecture + $tag = Resolve-Version -Requested $Requested + + $stateRoot = if ($env:WSO2_HOME) { $env:WSO2_HOME } else { Join-Path $HOME '.wso2' } + $binDir = Join-Path $stateRoot 'bin' + $archiveName = "wso2-cli-$tag-windows-$arch.zip" + $url = "$(Get-ReleaseBaseUrl)/download/$tag/$archiveName" + + Write-Output "Installing the WSO2 CLI $tag for windows/$arch." + + # Everything downloaded lands in a directory removed however this script + # exits, so a failed verification leaves nothing behind to run. + $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("wso2-install-" + [guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $tempDir -Force | Out-Null + + try { + $archivePath = Join-Path $tempDir $archiveName + $checksumPath = Join-Path $tempDir 'checksums.txt' + + Write-Output "Downloading $url" + try { + Invoke-WebRequest -Uri $url -OutFile $archivePath -UseBasicParsing + } catch { + Stop-WithError "could not download $url. Check that $tag is a published release." + } + try { + Invoke-WebRequest -Uri "$(Get-ReleaseBaseUrl)/download/$tag/checksums.txt" ` + -OutFile $checksumPath -UseBasicParsing + } catch { + Stop-WithError "could not download the checksum file for $tag, so the archive cannot be verified." + } + + Assert-Checksum -ArchivePath $archivePath -ChecksumPath $checksumPath -ArchiveName $archiveName + + $unpacked = Join-Path $tempDir 'unpacked' + Expand-Archive -LiteralPath $archivePath -DestinationPath $unpacked -Force + $extracted = Join-Path $unpacked 'wso2.exe' + if (-not (Test-Path -LiteralPath $extracted)) { + Stop-WithError 'the archive did not contain the expected wso2.exe binary.' + } + + New-Item -ItemType Directory -Path $binDir -Force | Out-Null + # Replacing the binary through a staged copy beside its final path keeps a + # failed move from leaving a half-written executable where the finished one + # belongs. The staging name carries this process id so two runs at once + # cannot stage onto each other. + $staged = Join-Path $binDir (".wso2.install.$PID.exe") + Move-Item -LiteralPath $extracted -Destination $staged -Force + $installed = Join-Path $binDir 'wso2.exe' + try { + Move-Item -LiteralPath $staged -Destination $installed -Force + } catch { + Remove-Item -LiteralPath $staged -Force -ErrorAction SilentlyContinue + Stop-WithError "could not replace ${installed}: $($_.Exception.Message). Close any running wso2 and try again." + } + Write-Output "Installed $installed" + + if ($env:WSO2_CLI_NO_PROFILE) { + Write-ManualPathInstructions -StateRoot $stateRoot -BinDir $binDir ` + -Reason 'Left your environment untouched, as asked.' + } else { + # The state root is recorded, not just used: an installation under a + # non-default WSO2_HOME would otherwise leave the installed shell reading + # its state from the default root. + [Environment]::SetEnvironmentVariable('WSO2_HOME', $stateRoot, 'User') + $env:WSO2_HOME = $stateRoot + Add-ToUserPath -Directory $binDir + } + + Write-Output '' + Write-Output "The WSO2 CLI $tag is installed. Run: wso2 --help" + } finally { + Remove-Item -LiteralPath $tempDir -Recurse -Force -ErrorAction SilentlyContinue + } +} + +Invoke-Install -Requested $Version diff --git a/test/acceptance/install_fixture_test.go b/test/acceptance/install_fixture_test.go new file mode 100644 index 0000000..053b9dc --- /dev/null +++ b/test/acceptance/install_fixture_test.go @@ -0,0 +1,320 @@ +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +// The fixture release both install scripts are driven against. +// +// It is shared rather than written twice because the two scripts implement one +// published contract: the same artifact names, the same checksum file, and the +// same tag resolution. A Windows-only copy of this would be free to drift from +// the contract the Unix script was proven against, and then only one of them +// would still be right. +// +// The archives carry the real shell, built with the version the tag names, so a +// test can install one and run what it installed. A stand-in that only claimed +// to be the shell would prove less: extraction, permissions, and replacement all +// behave differently for a real executable. +package acceptance_test + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "crypto/sha256" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" +) + +// The fixture release. The tags differ from each other and from anything either +// script defaults to, so an assertion cannot pass by coincidence, and the +// prerelease tag is newer than the stable one so that resolving "latest" wrongly +// would pick it. +const ( + fixtureStableTag = "v1.2.3" + fixturePreleaseTag = "v1.3.0-rc.1" + fixtureOlderTag = "v1.1.0" + installBlockMarker = "# >>> wso2 cli >>>" +) + +// Building the shell three times over is worth avoiding, and every test in the +// package wants the same three builds. +var ( + standInMutex sync.Mutex + standInCache = map[string][]byte{} +) + +// installedBinaryName is what the archive holds and what the script installs. +func installedBinaryName() string { + return "wso2" + executableSuffix() +} + +// standInShell reports the bytes of a shell built to report the given tag as its +// own version, which is how a test tells which release actually landed. +func standInShell(t *testing.T, tag string) []byte { + t.Helper() + standInMutex.Lock() + defer standInMutex.Unlock() + if cached, ok := standInCache[tag]; ok { + return cached + } + + binary := filepath.Join(t.TempDir(), installedBinaryName()) + // The version package prefixes a "v" for display and parses the bare form, so + // what is injected is the tag without it — exactly as the release does. + build(t, repoRoot(t), binary, + "-X github.com/wso2/wso2-cli/internal/version.shellVersion="+strings.TrimPrefix(tag, "v"), + "./cmd/wso2") + contents, err := os.ReadFile(binary) + if err != nil { + t.Fatalf("reading the built shell returned %v", err) + } + standInCache[tag] = contents + return contents +} + +// installHarness is one isolated install: a fixture release served over HTTP, a +// temporary home directory, and a state root nothing else writes to. +type installHarness struct { + t *testing.T + home string + stateRoot string + environment []string + server *httptest.Server + + // corruptArchive serves bytes the published checksum does not describe, which + // is what a substituted download looks like from the client's side. + corruptArchive bool + // precedingSiblingChecksum lists a longer artifact name ending in this + // archive's name before the archive's own line, so a loose filename match + // takes the wrong digest. + precedingSiblingChecksum bool + // omitChecksumLine publishes a checksum file that says nothing about this + // archive. + omitChecksumLine bool + + // The profile the script is expected to find, and whether one exists at all. + // Windows has no profile to edit and leaves both alone. + profilePath string + writeProfile bool + // profileMode is the permission the fixture profile is written with, so a + // test can present the script with one it cannot write to. + profileMode os.FileMode + + // What each platform needs and the other has no use for. It is a type per + // platform rather than a union of both, because a field only one build reads + // is dead code in the other — which the linter is right to say. + platform platformFields +} + +func newInstallHarness(t *testing.T) *installHarness { + t.Helper() + home := t.TempDir() + install := &installHarness{ + t: t, + home: home, + stateRoot: filepath.Join(home, ".wso2"), + profilePath: filepath.Join(home, ".bashrc"), + writeProfile: true, + profileMode: 0o644, + } + install.server = httptest.NewServer(http.HandlerFunc(install.serve)) + t.Cleanup(install.server.Close) + return install +} + +// serve answers the shapes both scripts depend on: the redirect that names the +// newest stable tag, the listing that names the newest prerelease, and the +// download paths for archives and the checksum file. +func (i *installHarness) serve(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/releases/latest": + http.Redirect(w, r, "/releases/tag/"+fixtureStableTag, http.StatusFound) + + case strings.HasPrefix(r.URL.Path, "/releases/tag/"): + // The redirect target has to answer, as the real release page does: both + // scripts follow the redirect and read the tag off the URL they land on. + if _, err := fmt.Fprintf(w, "release %s\n", + strings.TrimPrefix(r.URL.Path, "/releases/tag/")); err != nil { + i.t.Errorf("writing the tag page returned %v", err) + } + + case r.URL.Path == "/releases": + // Newest first, as the GitHub API returns them, with the fields in the + // order and nesting the real listing uses: tag_name precedes prerelease and + // a nested object sits between them. Both are what the parses cope with. + type author struct { + Login string `json:"login"` + ID int `json:"id"` + } + type release struct { + TagName string `json:"tag_name"` + Author author `json:"author"` + Prerelease bool `json:"prerelease"` + } + releases := []release{ + {TagName: fixturePreleaseTag, Author: author{Login: "release-bot", ID: 1}, Prerelease: true}, + {TagName: fixtureStableTag, Author: author{Login: "release-bot", ID: 1}, Prerelease: false}, + {TagName: fixtureOlderTag, Author: author{Login: "release-bot", ID: 1}, Prerelease: false}, + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(releases); err != nil { + i.t.Errorf("encoding the release listing returned %v", err) + } + + case strings.HasPrefix(r.URL.Path, "/releases/download/"): + rest := strings.TrimPrefix(r.URL.Path, "/releases/download/") + tag, name, found := strings.Cut(rest, "/") + if !found { + http.NotFound(w, r) + return + } + archive := i.archiveName(tag) + switch name { + case archive: + body := i.archiveBytes(tag) + if i.corruptArchive { + body = append(body, "tampered"...) + } + if _, err := w.Write(body); err != nil { + i.t.Errorf("writing the archive returned %v", err) + } + case "checksums.txt": + sum := sha256.Sum256(i.archiveBytes(tag)) + line := fmt.Sprintf("%x %s\n", sum, archive) + switch { + case i.omitChecksumLine: + line = fmt.Sprintf("%064d some-other-artifact.tar.gz\n", 0) + case i.precedingSiblingChecksum: + // A hash that is deliberately not the archive's, on a line whose name + // ends with the archive's name, listed first. + line = fmt.Sprintf("%064d %s.sig\n%s", 0, archive, line) + } + if _, err := fmt.Fprint(w, line); err != nil { + i.t.Errorf("writing the checksum file returned %v", err) + } + default: + http.NotFound(w, r) + } + + default: + http.NotFound(w, r) + } +} + +// archiveName is the published name for this platform, built from the same +// convention the scripts build their download URL from. +func (i *installHarness) archiveName(tag string) string { + extension := "tar.gz" + if runtime.GOOS == "darwin" || runtime.GOOS == "windows" { + extension = "zip" + } + return fmt.Sprintf("wso2-cli-%s-%s-%s.%s", tag, runtime.GOOS, runtime.GOARCH, extension) +} + +// archiveBytes builds the archive this platform's release would carry: the shell +// at the archive root, beside the licence and notice a real release ships. It is +// deterministic, so the checksum served alongside describes exactly these bytes. +func (i *installHarness) archiveBytes(tag string) []byte { + i.t.Helper() + files := []struct { + name string + body []byte + mode int64 + }{ + {installedBinaryName(), standInShell(i.t, tag), 0o755}, + {"LICENSE", []byte("Apache License 2.0\n"), 0o644}, + {"NOTICE", []byte("WSO2 CLI\n"), 0o644}, + } + + var buffer strings.Builder + if strings.HasSuffix(i.archiveName(tag), ".zip") { + writer := zip.NewWriter(&buffer) + for _, file := range files { + header := &zip.FileHeader{Name: file.name, Method: zip.Deflate} + header.SetMode(os.FileMode(file.mode)) + entry, err := writer.CreateHeader(header) + if err != nil { + i.t.Fatalf("creating %s in the zip returned %v", file.name, err) + } + if _, err := entry.Write(file.body); err != nil { + i.t.Fatalf("writing %s into the zip returned %v", file.name, err) + } + } + if err := writer.Close(); err != nil { + i.t.Fatalf("closing the zip returned %v", err) + } + return []byte(buffer.String()) + } + + gzipWriter := gzip.NewWriter(&buffer) + tarWriter := tar.NewWriter(gzipWriter) + for _, file := range files { + if err := tarWriter.WriteHeader(&tar.Header{ + Name: file.name, + Mode: file.mode, + Size: int64(len(file.body)), + }); err != nil { + i.t.Fatalf("writing the %s header returned %v", file.name, err) + } + if _, err := tarWriter.Write(file.body); err != nil { + i.t.Fatalf("writing %s into the tarball returned %v", file.name, err) + } + } + if err := tarWriter.Close(); err != nil { + i.t.Fatalf("closing the tarball returned %v", err) + } + if err := gzipWriter.Close(); err != nil { + i.t.Fatalf("closing the gzip stream returned %v", err) + } + return []byte(buffer.String()) +} + +// installedBinary is where the script is expected to have put the shell. +func (i *installHarness) installedBinary() string { + return filepath.Join(i.stateRoot, "bin", installedBinaryName()) +} + +// reportedVersion runs what was installed and reports the release tag it names. +// Running it is the point: a file of the right name that cannot execute would +// satisfy an existence check and nothing a user cares about. +func (i *installHarness) reportedVersion(t *testing.T) string { + t.Helper() + // Run against a state root of its own: reporting a version reads the module + // inventory, and it must not read what the install under test just created. + command := exec.Command(i.installedBinary(), "version") + command.Env = shellEnvironment(filepath.Join(i.t.TempDir(), "inventory")) + raw, err := command.CombinedOutput() + output := string(raw) + if err != nil { + t.Fatalf("the installed binary at %s did not run: %v\noutput:\n%s", + i.installedBinary(), err, output) + } + for _, tag := range []string{fixturePreleaseTag, fixtureStableTag, fixtureOlderTag} { + if strings.Contains(output, tag) { + return tag + } + } + t.Fatalf("the installed binary reported no known fixture tag:\n%s", output) + return "" +} diff --git a/test/acceptance/install_unix_test.go b/test/acceptance/install_unix_test.go index 0762c85..0b90997 100644 --- a/test/acceptance/install_unix_test.go +++ b/test/acceptance/install_unix_test.go @@ -30,33 +30,23 @@ package acceptance_test import ( - "archive/tar" - "archive/zip" - "compress/gzip" - "crypto/sha256" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" "os" "os/exec" "path/filepath" - "runtime" "strings" "testing" ) -// The fixture release. The tags differ from each other and from anything the -// script defaults to, so an assertion cannot pass by coincidence, and the -// prerelease tag is newer than the stable one so that resolving "latest" -// wrongly would pick it. -const ( - fixtureStableTag = "v1.2.3" - fixturePreleaseTag = "v1.3.0-rc.1" - fixtureOlderTag = "v1.1.0" - installBlockMarker = "# >>> wso2 cli >>>" - installScriptRelPath = "scripts/install.sh" -) +// The fixture release these run against is in install_fixture_test.go, shared +// with the Windows runs so that both scripts are proven against one contract. +const installScriptRelPath = "scripts/install.sh" + +// platformFields is what only the Unix runs need. +type platformFields struct { + // unameMachine is what `uname -m` answers, for reaching the + // unsupported-hardware path without unsupported hardware. + unameMachine string +} func TestInstallScriptInstallsTheShellAndWiresThePath(t *testing.T) { install := newInstallHarness(t) @@ -294,7 +284,7 @@ func TestInstallScriptRefusesAnUnsupportedArchitecture(t *testing.T) { // The real detection path is exercised by answering `uname -m` with hardware // no release is built for, rather than by giving the script a test-only // override it would then carry for users. - install.unameMachine = "sparc64" + install.platform.unameMachine = "sparc64" stdout, stderr, err := install.run() if err == nil { @@ -410,200 +400,6 @@ func TestInstallScriptHonoursTheStateRootVariable(t *testing.T) { } } -// installHarness is one isolated install: a fixture release served over HTTP, a -// temporary home directory with a profile in it, and a state root nothing else -// writes to. -type installHarness struct { - t *testing.T - home string - stateRoot string - profilePath string - environment []string - server *httptest.Server - corruptArchive bool - // precedingSiblingChecksum lists a longer artifact name ending in this - // archive's name before the archive's own line, so a loose filename match - // takes the wrong digest. - precedingSiblingChecksum bool - // omitChecksumLine publishes a checksum file that says nothing about this - // archive. - omitChecksumLine bool - unameMachine string - writeProfile bool - // profileMode is the permission the fixture profile is written with, so a - // test can present the script with one it cannot write to. - profileMode os.FileMode -} - -func newInstallHarness(t *testing.T) *installHarness { - t.Helper() - home := t.TempDir() - install := &installHarness{ - t: t, - home: home, - stateRoot: filepath.Join(home, ".wso2"), - profilePath: filepath.Join(home, ".bashrc"), - writeProfile: true, - unameMachine: "", - profileMode: 0o644, - } - install.server = httptest.NewServer(http.HandlerFunc(install.serve)) - t.Cleanup(install.server.Close) - return install -} - -// serve answers the two shapes the script depends on: the redirect that names -// the newest stable tag, the release listing that names the newest prerelease, -// and the download paths for archives and the checksum file. -func (i *installHarness) serve(w http.ResponseWriter, r *http.Request) { - switch { - case r.URL.Path == "/releases/latest": - http.Redirect(w, r, "/releases/tag/"+fixtureStableTag, http.StatusFound) - - case strings.HasPrefix(r.URL.Path, "/releases/tag/"): - // The redirect target has to answer, as the real release page does: the - // script follows the redirect and reads the tag off the URL it lands on, - // and a failing status there would be a failed download to it. - if _, err := fmt.Fprintf(w, "release %s\n", - strings.TrimPrefix(r.URL.Path, "/releases/tag/")); err != nil { - i.t.Errorf("writing the tag page returned %v", err) - } - - case r.URL.Path == "/releases": - // Newest first, as the GitHub API returns them, with the fields in the - // order and nesting the real listing uses: a struct rather than a map, so - // tag_name precedes prerelease and each release carries a nested object - // between them. Both are what the script's parse has to cope with. - type author struct { - Login string `json:"login"` - ID int `json:"id"` - } - type release struct { - TagName string `json:"tag_name"` - Author author `json:"author"` - Prerelease bool `json:"prerelease"` - } - releases := []release{ - {TagName: fixturePreleaseTag, Author: author{Login: "release-bot", ID: 1}, Prerelease: true}, - {TagName: fixtureStableTag, Author: author{Login: "release-bot", ID: 1}, Prerelease: false}, - {TagName: fixtureOlderTag, Author: author{Login: "release-bot", ID: 1}, Prerelease: false}, - } - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(releases); err != nil { - i.t.Errorf("encoding the release listing returned %v", err) - } - - case strings.HasPrefix(r.URL.Path, "/releases/download/"): - rest := strings.TrimPrefix(r.URL.Path, "/releases/download/") - tag, name, found := strings.Cut(rest, "/") - if !found { - http.NotFound(w, r) - return - } - archive := i.archiveName(tag) - switch name { - case archive: - body := i.archiveBytes(tag) - if i.corruptArchive { - // The bytes change and the published checksum does not, which is - // what a substituted download looks like from the client's side. - body = append(body, "tampered"...) - } - if _, err := w.Write(body); err != nil { - i.t.Errorf("writing the archive returned %v", err) - } - case "checksums.txt": - sum := sha256.Sum256(i.archiveBytes(tag)) - line := fmt.Sprintf("%x %s\n", sum, archive) - switch { - case i.omitChecksumLine: - line = fmt.Sprintf("%064d some-other-artifact.tar.gz\n", 0) - case i.precedingSiblingChecksum: - // A hash that is deliberately not the archive's, on a line whose name - // ends with the archive's name, listed first. - line = fmt.Sprintf("%064d %s.sig\n%s", 0, archive, line) - } - if _, err := fmt.Fprint(w, line); err != nil { - i.t.Errorf("writing the checksum file returned %v", err) - } - default: - http.NotFound(w, r) - } - - default: - http.NotFound(w, r) - } -} - -func (i *installHarness) archiveName(tag string) string { - extension := "tar.gz" - if runtime.GOOS == "darwin" { - extension = "zip" - } - return fmt.Sprintf("wso2-cli-%s-%s-%s.%s", tag, runtime.GOOS, runtime.GOARCH, extension) -} - -// archiveBytes builds the archive this platform's release would carry, holding a -// stand-in binary that reports the tag it was packaged for. It is deterministic, -// so the checksum served alongside it describes exactly these bytes. -func (i *installHarness) archiveBytes(tag string) []byte { - i.t.Helper() - stand := "#!/bin/sh\necho \"WSO2 CLI " + tag + "\"\n" - files := []struct { - name string - body string - mode int64 - }{ - {"wso2", stand, 0o755}, - {"LICENSE", "Apache License 2.0\n", 0o644}, - {"NOTICE", "WSO2 CLI\n", 0o644}, - } - - var buffer strings.Builder - if runtime.GOOS == "darwin" { - writer := zip.NewWriter(&buffer) - for _, file := range files { - header := &zip.FileHeader{Name: file.name, Method: zip.Deflate} - header.SetMode(os.FileMode(file.mode)) - entry, err := writer.CreateHeader(header) - if err != nil { - i.t.Fatalf("creating %s in the zip returned %v", file.name, err) - } - if _, err := entry.Write([]byte(file.body)); err != nil { - i.t.Fatalf("writing %s into the zip returned %v", file.name, err) - } - } - if err := writer.Close(); err != nil { - i.t.Fatalf("closing the zip returned %v", err) - } - return []byte(buffer.String()) - } - - gzipWriter := gzip.NewWriter(&buffer) - tarWriter := tar.NewWriter(gzipWriter) - for _, file := range files { - if err := tarWriter.WriteHeader(&tar.Header{ - Name: file.name, - Mode: file.mode, - Size: int64(len(file.body)), - }); err != nil { - i.t.Fatalf("writing the %s header returned %v", file.name, err) - } - if _, err := tarWriter.Write([]byte(file.body)); err != nil { - i.t.Fatalf("writing %s into the tarball returned %v", file.name, err) - } - } - if err := tarWriter.Close(); err != nil { - i.t.Fatalf("closing the tarball returned %v", err) - } - if err := gzipWriter.Close(); err != nil { - i.t.Fatalf("closing the gzip stream returned %v", err) - } - return []byte(buffer.String()) -} - -// run invokes the real script the way a user does, with everything it could -// reach outside the test redirected: home, state root, and release origin. func (i *installHarness) run(args ...string) (string, string, error) { i.t.Helper() if i.writeProfile { @@ -621,7 +417,7 @@ func (i *installHarness) run(args ...string) (string, string, error) { command := exec.Command("bash", append([]string{script}, args...)...) path := os.Getenv("PATH") - if i.unameMachine != "" { + if i.platform.unameMachine != "" { path = i.shimmedUname() + string(os.PathListSeparator) + path } command.Env = append([]string{ @@ -644,7 +440,7 @@ func (i *installHarness) run(args ...string) (string, string, error) { func (i *installHarness) shimmedUname() string { i.t.Helper() directory := i.t.TempDir() - shim := "#!/bin/sh\nif [ \"$1\" = \"-m\" ]; then echo " + i.unameMachine + + shim := "#!/bin/sh\nif [ \"$1\" = \"-m\" ]; then echo " + i.platform.unameMachine + "; else exec /usr/bin/uname \"$@\"; fi\n" if err := os.WriteFile(filepath.Join(directory, "uname"), []byte(shim), 0o755); err != nil { i.t.Fatalf("writing the uname shim returned %v", err) @@ -671,25 +467,6 @@ func (i *installHarness) readProfile(t *testing.T) string { return string(contents) } -// reportedVersion runs the installed binary and reports the release tag it names. -// The stand-in binary in the fixture archive echoes the tag it was packaged for, -// so this is how a test tells which release actually landed. -func (i *installHarness) reportedVersion(t *testing.T) string { - t.Helper() - binary := filepath.Join(i.stateRoot, "bin", "wso2") - output, err := exec.Command(binary, "version").CombinedOutput() - if err != nil { - t.Fatalf("the installed binary at %s did not run: %v\noutput:\n%s", binary, err, output) - } - for _, tag := range []string{fixturePreleaseTag, fixtureStableTag, fixtureOlderTag} { - if strings.Contains(string(output), tag) { - return tag - } - } - t.Fatalf("the installed binary reported no known fixture tag:\n%s", output) - return "" -} - // runWithProfileMode runs the installer against a profile carrying the given // permission, so the script can be presented with one it cannot write to. func (i *installHarness) runWithProfileMode(mode os.FileMode, args ...string) (string, string, error) { diff --git a/test/acceptance/install_windows_test.go b/test/acceptance/install_windows_test.go new file mode 100644 index 0000000..7683db2 --- /dev/null +++ b/test/acceptance/install_windows_test.go @@ -0,0 +1,357 @@ +// Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +//go:build windows + +// The Windows installer is driven the same way the Unix one is, against the same +// fixture release, so both are proven against one published contract rather than +// two descriptions of it. +// +// What differs is the environment it touches. There is no shell profile to edit: +// PATH and the state root are per-user environment variables, which is why these +// runs assert on what the script wrote to the user's environment rather than on +// what it wrote to a file. Those writes are real and outlive the process, so each +// run is given its own registry-backed user environment to write into and the +// values are put back afterwards. +package acceptance_test + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +const installScriptRelPath = "scripts/install.ps1" + +// platformFields is what only the Windows runs need. +type platformFields struct { + // processorArchitecture is what PROCESSOR_ARCHITECTURE reports, for reaching + // the unsupported-hardware path without unsupported hardware. + processorArchitecture string + // savedUserEnvironment holds the per-user environment variables as they were + // before the first run, so what the script really wrote can be put back. These + // writes outlive the process, unlike everything else a run here touches. + savedUserEnvironment map[string]string +} + +func TestInstallScriptInstallsTheShellAndWiresThePath(t *testing.T) { + install := newInstallHarness(t) + defer install.restoreUserEnvironment(t) + + stdout, stderr, err := install.run() + if err != nil { + t.Fatalf("install.ps1 failed: %v\nstdout:\n%s\nstderr:\n%s", err, stdout, stderr) + } + + if _, statErr := os.Stat(install.installedBinary()); statErr != nil { + t.Fatalf("expected an installed binary at %s: %v", install.installedBinary(), statErr) + } + // Running it is the point: an unextractable or truncated binary of the right + // name would satisfy an existence check and nothing a user cares about. + if reported := install.reportedVersion(t); reported != fixtureStableTag { + t.Errorf("installed binary reported %s, want %s", reported, fixtureStableTag) + } + + binDir := filepath.Join(install.stateRoot, "bin") + userPath := install.userEnvironment(t, "Path") + if !strings.Contains(strings.ToLower(userPath), strings.ToLower(binDir)) { + t.Errorf("user PATH does not contain %s:\n%s", binDir, userPath) + } + if got := install.userEnvironment(t, "WSO2_HOME"); got != install.stateRoot { + t.Errorf("user WSO2_HOME is %q, want %q", got, install.stateRoot) + } + if !strings.Contains(stdout, binDir) { + t.Errorf("output does not name the directory it added to PATH (%s):\n%s", binDir, stdout) + } +} + +func TestInstallScriptDoesNotDuplicateThePathEntry(t *testing.T) { + install := newInstallHarness(t) + defer install.restoreUserEnvironment(t) + + if _, stderr, err := install.run(); err != nil { + t.Fatalf("first install failed: %v\nstderr:\n%s", err, stderr) + } + if _, stderr, err := install.run(); err != nil { + t.Fatalf("second install failed: %v\nstderr:\n%s", err, stderr) + } + + binDir := strings.ToLower(filepath.Join(install.stateRoot, "bin")) + entries := 0 + for _, entry := range strings.Split(install.userEnvironment(t, "Path"), ";") { + if strings.TrimRight(strings.ToLower(strings.TrimSpace(entry)), `\`) == + strings.TrimRight(binDir, `\`) { + entries++ + } + } + if entries != 1 { + t.Errorf("user PATH carries %d entries for %s, want exactly 1", entries, binDir) + } +} + +func TestInstallScriptRefusesAnArchiveThatFailsItsChecksum(t *testing.T) { + install := newInstallHarness(t) + defer install.restoreUserEnvironment(t) + install.corruptArchive = true + + stdout, stderr, err := install.run() + if err == nil { + t.Fatalf("install.ps1 succeeded on a corrupted archive\nstdout:\n%s", stdout) + } + if !strings.Contains(strings.ToLower(stdout+stderr), "checksum") { + t.Errorf("refusal does not mention the checksum:\nstdout:\n%s\nstderr:\n%s", stdout, stderr) + } + // Nothing may survive a failed verification, on any platform. + if _, statErr := os.Stat(install.installedBinary()); !os.IsNotExist(statErr) { + t.Error("a binary was installed from an archive that failed verification") + } +} + +func TestInstallScriptRefusesAnArchiveWithNoPublishedChecksum(t *testing.T) { + install := newInstallHarness(t) + defer install.restoreUserEnvironment(t) + install.omitChecksumLine = true + + stdout, stderr, err := install.run() + if err == nil { + t.Fatalf("install.ps1 succeeded with no checksum published\nstdout:\n%s", stdout) + } + if !strings.Contains(stdout+stderr, "checksums.txt") { + t.Errorf("refusal does not say the checksum file lacked the archive:\nstdout:\n%s\nstderr:\n%s", + stdout, stderr) + } +} + +func TestInstallScriptReadsTheChecksumLineForTheArchiveItDownloaded(t *testing.T) { + install := newInstallHarness(t) + defer install.restoreUserEnvironment(t) + install.precedingSiblingChecksum = true + + stdout, stderr, err := install.run() + if err != nil { + t.Fatalf("install.ps1 refused a valid archive because another line was listed first: %v\nstdout:\n%s\nstderr:\n%s", + err, stdout, stderr) + } + if _, statErr := os.Stat(install.installedBinary()); statErr != nil { + t.Errorf("no binary was installed: %v", statErr) + } +} + +func TestInstallScriptInstallsAPinnedVersion(t *testing.T) { + install := newInstallHarness(t) + defer install.restoreUserEnvironment(t) + + if _, stderr, err := install.run(fixtureOlderTag); err != nil { + t.Fatalf("install.ps1 failed for a pinned version: %v\nstderr:\n%s", err, stderr) + } + + if reported := install.reportedVersion(t); reported != fixtureOlderTag { + t.Errorf("installed binary reported %s, want the pinned %s", reported, fixtureOlderTag) + } +} + +func TestInstallScriptResolvesAPrereleaseOnlyWhenAsked(t *testing.T) { + t.Run("the default resolves the newest stable release", func(t *testing.T) { + install := newInstallHarness(t) + defer install.restoreUserEnvironment(t) + + if _, stderr, err := install.run(); err != nil { + t.Fatalf("install.ps1 failed: %v\nstderr:\n%s", err, stderr) + } + if reported := install.reportedVersion(t); reported != fixtureStableTag { + t.Errorf("the default install resolved %s, want the newest stable %s", + reported, fixtureStableTag) + } + }) + + t.Run("the opt-in resolves the newest prerelease", func(t *testing.T) { + install := newInstallHarness(t) + defer install.restoreUserEnvironment(t) + install.environment = append(install.environment, "WSO2_CLI_PRERELEASE=true") + + if _, stderr, err := install.run(); err != nil { + t.Fatalf("install.ps1 failed: %v\nstderr:\n%s", err, stderr) + } + if reported := install.reportedVersion(t); reported != fixturePreleaseTag { + t.Errorf("the prerelease opt-in installed %s, want %s", reported, fixturePreleaseTag) + } + }) +} + +func TestInstallScriptRefusesAnUnsupportedArchitecture(t *testing.T) { + install := newInstallHarness(t) + defer install.restoreUserEnvironment(t) + // The real detection path is exercised by answering with hardware no release + // is built for, rather than by giving the script a test-only override. + install.platform.processorArchitecture = "IA64" + + stdout, stderr, err := install.run() + if err == nil { + t.Fatalf("install.ps1 succeeded on an unsupported architecture\nstdout:\n%s", stdout) + } + if !strings.Contains(stdout+stderr, "IA64") { + t.Errorf("refusal does not name the architecture it detected:\nstdout:\n%s\nstderr:\n%s", + stdout, stderr) + } +} + +func TestInstallScriptLeavesTheEnvironmentAloneWhenAsked(t *testing.T) { + install := newInstallHarness(t) + defer install.restoreUserEnvironment(t) + before := install.userEnvironment(t, "Path") + install.environment = append(install.environment, "WSO2_CLI_NO_PROFILE=1") + + stdout, stderr, err := install.run() + if err != nil { + t.Fatalf("install.ps1 failed: %v\nstdout:\n%s\nstderr:\n%s", err, stdout, stderr) + } + + // The opt-out suppresses the environment change, not the install. + if _, statErr := os.Stat(install.installedBinary()); statErr != nil { + t.Errorf("the opt-out suppressed the install itself: %v", statErr) + } + if after := install.userEnvironment(t, "Path"); after != before { + t.Errorf("user PATH changed despite the opt-out:\nbefore: %s\nafter: %s", before, after) + } + if binDir := filepath.Join(install.stateRoot, "bin"); !strings.Contains(stdout, binDir) { + t.Errorf("output does not tell the user how to reach %s:\n%s", binDir, stdout) + } +} + +func TestInstallScriptHonoursTheStateRootVariable(t *testing.T) { + install := newInstallHarness(t) + defer install.restoreUserEnvironment(t) + elsewhere := filepath.Join(t.TempDir(), "elsewhere") + install.stateRoot = elsewhere + install.environment = append(install.environment, "WSO2_HOME="+elsewhere) + + if _, stderr, err := install.run(); err != nil { + t.Fatalf("install.ps1 failed: %v\nstderr:\n%s", err, stderr) + } + + if _, statErr := os.Stat(install.installedBinary()); statErr != nil { + t.Errorf("the binary was not installed under WSO2_HOME: %v", statErr) + } +} + +// run invokes the real script the way a user does, with everything it could +// reach outside the test redirected: the home directory, the state root, and the +// release origin. +func (i *installHarness) run(args ...string) (string, string, error) { + i.t.Helper() + + // Captured before the first run rather than in the constructor, so a test that + // reads these values first still records what was there originally. + if i.platform.savedUserEnvironment == nil { + i.platform.savedUserEnvironment = map[string]string{ + "Path": i.userEnvironment(i.t, "Path"), + "WSO2_HOME": i.userEnvironment(i.t, "WSO2_HOME"), + } + } + + script := filepath.Join(repoRoot(i.t), installScriptRelPath) + arguments := []string{ + "-NoLogo", "-NoProfile", "-NonInteractive", + "-ExecutionPolicy", "Bypass", + "-File", script, + } + command := exec.Command(powerShell(), append(arguments, args...)...) + + // Built from scratch rather than inherited, so an ambient WSO2_ variable + // cannot reach the run. The Windows API needs SystemRoot and a temp directory + // to function at all, and PowerShell itself is found on PATH. + environment := []string{ + "USERPROFILE=" + i.home, + "HOME=" + i.home, + "WSO2_CLI_RELEASE_BASE_URL=" + i.server.URL + "/releases", + "WSO2_CLI_RELEASE_API_URL=" + i.server.URL + "/releases", + } + for _, name := range []string{"PATH", "SystemRoot", "windir", "TMP", "TEMP", "ProgramFiles", + "ProgramData", "LOCALAPPDATA", "APPDATA", "COMSPEC", "PATHEXT", "PSModulePath"} { + if value, present := os.LookupEnv(name); present { + environment = append(environment, name+"="+value) + } + } + if i.platform.processorArchitecture != "" { + // Both are set to the same value. The script reads PROCESSOR_ARCHITEW6432 + // first, so leaving whatever the runner has there would mask the + // architecture under test — and a runner that has it set is exactly the + // case an empty override would not cover. + environment = append(environment, + "PROCESSOR_ARCHITECTURE="+i.platform.processorArchitecture, + "PROCESSOR_ARCHITEW6432="+i.platform.processorArchitecture) + } else { + for _, name := range []string{"PROCESSOR_ARCHITECTURE", "PROCESSOR_ARCHITEW6432"} { + if value, present := os.LookupEnv(name); present { + environment = append(environment, name+"="+value) + } + } + } + command.Env = append(environment, i.environment...) + + var stdout, stderr strings.Builder + command.Stdout = &stdout + command.Stderr = &stderr + err := command.Run() + return stdout.String(), stderr.String(), err +} + +// powerShell reports the interpreter to drive the script with, preferring +// PowerShell 7 where the runner has it and falling back to the Windows +// PowerShell every Windows machine ships. The script supports both, so whichever +// is present is the one worth proving against. +func powerShell() string { + if path, err := exec.LookPath("pwsh"); err == nil { + return path + } + return "powershell" +} + +// userEnvironment reads a per-user environment variable as the script writes it: +// out of the user's own environment, not out of this process. +func (i *installHarness) userEnvironment(t *testing.T, name string) string { + t.Helper() + command := exec.Command(powerShell(), "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", + "[Environment]::GetEnvironmentVariable('"+name+"', 'User')") + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("reading the user environment variable %s returned %v\n%s", name, err, output) + } + return strings.TrimSpace(string(output)) +} + +// restoreUserEnvironment puts back what the run changed. These writes are real +// and persist beyond the process, so a test that did not undo them would leave +// the machine it ran on carrying a temporary directory on its PATH. +func (i *installHarness) restoreUserEnvironment(t *testing.T) { + t.Helper() + if i.platform.savedUserEnvironment == nil { + return + } + for name, value := range i.platform.savedUserEnvironment { + setting := "$null" + if value != "" { + setting = "'" + strings.ReplaceAll(value, "'", "''") + "'" + } + command := exec.Command(powerShell(), "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", + "[Environment]::SetEnvironmentVariable('"+name+"', "+setting+", 'User')") + if output, err := command.CombinedOutput(); err != nil { + t.Errorf("restoring the user environment variable %s returned %v\n%s", name, err, output) + } + } +}