diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index e0127809f..8e73fcd1d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -325,17 +325,23 @@ jobs: if ($output -match "Failed to load extension") { exit 1 } if ($status -ne 0 -and $output -notmatch "No models available|No model selected|No API key found") { exit $status } - # The one leg that proves musl at runtime rather than at link time. Docker is the - # only way to reach a musl userspace from a Blacksmith Ubuntu runner, and the - # native binding is downloaded rather than rebuilt so this job costs an archive - # build instead of a second zig cross-compile. + # Build and run each musl archive on the matching stock Alpine architecture. + # The native binding is downloaded rather than rebuilt, so these legs prove + # the complete release payload without a second Zig cross-compile. alpine-binary-smoke: - name: Smoke Alpine musl binary + name: Smoke Alpine musl binary (${{ matrix.arch }}) needs: [integrity, native-artifacts] - runs-on: blacksmith-4vcpu-ubuntu-2404 - timeout-minutes: 12 + runs-on: ${{ matrix.runner }} + timeout-minutes: 14 + strategy: + fail-fast: false + matrix: + include: + - { runner: blacksmith-4vcpu-ubuntu-2404, arch: x64, slug: linux-x64-musl, platform: linux-x64-musl, docker_platform: linux/amd64 } + - { runner: blacksmith-4vcpu-ubuntu-2404-arm, arch: arm64, slug: linux-arm64-musl, platform: linux-arm64-musl, docker_platform: linux/arm64 } steps: - uses: useblacksmith/checkout@6fd481652155169ed4d2f25ebaf97464f685175f # v1.1 + if: runner.os == 'Linux' with: ref: ${{ env.SOURCE_REF }} persist-credentials: false @@ -345,47 +351,30 @@ jobs: - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 22 + - name: Install musl archive tooling + run: | + sudo apt-get update + sudo apt-get install -y patchelf - name: Download musl native binding uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: atomic-natives-linux-x64-musl + name: atomic-natives-${{ matrix.slug }} path: packages/natives/native - - name: Build Linux x64 musl archive - run: ./scripts/build-binaries.sh --skip-deps --platform linux-x64-musl - - name: Smoke musl release archive on Alpine + - name: Build Linux musl archive + run: ./scripts/build-binaries.sh --skip-deps --platform "${{ matrix.platform }}" + - name: Smoke real musl release archive on stock Alpine + run: ./scripts/test-musl-release-archive.sh "packages/coding-agent/binaries/atomic-${{ matrix.platform }}.tar.gz" "${{ matrix.platform }}" + - name: Load the musl native binding under musl libc shell: bash run: | set -euo pipefail - smoke="$RUNNER_TEMP/atomic-alpine-smoke" + smoke="$RUNNER_TEMP/atomic-alpine-native-smoke" rm -rf "$smoke" mkdir -p "$smoke" - tar -xzf packages/coding-agent/binaries/atomic-linux-x64-musl.tar.gz -C "$smoke" - cat > "$smoke/smoke.sh" <<'SMOKE' - set -eu - apk add --no-cache libgcc libstdc++ - atomic=/smoke/atomic/atomic - "$atomic" --version - mkdir -p /tmp/atomic-alpine-cwd - cd /tmp/atomic-alpine-cwd - set +e - output=$(printf '' | "$atomic" --no-session 2>&1) - status=$? - set -e - echo "$output" - if echo "$output" | grep -q 'Failed to load extension'; then exit 1; fi - if [ "$status" -ne 0 ] && ! echo "$output" | grep -Eq 'No models available|No model selected|No API key found'; then - exit "$status" - fi - SMOKE - docker run --rm -v "$smoke:/smoke" alpine:3.22 /bin/sh /smoke/smoke.sh - - name: Load the musl native binding under musl libc - shell: bash - run: | - set -euo pipefail - smoke="$RUNNER_TEMP/atomic-alpine-smoke" + tar -xzf "packages/coding-agent/binaries/atomic-${{ matrix.platform }}.tar.gz" -C "$smoke" # `atomic` catches a failed binding load and degrades to the JS search # paths, so starting is not proof the addon dlopen'd. Require it here. - docker run --rm -v "$smoke:/smoke" node:22-alpine node -e ' + docker run --rm --platform "${{ matrix.docker_platform }}" -v "$smoke:/smoke:ro" node:22-alpine node -e ' const binding = require("/smoke/atomic/node_modules/@bastani/atomic-natives"); for (const name of ["glob", "grep"]) { if (typeof binding[name] !== "function") throw new Error(`musl binding is missing ${name}()`); @@ -424,6 +413,10 @@ jobs: bun run --cwd packages/natives create-npm-dirs bun run --cwd packages/natives artifacts bun run --cwd packages/natives prepublish:native -- --skip-optional-publish + - name: Install musl archive tooling + run: | + sudo apt-get update + sudo apt-get install -y patchelf - name: Build release archives run: ./scripts/build-binaries.sh --skip-install - name: Validate package metadata and prepare payload diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 968940b24..edb407c0f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -366,6 +366,8 @@ jobs: # own runner rather than through the workspace suites. - name: Script tests run: npm run test:scripts + - name: POSIX installer smoke tests in Alpine and Debian slim + run: ./scripts/test-installers-containers.sh - name: Deterministic CI and release contracts run: npm run test:ci-contracts diff --git a/README.md b/README.md index a29130e14..99ecb9c29 100644 --- a/README.md +++ b/README.md @@ -57,13 +57,31 @@ ### Prerequisites -- **Node.js 22.19 or newer** — check with `node --version`. -- **A package manager** — use npm, pnpm, Yarn, or Bun. Use Bun 1.3.14+ for Bun installs or workflow-authoring examples. +- **Release archive install:** macOS and Linux need `tar` and either `curl` or `wget`. Windows uses built-in PowerShell commands. This path does not need Node.js or a package manager. +- **Package install:** Node.js 22.19 or newer plus npm, pnpm, Yarn, or Bun. Use Bun 1.3.14+ for Bun installs or workflow-authoring examples. - **Model-provider access** — use a supported subscription login or API key. ### Install -With npm: +Install the self-contained release archive on macOS or Linux: + +```bash +curl -fsSL https://raw.githubusercontent.com/bastani-inc/atomic/main/install.sh | sh +``` + +On Windows, run this in PowerShell: + +```powershell +irm https://raw.githubusercontent.com/bastani-inc/atomic/main/install.ps1 | iex +``` + +The archive installer verifies `SHA256SUMS`, keeps versioned payloads, and links its launcher from `~/.local/bin/atomic` on macOS/Linux or `%LOCALAPPDATA%\atomic\bin\atomic.cmd` on Windows. It prints PATH guidance when needed. Set `ATOMIC_VERSION` to pin a release, `ATOMIC_INSTALL_DIR` or `ATOMIC_BIN_DIR` to change those locations, and `GITHUB_TOKEN` or `GH_TOKEN` if shared GitHub API limits are a concern. + +On macOS/Linux, relative install and bin directories resolve against the physical directory where the installer starts. The install root cannot equal or sit inside the `ATOMIC_BIN_DIR/atomic` launcher path, and `ATOMIC_BIN_DIR` cannot sit inside the install root's `current` or `versions` directories. On Windows, a same-stem launcher that `PATHEXT` resolves before `atomic.cmd`, such as a stale `atomic.exe`, is reported before any download. Exact pins use Atomic release tags in `MAJOR.MINOR.PATCH` or `MAJOR.MINOR.PATCH-alpha.REVISION` form. + +The Linux musl archives bundle their C++ runtime libraries and run on stock Alpine without an `apk add` step. Android and Termux use bionic rather than musl and remain unsupported by the release archives. + +Package installs still require Node.js and a package manager. With npm: ```bash npm install -g @bastani/atomic @@ -81,7 +99,7 @@ With Bun: bun add -g @bastani/atomic ``` -Atomic does not require package install scripts. Add `--ignore-scripts` to the install command if you want to disable dependency lifecycle scripts during installation. +Atomic does not require package install scripts. Add `--ignore-scripts` to a package install command if you want to disable dependency lifecycle scripts. ### Authenticate and run @@ -112,7 +130,7 @@ After authenticating, run `/atomic` for workflow guides, examples, and next step
Devcontainer, terminal, and SDK references -Atomic runs in a standard devcontainer or VM with Node.js 22.19+ installed. Install it inside the container with a package manager and pass provider credentials through environment variables. +Atomic runs in a standard devcontainer or VM. Use the release-archive installer for an image without Node.js or npm, or install Node.js 22.19+ and use a package manager. Pass provider credentials through environment variables. See [Terminal setup](./packages/coding-agent/docs/terminal-setup.md), [Security](./packages/coding-agent/docs/security.md), and [Programmatic Usage](./packages/coding-agent/README.md#programmatic-usage) for the SDK and RPC entry points. diff --git a/docs/ci.md b/docs/ci.md index d81ba6dfa..450e1db66 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -10,7 +10,7 @@ Pull request / selected branch push ├─ suites (Linux, Windows): build package -> unit -> integration ├─ agent-suite (Linux, Windows): native bindings -> coding-agent vitest (Node, then Bun) ├─ release-archive (Linux, Windows): build package -> binaries -> smoke - ├─ static-checks (Linux): typecheck, docs, Mintlify, contracts + ├─ static-checks (Linux): typecheck, docs, installer container smoke, contracts └─ test (2 legs): result gate carrying both required contexts Release tag push (`0.9.10` or `0.9.10-alpha.1`) @@ -43,7 +43,7 @@ The test workflow runs on pushes to `main`, `release/**`, and `prerelease/**`, a | `suites` | both | build `@bastani/atomic` -> unit -> integration | 121 s | 195 s | | `agent-suite` | both | build native bindings -> coding-agent vitest (Node), then its Bun-hosted SQLite selector project | 126 s | 232 s | | `release-archive` | both | build package -> `scripts/build-binaries.sh` -> archive smoke | 74 s | 149 s | -| `static-checks` | Linux only | typecheck, docs links, Mintlify, CI contracts | 30 s | – | +| `static-checks` | Linux only | typecheck, docs links, Mintlify, Alpine/Debian installer smoke, CI contracts | 30 s | – | | `test` | 2 gate legs | assert every work-job result is `success` | 15 s | – | Those are the per-step costs sampled from four sequential-job runs, which put the critical path on the Windows `agent-suite` chain at about 247 s against the 452 s (434–483 s, n=3 healthy) the single sequential job measured. Runner-seconds rise about 35 % (709 s to roughly 957 s); that is the price of the wall-clock cut. @@ -114,6 +114,8 @@ Every job that runs a suite through `scripts/run-flaky-test-suite.ts` uploads `. Archive smoke tests verify bundled builtins, native modules, runtime dependencies, `--version`, and startup far enough to reject extension-load failures. +The static job also runs `scripts/test-installers-containers.sh`. It executes `install.sh` with a restricted PATH and local release fixture inside `alpine:3.22` BusyBox `sh` and `debian:bookworm-slim`, checks the full payload and launcher, and gives the installer no JavaScript runtime or package manager. The Alpine fixture omits `ldd` from `PATH`, proving the `/etc/alpine-release` musl path. + ## Direct release trigger and recovery `.github/workflows/publish.yml` starts directly when an Atomic release tag is pushed. Atomic tags have no `v` prefix: @@ -297,7 +299,7 @@ pins are supply-chain hygiene, not a fix for this incident. Linux and Windows x64 each run `scripts/build-binaries.sh` for their platform, extract the resulting archive, check required bundled files, run `--version`, and start `--no-session` from a clean temporary directory. Expected no-model/no-key exits are accepted; extension-load failures and unexpected exits fail the job. -The `alpine-binary-smoke` job downloads the x64 musl binding, builds `atomic-linux-x64-musl.tar.gz`, and runs it in an `alpine:3.22` Docker container. The container installs `libgcc` and `libstdc++`, runs `--version` and the clean-cwd `--no-session` smoke, and rejects extension-load failures. A separate `node:22-alpine` container directly requires the extracted native package and checks its search exports. This currently exercises the x64 archive; the native matrix builds and publishes both musl architectures. +The `alpine-binary-smoke` matrix downloads each x64/arm64 musl binding, builds the matching archive, and passes it to `scripts/test-musl-release-archive.sh` on a matching runner. That script uses stock `alpine:3.22` with no package installation, checks the full payload and bundled `libgcc`/`libstdc++`, and runs `atomic --version`. A separate matching-architecture `node:22-alpine` container directly requires each extracted native package and checks its search exports. ### Release payload @@ -306,6 +308,7 @@ After native and smoke jobs pass, `build`: 1. Installs with `npm ci --ignore-scripts` and runs `npm run check:shrinkwrap`. 2. Generates native platform package directories and the native root manifest. 3. Runs `scripts/build-binaries.sh --skip-install` for all eight archives. + Musl payload assembly downloads pinned Alpine 3.22 `libgcc` and `libstdc++` packages, verifies their SHA256 hashes, copies only the matching runtime libraries under `atomic/lib`, and sets payload-local ELF search paths with `patchelf`. 4. Validates package identity, versions, public/private metadata, binary entrypoint, workspace dependency ranges, build outputs, eight native modules, and eight exact-version native optional dependencies. 5. Packs exactly ten npm tarballs. 6. Extracts release notes from `packages/coding-agent/CHANGELOG.md`. diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 000000000..078a46d27 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,956 @@ +# Atomic release archive installer for Windows PowerShell 5.1 and later. +# +# Usage: +# irm https://raw.githubusercontent.com/bastani-inc/atomic/main/install.ps1 | iex +# & ([scriptblock]::Create((irm https://raw.githubusercontent.com/bastani-inc/atomic/main/install.ps1))) -Ref 0.9.11 + +& { +param( + [string]$Ref, + [switch]$Help +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" + +$helpText = @' +Atomic release archive installer for Windows PowerShell 5.1+ + +Usage: + install.ps1 [-Ref ] [-Help] + +Options: + -Ref Install the exact GitHub release tag. This overrides ATOMIC_VERSION. + -Help Show this help and exit. + +Environment: + ATOMIC_VERSION Exact release tag when -Ref is not supplied. + ATOMIC_INSTALL_DIR Installation root. + ATOMIC_BIN_DIR Directory containing the atomic.cmd shim. + GITHUB_TOKEN Optional GitHub API token (preferred over GH_TOKEN). + GH_TOKEN Optional GitHub API token. + +Default install directory: $env:LOCALAPPDATA\atomic +Default bin directory: $env:LOCALAPPDATA\atomic\bin (the install directory's bin subdirectory) +'@ + +if ($Help) { + Write-Output $helpText + return +} + +function Get-AtomicFileSha256 { + param([string]$Path) + + if ($null -ne (Get-Command Get-FileHash -ErrorAction SilentlyContinue)) { + return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash + } + + $algorithm = [Security.Cryptography.SHA256]::Create() + $stream = [IO.File]::OpenRead($Path) + try { + return ([BitConverter]::ToString($algorithm.ComputeHash($stream))).Replace("-", "") + } + finally { + $stream.Dispose() + $algorithm.Dispose() + } +} + +function Test-AtomicReleaseTag { + param([string]$Tag) + + if ([string]::IsNullOrWhiteSpace($Tag)) { + return $false + } + + return $Tag -cmatch '^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(?:-alpha\.(?:[1-9][0-9]*))?$' +} + +function Get-AtomicRedirectTag { + param([string]$Uri) + + $response = $null + try { + $response = Invoke-WebRequest -Uri $Uri -UseBasicParsing -MaximumRedirection 0 -ErrorAction SilentlyContinue + } + catch { + if ($null -ne $_.Exception) { + try { + $response = $_.Exception.Response + } + catch { + $response = $null + } + } + } + + if ($null -eq $response) { + return $null + } + + $location = $null + try { + $location = [string]$response.Headers.Location + } + catch { + $location = $null + } + + if ([string]::IsNullOrWhiteSpace($location)) { + try { + if ($null -ne $response.Headers.PSObject.Methods["TryGetValues"]) { + $headerValues = $null + if ($response.Headers.TryGetValues("Location", [ref]$headerValues)) { + $values = @($headerValues) + if ($values.Count -gt 0) { + $location = [string]$values[0] + } + } + } + } + catch { + $location = $null + } + } + + if ([string]::IsNullOrWhiteSpace($location)) { + try { + if ($null -ne $response.Headers.PSObject.Methods["GetValues"]) { + $values = @($response.Headers.GetValues("Location")) + if ($values.Count -gt 0) { + $location = [string]$values[0] + } + } + } + catch { + $location = $null + } + } + + if ([string]::IsNullOrWhiteSpace($location)) { + try { + $location = [string]$response.Headers["Location"] + } + catch { + $location = $null + } + } + + if ([string]::IsNullOrWhiteSpace($location)) { + try { + $location = [string]$response.BaseResponse.ResponseUri.AbsoluteUri + } + catch { + $location = $null + } + } + + if (-not [string]::IsNullOrWhiteSpace($location) -and $location -match '/releases/tag/([^/?#]+)') { + return [Uri]::UnescapeDataString($Matches[1]) + } + + return $null +} + +function Invoke-AtomicApiRequest { + param( + [string]$Uri, + [hashtable]$Headers + ) + + try { + $response = Invoke-WebRequest -Uri $Uri -Headers $Headers -UseBasicParsing -ErrorAction Stop + return ($response.Content | ConvertFrom-Json) + } + catch { + throw "Failed to query GitHub release API at ${Uri}: $_" + } +} + +function Invoke-AtomicDownload { + param( + [string]$Uri, + [string]$Destination + ) + + try { + Invoke-WebRequest -Uri $Uri -OutFile $Destination -UseBasicParsing -ErrorAction Stop | Out-Null + } + catch { + throw "Failed to download ${Uri}: $_" + } +} + +function Test-AtomicPathContains { + param( + [AllowNull()][string]$PathValue, + [string]$Entry + ) + + if ([string]::IsNullOrWhiteSpace($PathValue)) { + return $false + } + + $target = [Environment]::ExpandEnvironmentVariables($Entry.Trim().Trim('"')).TrimEnd([char[]]@('\', '/')) + foreach ($candidate in ($PathValue -split ';')) { + $normalized = [Environment]::ExpandEnvironmentVariables($candidate.Trim().Trim('"')).TrimEnd([char[]]@('\', '/')) + if ($normalized -ieq $target) { + return $true + } + } + + return $false +} + +function Get-AtomicDirectoryEntry { + param([string]$Path) + + if ([string]::IsNullOrWhiteSpace($Path)) { + return $null + } + + $fullPath = [IO.Path]::GetFullPath($Path) + $parentPath = [IO.Path]::GetDirectoryName($fullPath) + $leafName = [IO.Path]::GetFileName($fullPath.TrimEnd([char[]]@('\', '/'))) + if ([string]::IsNullOrWhiteSpace($parentPath) -or -not [IO.Directory]::Exists($parentPath)) { + return $null + } + + foreach ($item in (Get-ChildItem -LiteralPath $parentPath -Force)) { + if ($item.Name -ieq $leafName) { + return $item + } + } + + return $null +} + +function Get-AtomicShimShadowingExtensions { + $shadowing = New-Object System.Collections.ArrayList + $cmdSeen = $false + $pathExtValue = $env:PATHEXT + if ([string]::IsNullOrWhiteSpace($pathExtValue)) { + $pathExtValue = ".COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC" + } + foreach ($pathExtEntry in ($pathExtValue -split ';')) { + $extension = $pathExtEntry.Trim().Trim('"').Trim() + if ([string]::IsNullOrWhiteSpace($extension)) { + continue + } + if (-not $extension.StartsWith(".")) { + $extension = "." + $extension + } + $extension = $extension.ToUpperInvariant() + if ($extension -eq ".CMD") { + $cmdSeen = $true + break + } + if (-not $shadowing.Contains($extension)) { + [void]$shadowing.Add($extension) + } + } + if (-not $cmdSeen) { + throw "PATHEXT does not include .CMD; bare atomic cannot resolve the installed atomic.cmd shim. Add .CMD to PATHEXT and rerun the installer." + } + return $shadowing +} + +function Remove-AtomicDirectoryLinkOrTree { + param([string]$Path) + + $item = Get-AtomicDirectoryEntry $Path + if ($null -eq $item) { + return + } + + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + [IO.Directory]::Delete($item.FullName) + return + } + + Remove-Item -LiteralPath $item.FullName -Recurse -Force +} + +function Remove-AtomicTemporaryDirectory { + param( + [string]$Path, + [int]$RetryLimit, + [int]$RetryDelayMilliseconds + ) + + if ([string]::IsNullOrWhiteSpace($Path) -or -not [IO.Directory]::Exists($Path)) { + return + } + + $attempt = 0 + $lastCleanupError = $null + while ($attempt -lt $RetryLimit) { + $attempt++ + + try { + $readOnlyCandidates = New-Object System.Collections.ArrayList + [void]$readOnlyCandidates.Add($Path) + foreach ($entry in [IO.Directory]::GetFileSystemEntries($Path, "*", [IO.SearchOption]::AllDirectories)) { + [void]$readOnlyCandidates.Add($entry) + } + foreach ($candidate in $readOnlyCandidates) { + $candidateAttributes = [IO.File]::GetAttributes($candidate) + if (($candidateAttributes -band [IO.FileAttributes]::ReadOnly) -ne 0) { + [IO.File]::SetAttributes( + $candidate, + [IO.FileAttributes]([int]$candidateAttributes -band (-bnot [int][IO.FileAttributes]::ReadOnly))) + } + } + } + catch { + $lastCleanupError = $_ + } + + try { + Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction Stop + } + catch { + $lastCleanupError = $_ + } + if (-not [IO.Directory]::Exists($Path)) { + return + } + + try { + [IO.Directory]::Delete($Path, $true) + } + catch { + $lastCleanupError = $_ + } + if (-not [IO.Directory]::Exists($Path)) { + return + } + + if ($attempt -lt $RetryLimit) { + Start-Sleep -Milliseconds ($RetryDelayMilliseconds * $attempt) + } + } + + $lastCleanupDetail = if ($null -eq $lastCleanupError) { + "the directory still existed after every verified removal attempt" + } + else { + [string]$lastCleanupError + } + throw "Failed to remove the temporary download directory ${Path} after $attempt attempts; last error: $lastCleanupDetail" +} + +function Remove-AtomicEmptyDirectory { + param([string]$Path) + + $item = Get-AtomicDirectoryEntry $Path + if ($null -eq $item -or -not $item.PSIsContainer -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + return $false + } + if (@(Get-ChildItem -LiteralPath $item.FullName -Force).Count -ne 0) { + return $false + } + + [IO.Directory]::Delete($item.FullName, $false) + return $true +} + +function Add-AtomicMissingDirectoryPaths { + param( + [System.Collections.ArrayList]$MissingPaths, + [string]$Path + ) + + $candidate = [IO.Path]::GetFullPath($Path) + while (-not [string]::IsNullOrWhiteSpace($candidate)) { + $trimmedCandidate = $candidate.TrimEnd([char[]]@('\', '/')) + $trimmedRoot = [IO.Path]::GetPathRoot($candidate).TrimEnd([char[]]@('\', '/')) + if ($trimmedCandidate -ieq $trimmedRoot) { + break + } + if ($null -ne (Get-AtomicDirectoryEntry $candidate)) { + break + } + + $alreadyRecorded = $false + foreach ($recordedPath in $MissingPaths) { + if ($recordedPath -ieq $candidate) { + $alreadyRecorded = $true + break + } + } + if (-not $alreadyRecorded) { + [void]$MissingPaths.Add($candidate) + } + + $parentPath = [IO.Path]::GetDirectoryName($trimmedCandidate) + if ([string]::IsNullOrWhiteSpace($parentPath) -or $parentPath -ieq $candidate) { + break + } + $candidate = $parentPath + } +} + +function Remove-AtomicCreatedEmptyDirectories { + param([System.Collections.ArrayList]$MissingPaths) + + $orderedPaths = @($MissingPaths | Sort-Object -Property Length -Descending) + do { + $removedDirectory = $false + foreach ($path in $orderedPaths) { + try { + if (Remove-AtomicEmptyDirectory $path) { + $removedDirectory = $true + } + } + catch { + Write-Warning -Message "Failed to remove transaction-created empty directory ${path}: $_" -WarningAction Continue + } + } + } while ($removedDirectory) +} + +function Invoke-AtomicTransactionRollback { + param([hashtable]$Transaction) + + if ($null -eq $Transaction -or $Transaction.RollbackCompleted) { + return + } + + if ($Transaction.ShimInstallIntended) { + try { + $shimItem = Get-AtomicDirectoryEntry $Transaction.ShimPath + if ($null -eq $shimItem) { + $Transaction.ShimInstallIntended = $false + } + else { + Remove-Item -LiteralPath $shimItem.FullName -Force + $Transaction.ShimInstallIntended = $false + } + } + catch { Write-Warning -Message "Failed to remove the unsuccessful atomic.cmd shim: $_" -WarningAction Continue } + } + if ($Transaction.ShimBackupIntended) { + try { + $shimBackupItem = Get-AtomicDirectoryEntry $Transaction.ShimBackupPath + $shimDestinationItem = Get-AtomicDirectoryEntry $Transaction.ShimPath + if ($null -ne $shimBackupItem -and $null -eq $shimDestinationItem) { + Move-Item -LiteralPath $shimBackupItem.FullName -Destination $Transaction.ShimPath + $Transaction.ShimBackupIntended = $false + } + elseif ($null -eq $shimBackupItem -and $null -ne $shimDestinationItem) { + $Transaction.ShimBackupIntended = $false + } + } + catch { Write-Warning -Message "Failed to restore the previous atomic.cmd shim: $_" -WarningAction Continue } + } + + if ($Transaction.AtomicCurrentInstallIntended) { + try { + $atomicCurrentItem = Get-AtomicDirectoryEntry $Transaction.AtomicCurrentPath + if ($null -eq $atomicCurrentItem) { + $Transaction.AtomicCurrentInstallIntended = $false + } + else { + Remove-AtomicDirectoryLinkOrTree $atomicCurrentItem.FullName + $Transaction.AtomicCurrentInstallIntended = $false + } + } + catch { Write-Warning -Message "Failed to remove the unsuccessful atomic-current pointer: $_" -WarningAction Continue } + } + if ($Transaction.AtomicCurrentBackupIntended) { + try { + $atomicCurrentBackupItem = Get-AtomicDirectoryEntry $Transaction.AtomicCurrentBackupPath + $atomicCurrentDestinationItem = Get-AtomicDirectoryEntry $Transaction.AtomicCurrentPath + if ($null -ne $atomicCurrentBackupItem -and $null -eq $atomicCurrentDestinationItem) { + Move-Item -LiteralPath $atomicCurrentBackupItem.FullName -Destination $Transaction.AtomicCurrentPath + $Transaction.AtomicCurrentBackupIntended = $false + } + elseif ($null -eq $atomicCurrentBackupItem -and $null -ne $atomicCurrentDestinationItem) { + $Transaction.AtomicCurrentBackupIntended = $false + } + } + catch { Write-Warning -Message "Failed to restore the previous atomic-current pointer: $_" -WarningAction Continue } + } + + if ($Transaction.CurrentInstallIntended) { + try { + $currentItem = Get-AtomicDirectoryEntry $Transaction.CurrentPath + if ($null -eq $currentItem) { + $Transaction.CurrentInstallIntended = $false + } + else { + Remove-AtomicDirectoryLinkOrTree $currentItem.FullName + $Transaction.CurrentInstallIntended = $false + } + } + catch { Write-Warning -Message "Failed to remove the unsuccessful current pointer: $_" -WarningAction Continue } + } + if ($Transaction.CurrentBackupIntended) { + try { + $currentBackupItem = Get-AtomicDirectoryEntry $Transaction.CurrentBackupPath + $currentDestinationItem = Get-AtomicDirectoryEntry $Transaction.CurrentPath + if ($null -ne $currentBackupItem -and $null -eq $currentDestinationItem) { + Move-Item -LiteralPath $currentBackupItem.FullName -Destination $Transaction.CurrentPath + $Transaction.CurrentBackupIntended = $false + } + elseif ($null -eq $currentBackupItem -and $null -ne $currentDestinationItem) { + $Transaction.CurrentBackupIntended = $false + } + } + catch { Write-Warning -Message "Failed to restore the previous current pointer: $_" -WarningAction Continue } + } + + if ($Transaction.VersionInstallIntended) { + try { + $versionItem = Get-AtomicDirectoryEntry $Transaction.VersionPath + if ($null -eq $versionItem) { + $Transaction.VersionInstallIntended = $false + } + else { + Remove-AtomicDirectoryLinkOrTree $versionItem.FullName + $Transaction.VersionInstallIntended = $false + } + } + catch { Write-Warning -Message "Failed to remove the unsuccessful version directory: $_" -WarningAction Continue } + } + if ($Transaction.VersionBackupIntended) { + try { + $versionBackupItem = Get-AtomicDirectoryEntry $Transaction.VersionBackupPath + $versionDestinationItem = Get-AtomicDirectoryEntry $Transaction.VersionPath + if ($null -ne $versionBackupItem -and $null -eq $versionDestinationItem) { + Move-Item -LiteralPath $versionBackupItem.FullName -Destination $Transaction.VersionPath + $Transaction.VersionBackupIntended = $false + } + elseif ($null -eq $versionBackupItem -and $null -ne $versionDestinationItem) { + $Transaction.VersionBackupIntended = $false + } + } + catch { Write-Warning -Message "Failed to restore the previous version directory: $_" -WarningAction Continue } + } + + if ($Transaction.CurrentPathChangeIntended) { + try { + $env:Path = $Transaction.OldCurrentPath + $Transaction.CurrentPathChangeIntended = $false + } + catch { Write-Warning -Message "Failed to restore the current PATH after installation failure: $_" -WarningAction Continue } + } + if ($Transaction.UserPathChangeIntended) { + try { + [Environment]::SetEnvironmentVariable("Path", $Transaction.OldUserPath, "User") + $Transaction.UserPathChangeIntended = $false + } + catch { Write-Warning -Message "Failed to restore the User PATH after installation failure: $_" -WarningAction Continue } + } + + $rollbackIncomplete = $false + foreach ($intentName in @( + "ShimInstallIntended", "ShimBackupIntended", + "AtomicCurrentInstallIntended", "AtomicCurrentBackupIntended", + "CurrentInstallIntended", "CurrentBackupIntended", + "VersionInstallIntended", "VersionBackupIntended", + "CurrentPathChangeIntended", "UserPathChangeIntended" + )) { + if ($Transaction[$intentName]) { + $rollbackIncomplete = $true + break + } + } + $Transaction.RollbackCompleted = -not $rollbackIncomplete +} + +function Remove-AtomicTransactionBackups { + param([hashtable]$Transaction) + + if ($null -eq $Transaction) { + return + } + + $shimBackupItem = Get-AtomicDirectoryEntry $Transaction.ShimBackupPath + if ($null -ne $shimBackupItem) { + try { Remove-Item -LiteralPath $shimBackupItem.FullName -Force } + catch { Write-Warning -Message "Installed successfully, but could not remove the previous shim backup: $_" -WarningAction Continue } + } + foreach ($backup in @( + @{ Name = "atomic-current"; Path = $Transaction.AtomicCurrentBackupPath }, + @{ Name = "current"; Path = $Transaction.CurrentBackupPath }, + @{ Name = "version"; Path = $Transaction.VersionBackupPath } + )) { + if ($null -ne (Get-AtomicDirectoryEntry $backup.Path)) { + try { Remove-AtomicDirectoryLinkOrTree $backup.Path } + catch { Write-Warning -Message "Installed successfully, but could not remove the previous $($backup.Name) backup: $_" -WarningAction Continue } + } + } +} + +$tempDir = $null +$versionStagePath = $null +$currentNextPath = $null +$atomicCurrentNextPath = $null +$shimNextPath = $null +$transaction = $null +$transactionCommitted = $false +$transactionMissingDirectories = New-Object System.Collections.ArrayList +$rollbackRetryLimit = 3 +$tempCleanupRetryLimit = 5 +$tempCleanupRetryDelayMilliseconds = 125 +$primaryError = $null +$tempCleanupError = $null + +$previousSecurityProtocol = [Net.ServicePointManager]::SecurityProtocol +try { + [Net.ServicePointManager]::SecurityProtocol = $previousSecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 +$requestedRef = $null +if ($PSBoundParameters.ContainsKey("Ref")) { + if ([string]::IsNullOrWhiteSpace($Ref)) { + throw "-Ref requires a non-empty release tag." + } + $requestedRef = $Ref +} +elseif (-not [string]::IsNullOrWhiteSpace($env:ATOMIC_VERSION)) { + $requestedRef = $env:ATOMIC_VERSION +} +if (-not [string]::IsNullOrWhiteSpace($requestedRef) -and -not (Test-AtomicReleaseTag $requestedRef)) { + throw "unsupported release tag: expected MAJOR.MINOR.PATCH or MAJOR.MINOR.PATCH-alpha.REVISION" +} + +$architecture = $env:PROCESSOR_ARCHITEW6432 +if ([string]::IsNullOrWhiteSpace($architecture)) { + $architecture = $env:PROCESSOR_ARCHITECTURE +} +if ([string]::IsNullOrWhiteSpace($architecture)) { + throw "Unable to determine the Windows processor architecture." +} + +switch ($architecture.ToUpperInvariant()) { + "AMD64" { $assetName = "atomic-windows-x64.zip" } + "X86_64" { $assetName = "atomic-windows-x64.zip" } + "ARM64" { $assetName = "atomic-windows-arm64.zip" } + default { throw "Unsupported Windows processor architecture: $architecture" } +} + +$installRoot = $env:ATOMIC_INSTALL_DIR +if ([string]::IsNullOrWhiteSpace($installRoot)) { + if ([string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) { + throw "LOCALAPPDATA is not set; set ATOMIC_INSTALL_DIR explicitly." + } + $installRoot = Join-Path $env:LOCALAPPDATA "atomic" +} +$installRoot = [IO.Path]::GetFullPath($installRoot) + +$binDir = $env:ATOMIC_BIN_DIR +if ([string]::IsNullOrWhiteSpace($binDir)) { + $binDir = Join-Path $installRoot "bin" +} +$binDir = [IO.Path]::GetFullPath($binDir) +$binDirHasPathSeparator = $binDir.Contains(";") +$atomicCurrentPath = Join-Path $binDir "atomic-current" +$shimPath = Join-Path $binDir "atomic.cmd" +$existingShimItem = Get-AtomicDirectoryEntry $shimPath +if ($null -ne $existingShimItem -and $existingShimItem.PSIsContainer) { + throw "ATOMIC_BIN_DIR contains an unexpected atomic.cmd directory; refusing to replace it." +} +$existingAtomicCurrentItem = Get-AtomicDirectoryEntry $atomicCurrentPath +if ($null -ne $existingAtomicCurrentItem -and + ($existingAtomicCurrentItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) { + throw "ATOMIC_BIN_DIR contains an unexpected atomic-current entry; refusing to replace it." +} +$currentPath = Join-Path $installRoot "current" +$existingCurrentItem = Get-AtomicDirectoryEntry $currentPath +if ($null -ne $existingCurrentItem -and + ($existingCurrentItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0) { + throw "ATOMIC_INSTALL_DIR contains an unexpected current entry; refusing to replace it." +} +foreach ($shadowingExtension in @(Get-AtomicShimShadowingExtensions)) { + $shadowingItem = Get-AtomicDirectoryEntry (Join-Path $binDir ("atomic" + $shadowingExtension)) + if ($null -ne $shadowingItem) { + throw "ATOMIC_BIN_DIR contains $($shadowingItem.Name), which PATHEXT resolves before atomic.cmd; remove it and rerun the installer." + } +} + +$apiHeaders = @{ Accept = "application/vnd.github+json" } +$token = $env:GITHUB_TOKEN +if ([string]::IsNullOrWhiteSpace($token)) { + $token = $env:GH_TOKEN +} +if (-not [string]::IsNullOrWhiteSpace($token)) { + $apiHeaders["Authorization"] = "Bearer $token" +} + +$latestApi = "https://api.github.com/repos/bastani-inc/atomic/releases/latest" +$tagsApiBase = "https://api.github.com/repos/bastani-inc/atomic/releases/tags" +$release = $null +$releaseTag = $null +if ([string]::IsNullOrWhiteSpace($requestedRef)) { + $redirectTag = Get-AtomicRedirectTag "https://github.com/bastani-inc/atomic/releases/latest" + if ([string]::IsNullOrWhiteSpace($redirectTag)) { + $release = Invoke-AtomicApiRequest $latestApi $apiHeaders + } + else { + $releaseTag = $redirectTag + } +} +else { + $encodedRequestedRef = [Uri]::EscapeDataString($requestedRef) + $release = Invoke-AtomicApiRequest "$tagsApiBase/$encodedRequestedRef" $apiHeaders +} + +if ([string]::IsNullOrWhiteSpace($releaseTag)) { + if ($null -eq $release -or $null -eq $release.PSObject.Properties["tag_name"]) { + throw "GitHub release API response did not include tag_name." + } + $releaseTag = [string]$release.tag_name + if ([string]::IsNullOrWhiteSpace($releaseTag)) { + throw "GitHub release API returned an empty tag_name." + } + if (-not [string]::IsNullOrWhiteSpace($requestedRef) -and $releaseTag -cne $requestedRef) { + throw "GitHub returned release $releaseTag for requested tag $requestedRef." + } +} + +if (-not (Test-AtomicReleaseTag $releaseTag)) { + throw "unsupported release tag: expected MAJOR.MINOR.PATCH or MAJOR.MINOR.PATCH-alpha.REVISION" +} +$encodedReleaseTag = [Uri]::EscapeDataString($releaseTag) +$releaseBase = "https://github.com/bastani-inc/atomic/releases/download/$encodedReleaseTag" +$tempDir = Join-Path ([IO.Path]::GetTempPath()) ("atomic-install-" + [Guid]::NewGuid().ToString("N")) +$archivePath = Join-Path $tempDir $assetName +$checksumsPath = Join-Path $tempDir "SHA256SUMS" +$payloadPath = Join-Path $tempDir "payload" + +try { + New-Item -ItemType Directory -Path $tempDir | Out-Null + Invoke-AtomicDownload "$releaseBase/$assetName" $archivePath + Invoke-AtomicDownload "$releaseBase/SHA256SUMS" $checksumsPath + + $checksumAssetRows = @() + $assetRowPattern = '(^|[ \t*])' + [regex]::Escape($assetName) + '[ \t]*$' + foreach ($line in (Get-Content -LiteralPath $checksumsPath)) { + if ($line -match $assetRowPattern) { + $checksumAssetRows += $line + } + } + if ($checksumAssetRows.Count -ne 1) { + throw "SHA256SUMS must contain exactly one row for $assetName." + } + + $checksumLine = $checksumAssetRows[0] + if ($checksumLine -notmatch '^([A-Fa-f0-9]{64}) ([ *])([^\\/\r\n]+)$' -or $Matches[3] -cne $assetName) { + throw "SHA256SUMS row for $assetName is malformed." + } + $expectedChecksum = $Matches[1].ToLowerInvariant() + $actualChecksum = (Get-AtomicFileSha256 $archivePath).ToLowerInvariant() + if ($actualChecksum -ne $expectedChecksum) { + throw "Checksum verification failed for $assetName (expected $expectedChecksum, got $actualChecksum)." + } + + New-Item -ItemType Directory -Path $payloadPath | Out-Null + Expand-Archive -LiteralPath $archivePath -DestinationPath $payloadPath -Force + $stagedAtomic = Join-Path $payloadPath "atomic.exe" + if (-not (Test-Path -LiteralPath $stagedAtomic -PathType Leaf)) { + throw "Release archive $assetName does not contain atomic.exe at its root." + } + + & $stagedAtomic "--version" + $stagedExitCode = $LASTEXITCODE + if ($stagedExitCode -ne 0) { + throw "Staged atomic.exe --version failed with exit code $stagedExitCode." + } + + $versionsDir = Join-Path $installRoot "versions" + $versionDirectoryName = [Uri]::EscapeDataString($releaseTag) + $versionPath = Join-Path $versionsDir $versionDirectoryName + $currentPath = Join-Path $installRoot "current" + $atomicCurrentPath = Join-Path $binDir "atomic-current" + $shimPath = Join-Path $binDir "atomic.cmd" + $transactionId = [Guid]::NewGuid().ToString("N") + $versionStagePath = Join-Path $versionsDir (".stage-" + $transactionId) + $versionBackupPath = Join-Path $versionsDir (".backup-" + $transactionId) + $currentNextPath = Join-Path $installRoot (".current-" + $transactionId) + $currentBackupPath = Join-Path $installRoot (".current-backup-" + $transactionId) + $atomicCurrentNextPath = Join-Path $binDir (".atomic-current-" + $transactionId) + $atomicCurrentBackupPath = Join-Path $binDir (".atomic-current-backup-" + $transactionId) + $shimNextPath = Join-Path $binDir (".atomic-" + $transactionId + ".cmd") + $shimBackupPath = Join-Path $binDir (".atomic-backup-" + $transactionId + ".cmd") + $oldUserPath = [Environment]::GetEnvironmentVariable("Path", "User") + $oldCurrentPath = $env:Path + + $transaction = @{ + VersionPath = $versionPath + VersionBackupPath = $versionBackupPath + CurrentPath = $currentPath + CurrentBackupPath = $currentBackupPath + AtomicCurrentPath = $atomicCurrentPath + AtomicCurrentBackupPath = $atomicCurrentBackupPath + ShimPath = $shimPath + ShimBackupPath = $shimBackupPath + OldUserPath = $oldUserPath + OldCurrentPath = $oldCurrentPath + VersionBackupIntended = $false + VersionInstallIntended = $false + CurrentBackupIntended = $false + CurrentInstallIntended = $false + AtomicCurrentBackupIntended = $false + AtomicCurrentInstallIntended = $false + ShimBackupIntended = $false + ShimInstallIntended = $false + UserPathChangeIntended = $false + CurrentPathChangeIntended = $false + RollbackCompleted = $false + } + + Add-AtomicMissingDirectoryPaths $transactionMissingDirectories $versionsDir + Add-AtomicMissingDirectoryPaths $transactionMissingDirectories $binDir + + try { + New-Item -ItemType Directory -Path $versionsDir -Force | Out-Null + New-Item -ItemType Directory -Path $versionStagePath | Out-Null + foreach ($payloadItem in (Get-ChildItem -LiteralPath $payloadPath -Force)) { + Copy-Item -LiteralPath $payloadItem.FullName -Destination $versionStagePath -Recurse -Force + } + + $previousVersionItem = Get-AtomicDirectoryEntry $versionPath + if ($null -ne $previousVersionItem) { + $transaction.VersionBackupIntended = $true + Move-Item -LiteralPath $previousVersionItem.FullName -Destination $versionBackupPath + } + $transaction.VersionInstallIntended = $true + Move-Item -LiteralPath $versionStagePath -Destination $versionPath + + New-Item -ItemType Junction -Path $currentNextPath -Target $versionPath | Out-Null + $previousCurrentItem = Get-AtomicDirectoryEntry $currentPath + if ($null -ne $previousCurrentItem) { + $transaction.CurrentBackupIntended = $true + Move-Item -LiteralPath $previousCurrentItem.FullName -Destination $currentBackupPath + } + $transaction.CurrentInstallIntended = $true + Move-Item -LiteralPath $currentNextPath -Destination $currentPath + + New-Item -ItemType Directory -Path $binDir -Force | Out-Null + New-Item -ItemType Junction -Path $atomicCurrentNextPath -Target $versionPath | Out-Null + $shimContent = "@echo off`r`n`"%~dp0atomic-current\atomic.exe`" %*`r`nexit /b %ERRORLEVEL%`r`n" + Set-Content -LiteralPath $shimNextPath -Value $shimContent -Encoding ASCII -NoNewline + + $previousAtomicCurrentItem = Get-AtomicDirectoryEntry $atomicCurrentPath + if ($null -ne $previousAtomicCurrentItem) { + $transaction.AtomicCurrentBackupIntended = $true + Move-Item -LiteralPath $previousAtomicCurrentItem.FullName -Destination $atomicCurrentBackupPath + } + $previousShimItem = Get-AtomicDirectoryEntry $shimPath + if ($null -ne $previousShimItem) { + $transaction.ShimBackupIntended = $true + Move-Item -LiteralPath $previousShimItem.FullName -Destination $shimBackupPath + } + $transaction.AtomicCurrentInstallIntended = $true + Move-Item -LiteralPath $atomicCurrentNextPath -Destination $atomicCurrentPath + $transaction.ShimInstallIntended = $true + Move-Item -LiteralPath $shimNextPath -Destination $shimPath + + if (-not $binDirHasPathSeparator -and -not (Test-AtomicPathContains $oldUserPath $binDir)) { + $newUserPath = if ([string]::IsNullOrWhiteSpace($oldUserPath)) { $binDir } else { "$oldUserPath;$binDir" } + $transaction.UserPathChangeIntended = $true + [Environment]::SetEnvironmentVariable("Path", $newUserPath, "User") + } + if (-not $binDirHasPathSeparator -and -not (Test-AtomicPathContains $env:Path $binDir)) { + $transaction.CurrentPathChangeIntended = $true + $env:Path = if ([string]::IsNullOrWhiteSpace($env:Path)) { $binDir } else { "$env:Path;$binDir" } + } + + $shimCommand = '"' + $shimPath + '" --version' + & $env:ComSpec /d /c $shimCommand + $finalExitCode = $LASTEXITCODE + if ($finalExitCode -ne 0) { + throw "Installed atomic.cmd --version failed with exit code $finalExitCode." + } + + $transactionCommitted = $true + Remove-AtomicTransactionBackups $transaction + } + catch { + $commitError = $_ + if (-not $transactionCommitted) { + Invoke-AtomicTransactionRollback $transaction + } + throw $commitError + } + + Write-Output "Atomic $releaseTag installed successfully." + Write-Output "Shim: $shimPath" + if ($binDirHasPathSeparator) { + Write-Output "ATOMIC_BIN_DIR contains ';' and cannot be represented as one Windows PATH entry." + Write-Output "Run Atomic directly: `"$shimPath`"" + Write-Output "Choose a semicolon-free ATOMIC_BIN_DIR to add Atomic to PATH." + } + else { + Write-Output "Restart your terminal so other processes pick up the updated User PATH." + } +} +catch { + $primaryError = $_ + throw $primaryError +} +finally { + if ($null -ne $transaction -and -not $transactionCommitted) { + $rollbackAttempt = 0 + while ($rollbackAttempt -lt $rollbackRetryLimit -and -not $transaction.RollbackCompleted) { + $rollbackAttempt++ + Invoke-AtomicTransactionRollback $transaction + } + if (-not $transaction.RollbackCompleted) { + Write-Warning -Message "Installation rollback remains incomplete after $rollbackRetryLimit final cleanup attempts; transaction backups were retained for recovery." -WarningAction Continue + } + } + if ($null -ne $transaction -and $transactionCommitted) { + Remove-AtomicTransactionBackups $transaction + } + + if ($null -ne $shimNextPath -and $null -ne (Get-AtomicDirectoryEntry $shimNextPath)) { + Remove-Item -LiteralPath $shimNextPath -Force -ErrorAction SilentlyContinue + } + if ($null -ne $atomicCurrentNextPath -and $null -ne (Get-AtomicDirectoryEntry $atomicCurrentNextPath)) { + try { Remove-AtomicDirectoryLinkOrTree $atomicCurrentNextPath } + catch { Write-Warning -Message "Failed to remove temporary atomic-current pointer ${atomicCurrentNextPath}: $_" -WarningAction Continue } + } + if ($null -ne $currentNextPath -and $null -ne (Get-AtomicDirectoryEntry $currentNextPath)) { + try { Remove-AtomicDirectoryLinkOrTree $currentNextPath } + catch { Write-Warning -Message "Failed to remove temporary current pointer ${currentNextPath}: $_" -WarningAction Continue } + } + if ($null -ne $versionStagePath -and $null -ne (Get-AtomicDirectoryEntry $versionStagePath)) { + Remove-Item -LiteralPath $versionStagePath -Recurse -Force -ErrorAction SilentlyContinue + } + if ($null -ne $tempDir -and (Test-Path -LiteralPath $tempDir)) { + try { + Remove-AtomicTemporaryDirectory $tempDir $tempCleanupRetryLimit $tempCleanupRetryDelayMilliseconds + } + catch { + $tempCleanupError = $_ + } + } + + if ($null -ne $transaction -and -not $transactionCommitted) { + Remove-AtomicCreatedEmptyDirectories $transactionMissingDirectories + } + + if ($null -ne $tempCleanupError) { + if ($null -ne $primaryError) { + Write-Warning -Message "Temporary download directory cleanup remains incomplete: $tempCleanupError" -WarningAction Continue + } + else { + throw $tempCleanupError + } + } +} +} +finally { + try { + [Net.ServicePointManager]::SecurityProtocol = $previousSecurityProtocol + } + catch { + Write-Warning -Message "Failed to restore the caller's TLS protocol setting: $_" -WarningAction Continue + } +} +} @args diff --git a/install.sh b/install.sh new file mode 100755 index 000000000..4c0ebce21 --- /dev/null +++ b/install.sh @@ -0,0 +1,958 @@ +#!/bin/sh + +set -eu +set -f + +LC_ALL=C +export LC_ALL + +REPOSITORY=bastani-inc/atomic +GITHUB_WEB=https://github.com +GITHUB_API=https://api.github.com +CHECKSUM_FILE=SHA256SUMS +NEWLINE=$(printf '\n_') +NEWLINE=${NEWLINE%_} + +usage() { + printf '%s\n' 'Atomic release archive installer + +Usage: + install.sh [--ref | --ref= | -r ] [--help] + +Options: + --ref Install the exact GitHub release tag. + --ref= Install the exact GitHub release tag. + -r Install the exact GitHub release tag. + --help Show this help and exit. + +Environment: + ATOMIC_VERSION Exact release tag when --ref is not supplied. + ATOMIC_INSTALL_DIR Installation root (default: $HOME/.local/share/atomic). + ATOMIC_BIN_DIR Directory containing the atomic link (default: $HOME/.local/bin). + GITHUB_TOKEN Optional GitHub API token (preferred over GH_TOKEN). + GH_TOKEN Optional GitHub API token.' +} + +fail() { + printf 'error: %s\n' "$*" >&2 + exit 1 +} + +normalize_absolute_path() { + normalize_remaining=${1#/} + normalize_result= + while [ -n "$normalize_remaining" ]; do + case $normalize_remaining in + */*) + normalize_segment=${normalize_remaining%%/*} + normalize_remaining=${normalize_remaining#*/} + ;; + *) + normalize_segment=$normalize_remaining + normalize_remaining= + ;; + esac + case $normalize_segment in + ''|.) ;; + ..) + case $normalize_result in + */*) normalize_result=${normalize_result%/*} ;; + *) normalize_result= ;; + esac + ;; + *) + if [ -n "$normalize_result" ]; then + normalize_result=$normalize_result/$normalize_segment + else + normalize_result=$normalize_segment + fi + ;; + esac + done + if [ -n "$normalize_result" ]; then + printf '/%s' "$normalize_result" + else + printf '/' + fi +} + +canonicalize_existing_prefix() { + canonical_input=$1 + canonical_probe=$canonical_input + canonical_suffix= + + while [ ! -d "$canonical_probe" ]; do + canonical_segment=${canonical_probe##*/} + if [ -n "$canonical_suffix" ]; then + canonical_suffix=$canonical_segment/$canonical_suffix + else + canonical_suffix=$canonical_segment + fi + canonical_parent=${canonical_probe%/*} + [ -n "$canonical_parent" ] || canonical_parent=/ + [ "$canonical_parent" != "$canonical_probe" ] || break + canonical_probe=$canonical_parent + done + + canonical_physical=$(CDPATH= cd -P "$canonical_probe" 2>/dev/null && pwd && printf '_') || return 1 + canonical_physical=${canonical_physical%_} + canonical_physical=${canonical_physical%"$NEWLINE"} + if [ -n "$canonical_suffix" ]; then + normalize_absolute_path "$canonical_physical/$canonical_suffix" + else + normalize_absolute_path "$canonical_physical" + fi +} + +reject_dangling_symlink_path() { + dangling_input=$1 + dangling_label=${2:-ATOMIC_BIN_DIR} + dangling_probe=/ + dangling_remaining=${dangling_input#/} + while [ -n "$dangling_remaining" ]; do + case $dangling_remaining in + */*) + dangling_segment=${dangling_remaining%%/*} + dangling_remaining=${dangling_remaining#*/} + ;; + *) + dangling_segment=$dangling_remaining + dangling_remaining= + ;; + esac + case $dangling_segment in + ''|.) ;; + ..) + case $dangling_probe in + /) ;; + *) + dangling_probe=${dangling_probe%/*} + [ -n "$dangling_probe" ] || dangling_probe=/ + ;; + esac + ;; + *) + case $dangling_probe in + /) dangling_probe=/$dangling_segment ;; + *) dangling_probe=$dangling_probe/$dangling_segment ;; + esac + if [ -L "$dangling_probe" ] && [ ! -d "$dangling_probe" ]; then + fail "$dangling_label contains an unresolved symbolic link; refusing an unresolvable path: $dangling_probe" + fi + ;; + esac + done +} + +percent_encode() { + percent_input=$1 + percent_output= + while [ -n "$percent_input" ]; do + percent_character=${percent_input%"${percent_input#?}"} + percent_input=${percent_input#?} + case $percent_character in + [abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._~-]) + percent_output=$percent_output$percent_character + ;; + *) + percent_decimal=$(printf '%d' "'$percent_character") + if [ "$percent_decimal" -lt 0 ]; then + percent_decimal=$((percent_decimal + 256)) + fi + percent_output=$percent_output%$(printf '%02X' "$percent_decimal") + ;; + esac + done + printf '%s\n' "$percent_output" +} + +hex_nibble() { + case $1 in + 0|1|2|3|4|5|6|7|8|9) printf '%s' "$1" ;; + A|a) printf 10 ;; + B|b) printf 11 ;; + C|c) printf 12 ;; + D|d) printf 13 ;; + E|e) printf 14 ;; + F|f) printf 15 ;; + *) return 1 ;; + esac +} + +percent_decode() { + decode_input=$1 + decode_output= + while [ -n "$decode_input" ]; do + decode_character=${decode_input%"${decode_input#?}"} + if [ "$decode_character" = % ]; then + decode_after_percent=${decode_input#?} + decode_high=${decode_after_percent%"${decode_after_percent#?}"} + decode_after_high=${decode_after_percent#?} + decode_low=${decode_after_high%"${decode_after_high#?}"} + [ -n "$decode_high" ] && [ -n "$decode_low" ] || return 1 + decode_high_value=$(hex_nibble "$decode_high") || return 1 + decode_low_value=$(hex_nibble "$decode_low") || return 1 + decode_decimal=$((decode_high_value * 16 + decode_low_value)) + [ "$decode_decimal" -ne 0 ] || return 1 + decode_octal=$(printf '%03o' "$decode_decimal") + decode_output=$decode_output$(printf "\\$decode_octal") + decode_input=${decode_after_high#?} + else + decode_output=$decode_output$decode_character + decode_input=${decode_input#?} + fi + done + printf '%s\n' "$decode_output" +} + +REQUESTED_REF=${ATOMIC_VERSION:-} +while [ "$#" -gt 0 ]; do + case $1 in + --ref) + shift + [ "$#" -gt 0 ] || fail "--ref requires a release tag" + [ -n "$1" ] || fail "--ref requires a non-empty release tag" + REQUESTED_REF=$1 + ;; + --ref=*) + REQUESTED_REF=${1#--ref=} + [ -n "$REQUESTED_REF" ] || fail "--ref requires a non-empty release tag" + ;; + -r) + shift + [ "$#" -gt 0 ] || fail "-r requires a release tag" + [ -n "$1" ] || fail "-r requires a non-empty release tag" + REQUESTED_REF=$1 + ;; + --help|-h) + usage + exit 0 + ;; + *) + fail "unknown option: $1" + ;; + esac + shift +done + +START_WORKING_DIR=$(pwd -P && printf '_') || fail "unable to resolve the current working directory" +START_WORKING_DIR=${START_WORKING_DIR%_} +START_WORKING_DIR=${START_WORKING_DIR%"$NEWLINE"} +INSTALL_ROOT=${ATOMIC_INSTALL_DIR:-${HOME:?HOME is not set}/.local/share/atomic} +BIN_DIR=${ATOMIC_BIN_DIR:-${HOME:?HOME is not set}/.local/bin} +case $INSTALL_ROOT in + /*) ;; + *) INSTALL_ROOT=$START_WORKING_DIR/$INSTALL_ROOT ;; +esac +case $BIN_DIR in + /*) ;; + *) BIN_DIR=$START_WORKING_DIR/$BIN_DIR ;; +esac +INSTALL_ROOT=$(normalize_absolute_path "$INSTALL_ROOT" && printf '_') +INSTALL_ROOT=${INSTALL_ROOT%_} +BIN_DIR=$(normalize_absolute_path "$BIN_DIR" && printf '_') +BIN_DIR=${BIN_DIR%_} +reject_dangling_symlink_path "$INSTALL_ROOT" ATOMIC_INSTALL_DIR +reject_dangling_symlink_path "$BIN_DIR" ATOMIC_BIN_DIR +BIN_PATH=$BIN_DIR/atomic +PHYSICAL_INSTALL_ROOT=$(canonicalize_existing_prefix "$INSTALL_ROOT" && printf '_') || + fail "unable to resolve ATOMIC_INSTALL_DIR: $INSTALL_ROOT" +PHYSICAL_INSTALL_ROOT=${PHYSICAL_INSTALL_ROOT%_} +PHYSICAL_BIN_PATH=$(canonicalize_existing_prefix "$BIN_PATH" && printf '_') || + fail "unable to resolve ATOMIC_BIN_DIR/atomic: $BIN_PATH" +PHYSICAL_BIN_PATH=${PHYSICAL_BIN_PATH%_} +case $PHYSICAL_INSTALL_ROOT/ in + "$PHYSICAL_BIN_PATH/"*) + fail "ATOMIC_INSTALL_DIR cannot equal ATOMIC_BIN_DIR/atomic or be inside that launcher path: $INSTALL_ROOT" + ;; +esac +for owned_child in current versions; do + for owned_root in "$INSTALL_ROOT" "$PHYSICAL_INSTALL_ROOT"; do + case $owned_root in + /) owned_path=/$owned_child ;; + *) owned_path=$owned_root/$owned_child ;; + esac + for owned_candidate in "$BIN_PATH" "$PHYSICAL_BIN_PATH"; do + case $owned_candidate in + "$owned_path"|"$owned_path"/*) + fail "ATOMIC_BIN_DIR cannot be inside ATOMIC_INSTALL_DIR/$owned_child; the installer replaces that path: $BIN_DIR" + ;; + esac + done + done +done +if [ -d "$BIN_PATH" ] && [ ! -L "$BIN_PATH" ]; then + fail "ATOMIC_BIN_DIR/atomic is an unexpected directory; refusing to replace it: $BIN_PATH" +fi + +for required_command in uname tar mkdir mv chmod ln rm rmdir; do + command -v "$required_command" >/dev/null 2>&1 || fail "required command not found: $required_command" +done + +if command -v curl >/dev/null 2>&1; then + DOWNLOADER=curl +elif command -v wget >/dev/null 2>&1; then + DOWNLOADER=wget +else + fail "curl or wget is required" +fi + +if command -v sha256sum >/dev/null 2>&1; then + CHECKSUM_TOOL=sha256sum +elif command -v shasum >/dev/null 2>&1; then + CHECKSUM_TOOL=shasum +elif command -v openssl >/dev/null 2>&1; then + CHECKSUM_TOOL=openssl +else + fail "sha256sum, shasum, or openssl is required" +fi + +HOST_OS=$(uname -s 2>/dev/null) || fail "unable to determine the host operating system" +HOST_MACHINE=$(uname -m 2>/dev/null) || fail "unable to determine the host architecture" + +if [ "$HOST_OS" = Darwin ]; then + if command -v sysctl >/dev/null 2>&1; then + arm64_capable=$(sysctl -in hw.optional.arm64 2>/dev/null || :) + elif [ -x /usr/sbin/sysctl ]; then + arm64_capable=$(/usr/sbin/sysctl -in hw.optional.arm64 2>/dev/null || :) + else + arm64_capable= + fi + if [ "$arm64_capable" = 1 ]; then + HOST_MACHINE=arm64 + fi +fi + +case $HOST_MACHINE in + x86_64|amd64) HOST_ARCH=x64 ;; + arm64|aarch64) HOST_ARCH=arm64 ;; + *) fail "unsupported architecture: $HOST_MACHINE" ;; +esac + +case $HOST_OS in + Darwin) + case $HOST_ARCH in + arm64) ASSET_NAME=atomic-darwin-arm64.tar.gz ;; + x64) ASSET_NAME=atomic-darwin-x64.tar.gz ;; + esac + ;; + Linux) + if [ -n "${ANDROID_ROOT:-}" ] || [ -n "${ANDROID_DATA:-}" ] || [ -n "${TERMUX_VERSION:-}" ] || + [ -e /system/bin/linker ] || [ -e /system/bin/linker64 ]; then + fail "unsupported Linux libc: bionic" + fi + + if [ -f /etc/alpine-release ]; then + LIBC_SUFFIX=-musl + else + command -v ldd >/dev/null 2>&1 || fail "unable to identify Linux libc: ldd not found" + ldd_version=$(ldd --version 2>&1 || :) + case $ldd_version in + *uClibc*|*uclibc*|*UCLIBC*) fail "unsupported Linux libc: uClibc" ;; + *bionic*|*Bionic*|*BIONIC*) fail "unsupported Linux libc: bionic" ;; + *musl*|*Musl*|*MUSL*) LIBC_SUFFIX=-musl ;; + *GLIBC*|*glibc*|*GNU\ libc*|*GNU\ C\ Library*) LIBC_SUFFIX= ;; + *) fail "unsupported Linux libc: unknown" ;; + esac + fi + case "$HOST_ARCH$LIBC_SUFFIX" in + x64) ASSET_NAME=atomic-linux-x64.tar.gz ;; + arm64) ASSET_NAME=atomic-linux-arm64.tar.gz ;; + x64-musl) ASSET_NAME=atomic-linux-x64-musl.tar.gz ;; + arm64-musl) ASSET_NAME=atomic-linux-arm64-musl.tar.gz ;; + esac + ;; + *) + fail "unsupported operating system: $HOST_OS" + ;; +esac + +VERSIONS_DIR= +CURRENT_PATH= + +set -- "${GITHUB_TOKEN:-}" "${GH_TOKEN:-}" +unset GITHUB_TOKEN GH_TOKEN TOKEN +TOKEN=$1 +if [ -z "$TOKEN" ]; then + TOKEN=$2 +fi +set -- +token_line_feed=$(printf '\n_') +token_line_feed=${token_line_feed%_} +token_carriage_return=$(printf '\r_') +token_carriage_return=${token_carriage_return%_} +case $TOKEN in + *"$token_line_feed"*|*"$token_carriage_return"*) fail "GitHub API token contains a forbidden newline" ;; +esac + +ORIGINAL_UMASK=$(umask) +umask 077 +TEMP_BASE=${TMPDIR:-/tmp}/atomic-install.$$ +TEMP_DIR=$TEMP_BASE +TEMP_ATTEMPT=0 +while ! mkdir "$TEMP_DIR" 2>/dev/null; do + TEMP_ATTEMPT=$((TEMP_ATTEMPT + 1)) + [ "$TEMP_ATTEMPT" -lt 100 ] || fail "unable to create a temporary directory under ${TMPDIR:-/tmp}" + TEMP_DIR=$TEMP_BASE.$TEMP_ATTEMPT +done + +TRANSACTION_ID=$$.$TEMP_ATTEMPT +ARCHIVE_PATH=$TEMP_DIR/$ASSET_NAME +CHECKSUM_PATH=$TEMP_DIR/$CHECKSUM_FILE +API_AUTH_PATH=$TEMP_DIR/github-api-auth +EXTRACT_ROOT=$TEMP_DIR/extract +PAYLOAD_ROOT= +VERSION_PATH= +VERSION_STAGE= +VERSION_BACKUP= +CURRENT_NEXT= +CURRENT_BACKUP= +BIN_NEXT= +BIN_BACKUP= +VERSION_INSTALLED=0 +VERSION_BACKED_UP=0 +CURRENT_INSTALLED=0 +CURRENT_BACKED_UP=0 +BIN_INSTALLED=0 +BIN_BACKED_UP=0 +INSTALL_COMMITTED=0 +CREATED_INSTALL_ROOT=0 +CREATED_VERSIONS_DIR=0 +CREATED_BIN_DIR=0 +INSTALL_DIRECTORY_STOP= +BIN_DIRECTORY_STOP= +ROLLBACK_RETRY_LIMIT=3 + +path_exists() { + [ -e "$1" ] || [ -L "$1" ] +} + +nearest_existing_directory() { + existing_candidate=$1 + while [ ! -d "$existing_candidate" ]; do + existing_parent=${existing_candidate%/*} + [ -n "$existing_parent" ] || existing_parent=/ + [ "$existing_parent" != "$existing_candidate" ] || return 1 + existing_candidate=$existing_parent + done + printf '%s' "$existing_candidate" +} + +remove_created_empty_path() { + created_candidate=$1 + created_stop=$2 + while [ -n "$created_candidate" ] && [ "$created_candidate" != "$created_stop" ]; do + if ! rmdir "$created_candidate" 2>/dev/null; then + return + fi + created_parent=${created_candidate%/*} + [ -n "$created_parent" ] || created_parent=/ + [ "$created_parent" != "$created_candidate" ] || return + created_candidate=$created_parent + done +} + +rollback_once() { + rollback_incomplete=0 + + if [ "$BIN_INSTALLED" -eq 1 ]; then + if ! path_exists "$BIN_PATH"; then + BIN_INSTALLED=0 + elif rm -rf "$BIN_PATH"; then + BIN_INSTALLED=0 + else + printf 'warning: failed to remove the unsuccessful atomic launcher: %s\n' "$BIN_PATH" >&2 + rollback_incomplete=1 + fi + fi + if [ "$BIN_BACKED_UP" -eq 1 ]; then + if path_exists "$BIN_BACKUP" && ! path_exists "$BIN_PATH"; then + if mv "$BIN_BACKUP" "$BIN_PATH"; then + BIN_BACKED_UP=0 + else + printf 'warning: failed to restore the previous atomic launcher: %s\n' "$BIN_PATH" >&2 + rollback_incomplete=1 + fi + elif ! path_exists "$BIN_BACKUP" && path_exists "$BIN_PATH"; then + BIN_BACKED_UP=0 + else + printf 'warning: previous atomic launcher restore remains incomplete: %s\n' "$BIN_PATH" >&2 + rollback_incomplete=1 + fi + fi + + if [ "$CURRENT_INSTALLED" -eq 1 ]; then + if ! path_exists "$CURRENT_PATH"; then + CURRENT_INSTALLED=0 + elif rm -rf "$CURRENT_PATH"; then + CURRENT_INSTALLED=0 + else + printf 'warning: failed to remove the unsuccessful current pointer: %s\n' "$CURRENT_PATH" >&2 + rollback_incomplete=1 + fi + fi + if [ "$CURRENT_BACKED_UP" -eq 1 ]; then + if path_exists "$CURRENT_BACKUP" && ! path_exists "$CURRENT_PATH"; then + if mv "$CURRENT_BACKUP" "$CURRENT_PATH"; then + CURRENT_BACKED_UP=0 + else + printf 'warning: failed to restore the previous current pointer: %s\n' "$CURRENT_PATH" >&2 + rollback_incomplete=1 + fi + elif ! path_exists "$CURRENT_BACKUP" && path_exists "$CURRENT_PATH"; then + CURRENT_BACKED_UP=0 + else + printf 'warning: previous current pointer restore remains incomplete: %s\n' "$CURRENT_PATH" >&2 + rollback_incomplete=1 + fi + fi + + if [ "$VERSION_INSTALLED" -eq 1 ]; then + if ! path_exists "$VERSION_PATH"; then + VERSION_INSTALLED=0 + elif rm -rf "$VERSION_PATH"; then + VERSION_INSTALLED=0 + else + printf 'warning: failed to remove the unsuccessful version: %s\n' "$VERSION_PATH" >&2 + rollback_incomplete=1 + fi + fi + if [ "$VERSION_BACKED_UP" -eq 1 ]; then + if path_exists "$VERSION_BACKUP" && ! path_exists "$VERSION_PATH"; then + if mv "$VERSION_BACKUP" "$VERSION_PATH"; then + VERSION_BACKED_UP=0 + else + printf 'warning: failed to restore the previous version: %s\n' "$VERSION_PATH" >&2 + rollback_incomplete=1 + fi + elif ! path_exists "$VERSION_BACKUP" && path_exists "$VERSION_PATH"; then + VERSION_BACKED_UP=0 + else + printf 'warning: previous version restore remains incomplete: %s\n' "$VERSION_PATH" >&2 + rollback_incomplete=1 + fi + fi +} + +cleanup() { + cleanup_status=$? + set +e + + if [ "$INSTALL_COMMITTED" -ne 1 ]; then + rollback_attempt=0 + rollback_incomplete=1 + while [ "$rollback_attempt" -lt "$ROLLBACK_RETRY_LIMIT" ] && [ "$rollback_incomplete" -eq 1 ]; do + rollback_attempt=$((rollback_attempt + 1)) + rollback_once + done + if [ "$rollback_incomplete" -eq 1 ]; then + printf 'warning: installation rollback remains incomplete after %s attempts; backups were retained for recovery.\n' "$ROLLBACK_RETRY_LIMIT" >&2 + cleanup_status=1 + fi + + if [ -n "$BIN_NEXT" ] && path_exists "$BIN_NEXT"; then + rm -rf "$BIN_NEXT" || printf 'warning: failed to remove temporary launcher: %s\n' "$BIN_NEXT" >&2 + fi + if [ -n "$CURRENT_NEXT" ] && path_exists "$CURRENT_NEXT"; then + rm -rf "$CURRENT_NEXT" || printf 'warning: failed to remove temporary current pointer: %s\n' "$CURRENT_NEXT" >&2 + fi + if [ -n "$VERSION_STAGE" ] && path_exists "$VERSION_STAGE"; then + rm -rf "$VERSION_STAGE" || printf 'warning: failed to remove staged version: %s\n' "$VERSION_STAGE" >&2 + fi + + if [ "$CREATED_BIN_DIR" -eq 1 ]; then + remove_created_empty_path "$BIN_DIR" "$BIN_DIRECTORY_STOP" + fi + if [ "$CREATED_VERSIONS_DIR" -eq 1 ]; then + rmdir "$VERSIONS_DIR" 2>/dev/null || : + fi + if [ "$CREATED_INSTALL_ROOT" -eq 1 ]; then + remove_created_empty_path "$INSTALL_ROOT" "$INSTALL_DIRECTORY_STOP" + fi + fi + + rm -rf "$TEMP_DIR" + trap - 0 HUP INT TERM + exit "$cleanup_status" +} + +trap cleanup 0 +trap 'exit 1' HUP INT TERM + +prepare_api_auth() { + [ -n "$TOKEN" ] || return 0 + if [ "$DOWNLOADER" = curl ]; then + printf 'Authorization: Bearer %s\n' "$TOKEN" > "$API_AUTH_PATH" + else + wget_version=$(wget --version 2>/dev/null || :) + case $wget_version in + *'GNU Wget'*) ;; + *) fail "authenticated GitHub API requests require curl or GNU Wget; this wget cannot protect the token" ;; + esac + printf 'header = Authorization: Bearer %s\n' "$TOKEN" > "$API_AUTH_PATH" + fi + chmod 600 "$API_AUTH_PATH" +} + +clear_api_auth() { + rm -f "$API_AUTH_PATH" + unset TOKEN +} + +http_get() { + http_url=$1 + if [ "$DOWNLOADER" = curl ]; then + if [ -n "$TOKEN" ]; then + curl -fsSL -H 'Accept: application/vnd.github+json' -H "@$API_AUTH_PATH" "$http_url" + else + curl -fsSL -H 'Accept: application/vnd.github+json' "$http_url" + fi + else + if [ -n "$TOKEN" ]; then + WGETRC="$API_AUTH_PATH" wget -q -O - --header='Accept: application/vnd.github+json' "$http_url" + else + wget -q -O - --header='Accept: application/vnd.github+json' "$http_url" + fi + fi +} + +download_file() { + download_url=$1 + download_destination=$2 + if [ "$DOWNLOADER" = curl ]; then + curl -fsSL -o "$download_destination" "$download_url" + else + wget -q -O "$download_destination" "$download_url" + fi +} + +tag_from_release_url() { + release_url=$1 + case $release_url in + */releases/tag/*) + resolved_url_tag=${release_url##*/releases/tag/} + resolved_url_tag=${resolved_url_tag%%\?*} + resolved_url_tag=${resolved_url_tag%%\#*} + [ -n "$resolved_url_tag" ] || return 1 + percent_decode "$resolved_url_tag" + ;; + *) return 1 ;; + esac +} + +resolve_redirect_tag() { + latest_url=$GITHUB_WEB/$REPOSITORY/releases/latest + if [ "$DOWNLOADER" = curl ]; then + if latest_effective_url=$(curl -fsSL -o /dev/null -w '%{url_effective}' "$latest_url" 2>/dev/null); then + tag_from_release_url "$latest_effective_url" + return + fi + return 1 + fi + + latest_headers=$TEMP_DIR/latest-headers + if ! wget -S --spider "$latest_url" > /dev/null 2>"$latest_headers"; then + return 1 + fi + latest_location= + while IFS= read -r header_line || [ -n "$header_line" ]; do + case $header_line in + *Location:*|*location:*) + header_value=${header_line#*:} + for header_word in $header_value; do + latest_location=$header_word + break + done + ;; + esac + done < "$latest_headers" + [ -n "$latest_location" ] || return 1 + tag_from_release_url "$latest_location" +} + +is_release_number() { + case $1 in + ''|*[!0123456789]*) return 1 ;; + 0|[123456789]*) return 0 ;; + *) return 1 ;; + esac +} + +is_atomic_release_tag() { + release_version=$1 + case $release_version in + *-alpha.*) + release_revision=${release_version##*-alpha.} + release_core=${release_version%-alpha.*} + is_release_number "$release_revision" || return 1 + [ "$release_revision" != 0 ] || return 1 + ;; + *-*) return 1 ;; + *) release_core=$release_version ;; + esac + + release_ifs=$IFS + IFS=. + set -- $release_core + IFS=$release_ifs + [ "$#" -eq 3 ] || return 1 + is_release_number "$1" && is_release_number "$2" && is_release_number "$3" +} + +parse_release_tag() { + release_json=$1 + case $release_json in + *\"tag_name\"*) release_json_tail=${release_json#*\"tag_name\"} ;; + *) return 1 ;; + esac + + release_tab=$(printf '\t_') + release_tab=${release_tab%_} + release_line_feed=$(printf '\n_') + release_line_feed=${release_line_feed%_} + release_carriage_return=$(printf '\r_') + release_carriage_return=${release_carriage_return%_} + while [ -n "$release_json_tail" ]; do + release_character=${release_json_tail%"${release_json_tail#?}"} + case $release_character in + ' '|"$release_tab"|"$release_line_feed"|"$release_carriage_return") + release_json_tail=${release_json_tail#?} + ;; + *) break ;; + esac + done + case $release_json_tail in + :*) release_json_tail=${release_json_tail#?} ;; + *) return 1 ;; + esac + while [ -n "$release_json_tail" ]; do + release_character=${release_json_tail%"${release_json_tail#?}"} + case $release_character in + ' '|"$release_tab"|"$release_line_feed"|"$release_carriage_return") + release_json_tail=${release_json_tail#?} + ;; + *) break ;; + esac + done + case $release_json_tail in + \"*) release_json_tail=${release_json_tail#?} ;; + *) return 1 ;; + esac + case $release_json_tail in + *\"*) parsed_release_tag=${release_json_tail%%\"*} ;; + *) return 1 ;; + esac + is_atomic_release_tag "$parsed_release_tag" || return 1 + printf '%s\n' "$parsed_release_tag" +} + +TAGS_API=$GITHUB_API/repos/$REPOSITORY/releases/tags +RELEASE_TAG= +RELEASE_TAG_ENCODED= +API_URL= +if [ -n "$REQUESTED_REF" ]; then + is_atomic_release_tag "$REQUESTED_REF" || + fail "unsupported release tag: expected MAJOR.MINOR.PATCH or MAJOR.MINOR.PATCH-alpha.REVISION" + REQUESTED_REF_ENCODED=$(percent_encode "$REQUESTED_REF") + API_URL=$TAGS_API/$REQUESTED_REF_ENCODED +else + REDIRECT_TAG= + if REDIRECT_TAG=$(resolve_redirect_tag); then + is_atomic_release_tag "$REDIRECT_TAG" || + fail "latest release redirect returned an unsupported Atomic release tag" + RELEASE_TAG=$REDIRECT_TAG + else + API_URL=$GITHUB_API/repos/$REPOSITORY/releases/latest + fi +fi + +if [ -z "$RELEASE_TAG" ]; then + prepare_api_auth + if ! RELEASE_JSON=$(http_get "$API_URL"); then + fail "failed to resolve the GitHub release" + fi + if ! RELEASE_TAG=$(parse_release_tag "$RELEASE_JSON"); then + fail "GitHub release response did not contain a valid tag_name" + fi + if [ -n "$REQUESTED_REF" ] && [ "$RELEASE_TAG" != "$REQUESTED_REF" ]; then + fail "GitHub returned release $RELEASE_TAG for requested tag $REQUESTED_REF" + fi +fi +clear_api_auth +RELEASE_TAG_ENCODED=$(percent_encode "$RELEASE_TAG") +case $RELEASE_TAG_ENCODED in + ''|.|..) fail "release tag cannot be used as a version directory: $RELEASE_TAG" ;; +esac + +RELEASE_BASE=$GITHUB_WEB/$REPOSITORY/releases/download/$RELEASE_TAG_ENCODED +if ! download_file "$RELEASE_BASE/$ASSET_NAME" "$ARCHIVE_PATH"; then + fail "failed to download release asset: $ASSET_NAME" +fi +if ! download_file "$RELEASE_BASE/$CHECKSUM_FILE" "$CHECKSUM_PATH"; then + fail "failed to download $CHECKSUM_FILE" +fi + +EXPECTED_CHECKSUM= +CHECKSUM_MATCHES=0 +while IFS= read -r checksum_line || [ -n "$checksum_line" ]; do + set -- $checksum_line + [ "$#" -ge 2 ] || continue + checksum_value=$1 + checksum_name=$2 + case $checksum_name in + \**) checksum_name=${checksum_name#\*} ;; + esac + if [ "$checksum_name" = "$ASSET_NAME" ]; then + CHECKSUM_MATCHES=$((CHECKSUM_MATCHES + 1)) + [ "$#" -eq 2 ] || fail "$CHECKSUM_FILE row for $ASSET_NAME is malformed" + [ "${#checksum_value}" -eq 64 ] || fail "$CHECKSUM_FILE row for $ASSET_NAME is malformed" + case $checksum_value in + *[!0123456789abcdef]*|'') fail "$CHECKSUM_FILE row for $ASSET_NAME is malformed" ;; + esac + EXPECTED_CHECKSUM=$checksum_value + fi +done < "$CHECKSUM_PATH" +[ "$CHECKSUM_MATCHES" -eq 1 ] || fail "$CHECKSUM_FILE must contain exactly one row for $ASSET_NAME" + +case $CHECKSUM_TOOL in + sha256sum) checksum_output=$(sha256sum "$ARCHIVE_PATH") || fail "failed to hash $ASSET_NAME" ;; + shasum) checksum_output=$(shasum -a 256 "$ARCHIVE_PATH") || fail "failed to hash $ASSET_NAME" ;; + openssl) checksum_output=$(openssl dgst -sha256 "$ARCHIVE_PATH") || fail "failed to hash $ASSET_NAME" ;; +esac +set -- $checksum_output +if [ "$CHECKSUM_TOOL" = openssl ]; then + ACTUAL_CHECKSUM= + for checksum_word in "$@"; do + ACTUAL_CHECKSUM=$checksum_word + done +else + ACTUAL_CHECKSUM=$1 +fi +[ "$ACTUAL_CHECKSUM" = "$EXPECTED_CHECKSUM" ] || fail "checksum verification failed for $ASSET_NAME" + +umask "$ORIGINAL_UMASK" +mkdir "$EXTRACT_ROOT" +if ! tar -xzf "$ARCHIVE_PATH" -C "$EXTRACT_ROOT"; then + fail "failed to extract release asset: $ASSET_NAME" +fi +PAYLOAD_ROOT=$EXTRACT_ROOT/atomic +[ -d "$PAYLOAD_ROOT" ] || fail "release asset $ASSET_NAME does not contain the top-level atomic directory" +[ -f "$PAYLOAD_ROOT/atomic" ] || fail "release asset $ASSET_NAME does not contain atomic/atomic" +chmod +x "$PAYLOAD_ROOT/atomic" +if ! "$PAYLOAD_ROOT/atomic" --version >/dev/null; then + fail "staged atomic --version check failed" +fi + +VERSIONS_DIR=$INSTALL_ROOT/versions +CURRENT_PATH=$INSTALL_ROOT/current + +INSTALL_DIRECTORY_STOP=$(nearest_existing_directory "$INSTALL_ROOT" && printf '_') || + fail "unable to find an existing parent for ATOMIC_INSTALL_DIR: $INSTALL_ROOT" +INSTALL_DIRECTORY_STOP=${INSTALL_DIRECTORY_STOP%_} +BIN_DIRECTORY_STOP=$(nearest_existing_directory "$BIN_DIR" && printf '_') || + fail "unable to find an existing parent for ATOMIC_BIN_DIR: $BIN_DIR" +BIN_DIRECTORY_STOP=${BIN_DIRECTORY_STOP%_} + +if [ ! -d "$INSTALL_ROOT" ]; then + mkdir -p "$INSTALL_ROOT" + CREATED_INSTALL_ROOT=1 +fi +if [ ! -d "$VERSIONS_DIR" ]; then + mkdir -p "$VERSIONS_DIR" + CREATED_VERSIONS_DIR=1 +fi +if [ ! -d "$BIN_DIR" ]; then + mkdir -p "$BIN_DIR" + CREATED_BIN_DIR=1 +fi + +VERSION_PATH=$VERSIONS_DIR/$RELEASE_TAG_ENCODED +VERSION_STAGE=$VERSIONS_DIR/.stage-$TRANSACTION_ID +VERSION_BACKUP=$VERSIONS_DIR/.backup-$TRANSACTION_ID +CURRENT_NEXT=$INSTALL_ROOT/.current-$TRANSACTION_ID +CURRENT_BACKUP=$INSTALL_ROOT/.current-backup-$TRANSACTION_ID +BIN_NEXT=$BIN_DIR/.atomic-$TRANSACTION_ID +BIN_BACKUP=$BIN_DIR/.atomic-backup-$TRANSACTION_ID + +mv "$PAYLOAD_ROOT" "$VERSION_STAGE" +PAYLOAD_ROOT= +if path_exists "$VERSION_PATH"; then + VERSION_BACKED_UP=1 + mv "$VERSION_PATH" "$VERSION_BACKUP" +fi +VERSION_INSTALLED=1 +mv "$VERSION_STAGE" "$VERSION_PATH" +VERSION_STAGE= + +ln -s "versions/$RELEASE_TAG_ENCODED" "$CURRENT_NEXT" +if path_exists "$CURRENT_PATH"; then + CURRENT_BACKED_UP=1 + mv "$CURRENT_PATH" "$CURRENT_BACKUP" +fi +CURRENT_INSTALLED=1 +mv "$CURRENT_NEXT" "$CURRENT_PATH" +CURRENT_NEXT= + +ln -s "$CURRENT_PATH/atomic" "$BIN_NEXT" +if path_exists "$BIN_PATH"; then + BIN_BACKED_UP=1 + mv "$BIN_PATH" "$BIN_BACKUP" +fi +BIN_INSTALLED=1 +mv "$BIN_NEXT" "$BIN_PATH" +BIN_NEXT= + +if ! "$BIN_PATH" --version >/dev/null; then + fail "installed atomic --version check failed" +fi + +INSTALL_COMMITTED=1 +if [ "$BIN_BACKED_UP" -eq 1 ]; then + rm -rf "$BIN_BACKUP" || : + BIN_BACKED_UP=0 +fi +if [ "$CURRENT_BACKED_UP" -eq 1 ]; then + rm -rf "$CURRENT_BACKUP" || : + CURRENT_BACKED_UP=0 +fi +if [ "$VERSION_BACKED_UP" -eq 1 ]; then + rm -rf "$VERSION_BACKUP" || : + VERSION_BACKED_UP=0 +fi + +shell_quote() { + shell_quote_input=$1 + printf '%s' "'" + while [ -n "$shell_quote_input" ]; do + shell_quote_character=${shell_quote_input%"${shell_quote_input#?}"} + shell_quote_input=${shell_quote_input#?} + case $shell_quote_character in + "'") printf '%s' "'\\''" ;; + *) printf '%s' "$shell_quote_character" ;; + esac + done + printf '%s' "'" +} + +printf 'Atomic %s installed successfully.\n' "$RELEASE_TAG" +printf 'Binary: %s\n' "$BIN_PATH" +case $BIN_DIR in + *:*) + printf '%s\n' "ATOMIC_BIN_DIR contains ':' and cannot be represented as one POSIX PATH entry." + printf 'Run Atomic directly: ' + shell_quote "$BIN_PATH" + printf '\n' + printf '%s\n' "Choose a colon-free ATOMIC_BIN_DIR to add Atomic to PATH." + ;; + *) + case :${PATH:-}: in + *:"$BIN_DIR":*) ;; + *) + printf 'Add Atomic to PATH for this shell:\n' + printf ' export PATH=' + shell_quote "$BIN_DIR" + printf ':"$PATH"\n' + ;; + esac + ;; +esac diff --git a/packages/coding-agent/docs/containerization.md b/packages/coding-agent/docs/containerization.md index 62f9a3e5f..880fcc5eb 100644 --- a/packages/coding-agent/docs/containerization.md +++ b/packages/coding-agent/docs/containerization.md @@ -60,6 +60,23 @@ WORKDIR /workspace ENTRYPOINT ["atomic"] ``` +For an image without Node.js or npm, use the release-archive installer instead: + +```dockerfile +FROM debian:bookworm-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends bash ca-certificates curl git ripgrep tar \ + && rm -rf /var/lib/apt/lists/* +RUN curl -fsSL https://raw.githubusercontent.com/bastani-inc/atomic/main/install.sh | sh + +ENV PATH="/root/.local/bin:${PATH}" +WORKDIR /workspace +ENTRYPOINT ["atomic"] +``` + +The archive path installs the full prebuilt payload and needs no JavaScript runtime or package manager. The other packages in this example support Atomic's shell and common coding tasks. + Build and run: ```bash diff --git a/packages/coding-agent/docs/index.md b/packages/coding-agent/docs/index.md index 31192d24e..ece145e16 100644 --- a/packages/coding-agent/docs/index.md +++ b/packages/coding-agent/docs/index.md @@ -9,37 +9,33 @@ Atomic is the loop engine for all engineering work: a terminal coding-agent runt ## Quick start -Install Atomic globally with npm, pnpm, or Bun: - -With npm: +Install the self-contained release archive on macOS or Linux: ```bash -npm install -g @bastani/atomic +curl -fsSL https://raw.githubusercontent.com/bastani-inc/atomic/main/install.sh | sh ``` -With pnpm: +On Windows PowerShell: -```bash -pnpm add -g @bastani/atomic +```powershell +irm https://raw.githubusercontent.com/bastani-inc/atomic/main/install.ps1 | iex ``` -With Bun: +Archive installation does not require Node.js or a package manager. It verifies the GitHub Release checksum and installs the full payload under a versioned root. See the [Quickstart](/quickstart#release-archive) for exact-version flags, default paths, `ATOMIC_INSTALL_DIR`, `ATOMIC_BIN_DIR`, `ATOMIC_VERSION`, optional `GITHUB_TOKEN`/`GH_TOKEN`, and PATH guidance. + +Package installation still requires Node.js. With npm, pnpm, or Bun: ```bash +npm install -g @bastani/atomic +pnpm add -g @bastani/atomic bun add -g @bastani/atomic ``` -Atomic does not require package install scripts. If you want to disable dependency lifecycle scripts during the Atomic install, you can add `--ignore-scripts` to the install command. - -Or download an `atomic-*` archive from the Atomic GitHub Release for your platform. +Atomic does not require package install scripts. Add `--ignore-scripts` if you want to disable dependency lifecycle scripts during a package install. ### Alpine and musl Linux archives -Alpine Linux x64 and arm64 users can download `atomic-linux-x64-musl.tar.gz` or `atomic-linux-arm64-musl.tar.gz`. These archives include native search and PTY bindings. Install the required runtime libraries before running an archive: - -```bash -apk add --no-cache libgcc libstdc++ -``` +The shell installer detects Alpine and selects `atomic-linux-x64-musl.tar.gz` or `atomic-linux-arm64-musl.tar.gz`. Each archive includes its matching native search and PTY bindings plus payload-local `libgcc` and `libstdc++` runtimes. It runs on stock Alpine without installing runtime packages. The musl archives deliberately omit a clipboard native binding because `@mariozechner/clipboard` 0.3.9 publishes metadata-only musl stubs without a `.node` payload; Atomic uses Linux clipboard commands and OSC52 fallback instead. They also omit `@embedded-postgres/*` binary packages because those packages are glibc-linked. Durable workflows on Alpine therefore require external Postgres via `DBOS_SYSTEM_DATABASE_URL` or Docker; without a durable backend, Atomic uses a loud non-durable in-memory fallback. diff --git a/packages/coding-agent/docs/quickstart.md b/packages/coding-agent/docs/quickstart.md index 41090bed5..a25163514 100644 --- a/packages/coding-agent/docs/quickstart.md +++ b/packages/coding-agent/docs/quickstart.md @@ -4,37 +4,57 @@ This page gets you from install to a useful first Atomic session. Atomic is the ## Prerequisites -- **Node.js 24 LTS or newer** — Atomic requires the latest Node LTS runtime. Check with `node --version`. -- **A package manager** — use npm (included with Node), pnpm, Yarn, or Bun. Use Bun 1.3.14+ for Bun installs or workflow-authoring examples. +- **Release archive install:** macOS and Linux need `tar` and either `curl` or `wget`; Windows uses built-in PowerShell commands. Node.js and a package manager are not required. +- **Package install:** Node.js 24 LTS or newer plus npm, pnpm, Yarn, or Bun. Use Bun 1.3.14+ for Bun installs or workflow-authoring examples. - **Model-provider access** — Use `/login` after startup. Supports provider subscriptions and APIs. ## Install -Install the published package globally with npm, pnpm, or Bun: +### Release archive -With npm: +On macOS or Linux: ```bash -npm install -g @bastani/atomic +curl -fsSL https://raw.githubusercontent.com/bastani-inc/atomic/main/install.sh | sh +``` + +On Windows PowerShell: + +```powershell +irm https://raw.githubusercontent.com/bastani-inc/atomic/main/install.ps1 | iex ``` -With pnpm: +The default macOS/Linux paths are `~/.local/share/atomic` for versioned payloads and `~/.local/bin/atomic` for the launcher. The Windows defaults are `%LOCALAPPDATA%\atomic` and `%LOCALAPPDATA%\atomic\bin\atomic.cmd`. The Unix installer prints a paste-safe `export PATH=...` command if needed. A custom Unix `ATOMIC_BIN_DIR` containing `:` cannot be one PATH entry, so the installer prints direct-run guidance instead. The Windows installer updates the User PATH and current process, then asks you to restart the terminal. + +Use `ATOMIC_INSTALL_DIR` and `ATOMIC_BIN_DIR` to change those paths. Set `ATOMIC_VERSION` to an exact release tag, or pass a flag that overrides it: + +Relative `ATOMIC_INSTALL_DIR` and `ATOMIC_BIN_DIR` values on macOS/Linux resolve against the physical directory where the installer starts, and both are used exactly as given, including any trailing whitespace or newline. The install root cannot equal or sit inside the launcher path (`ATOMIC_BIN_DIR/atomic`), and `ATOMIC_BIN_DIR` cannot sit inside the install root's `current` or `versions` directories, which the installer replaces on every install; impossible layouts fail before any download or filesystem change. Exact pins use Atomic's `MAJOR.MINOR.PATCH` or `MAJOR.MINOR.PATCH-alpha.REVISION` release tag form, and a pin is honored literally: if GitHub answers with a different release tag, the installer stops before downloading anything rather than installing a version you did not ask for. ```bash -pnpm add -g @bastani/atomic +curl -fsSL https://raw.githubusercontent.com/bastani-inc/atomic/main/install.sh | sh -s -- --ref 0.9.11 ``` -With Bun: +```powershell +& ([scriptblock]::Create((irm https://raw.githubusercontent.com/bastani-inc/atomic/main/install.ps1))) -Ref 0.9.11 +``` + +`GITHUB_TOKEN` or `GH_TOKEN` is optional and raises GitHub API limits on shared networks. Curl and GNU Wget keep the token in a protected temporary file instead of process arguments. BusyBox Wget remains supported without a token, and with a token when the latest-release redirect avoids the API; if an authenticated API fallback is needed, install curl or GNU Wget rather than exposing the token. The installer downloads only the matching GitHub Release archive and `SHA256SUMS`, verifies the checksum, and keeps the complete payload in a versioned directory. + +### Package managers + +Package installs still require Node.js. Install the published package globally with npm, pnpm, or Bun: ```bash +npm install -g @bastani/atomic +pnpm add -g @bastani/atomic bun add -g @bastani/atomic ``` -Atomic does not require package install scripts. If you want to disable dependency lifecycle scripts during the Atomic install, you can add `--ignore-scripts` to the install command. +Atomic does not require package install scripts. Add `--ignore-scripts` if you want to disable dependency lifecycle scripts during a package install. ### Alpine and musl Linux archives -For Alpine Linux, use `atomic-linux-x64-musl.tar.gz` on x64 or `atomic-linux-arm64-musl.tar.gz` on arm64. These archives provide native search and PTY bindings. Install their runtime libraries with `apk add --no-cache libgcc libstdc++`, then see the [Alpine and musl Linux archive notes](/index#alpine-and-musl-linux-archives) for the clipboard fallback and external Postgres or Docker requirement for durable workflows. +The shell installer detects Alpine and selects `atomic-linux-x64-musl.tar.gz` or `atomic-linux-arm64-musl.tar.gz`. These archives bundle payload-local `libgcc` and `libstdc++`, so stock Alpine needs no runtime package install. See the [Alpine and musl Linux archive notes](/index#alpine-and-musl-linux-archives) for the clipboard fallback and external Postgres or Docker requirement for durable workflows. Then start Atomic in the project directory you want it to work on: @@ -45,7 +65,9 @@ atomic ## Uninstall -Remove the global package with the same package manager you used to install it: +For a default archive install on macOS or Linux, remove `~/.local/share/atomic` and the `~/.local/bin/atomic` link. On Windows, remove `%LOCALAPPDATA%\atomic`; if you set `ATOMIC_BIN_DIR`, also remove `atomic.cmd` and the `atomic-current` junction from that directory, then remove the directory from your User PATH. + +For a package install, remove the global package with the same package manager: ```bash npm uninstall -g @bastani/atomic @@ -53,7 +75,7 @@ pnpm remove -g @bastani/atomic bun remove -g @bastani/atomic ``` -This removes the CLI package only. User configuration, auth, sessions, and packages remain under `~/.atomic/agent/` unless you delete that directory yourself. +These commands remove the CLI only. User configuration, auth, sessions, and packages remain under `~/.atomic/agent/` unless you delete that directory yourself. ## Authenticate diff --git a/packages/coding-agent/docs/termux.md b/packages/coding-agent/docs/termux.md index b90832ad4..32feb47c0 100644 --- a/packages/coding-agent/docs/termux.md +++ b/packages/coding-agent/docs/termux.md @@ -2,7 +2,7 @@ Atomic runs on Android via [Termux](https://termux.dev/), a terminal emulator and Linux environment for Android. -Termux uses Android's bionic libc, not Alpine's musl libc. Atomic's Alpine musl archives target x64 and arm64 musl Linux only; they are not Termux packages and do not provide an Android target. Android ARM64 still has no Atomic native target, so musl support does not change Termux's native-addon limitations. +Termux uses Android's bionic libc, not Alpine's musl libc. Do not run the root `install.sh` there: Atomic's glibc and musl Linux release archives are not Android packages and do not provide a Termux target. Use the npm path below. Android ARM64 still has no Atomic native target, so musl support does not change Termux's native-addon limitations. ## Prerequisites diff --git a/packages/coding-agent/docs/windows.md b/packages/coding-agent/docs/windows.md index 0ff4b7d6c..5e53ee3a0 100644 --- a/packages/coding-agent/docs/windows.md +++ b/packages/coding-agent/docs/windows.md @@ -1,6 +1,28 @@ # Windows Setup -Atomic requires a bash shell on Windows. Checked locations (in order): +## Install + +Install the self-contained Windows release archive with Windows PowerShell 5.1 or newer: + +```powershell +irm https://raw.githubusercontent.com/bastani-inc/atomic/main/install.ps1 | iex +``` + +This path does not require Node.js or a package manager. To pin an exact release: + +```powershell +& ([scriptblock]::Create((irm https://raw.githubusercontent.com/bastani-inc/atomic/main/install.ps1))) -Ref 0.9.11 +``` + +The installer verifies `SHA256SUMS` before changing an existing install. It stores versioned payloads under `%LOCALAPPDATA%\atomic` and places an ASCII-only `atomic.cmd` plus an `atomic-current` junction in `%LOCALAPPDATA%\atomic\bin` by default. The relative shim remains safe when the install path contains Unicode text or the bin directory is elsewhere. Set `ATOMIC_INSTALL_DIR`, `ATOMIC_BIN_DIR`, or `ATOMIC_VERSION` to override those values. `GITHUB_TOKEN` or `GH_TOKEN` is optional for higher GitHub API limits. Exact pins use Atomic's `MAJOR.MINOR.PATCH` or `MAJOR.MINOR.PATCH-alpha.REVISION` release tag form. + +Every attempt removes its own `atomic-install-*` staging directory under the Windows temp path before it finishes. Windows can hold a file or an executable image open briefly after the process that used it exits, so the installer clears read-only attributes, retries the removal a bounded number of times, and verifies the directory is gone after each try. If it still cannot remove the directory, it reports the exact path, the number of attempts, and the last Windows error instead of leaving the residue unmentioned. A cleanup failure never replaces the original error: when an install fails for another reason, that error is still the one you see and the incomplete cleanup is reported as a warning. + +After the script is fetched, it enables TLS 1.2 for its own GitHub requests and restores the caller's prior protocol setting. A downloaded script cannot repair the connection used to fetch itself: on a legacy Windows PowerShell 5.1 host where the literal `irm` command cannot reach GitHub, enable TLS 1.2 in that shell before rerunning the same one-liner. + +The installer adds the bin directory to the User PATH and the current PowerShell process. Restart the terminal when it finishes so other processes see the new PATH. A custom `ATOMIC_BIN_DIR` containing `;` cannot be one Windows PATH entry, so the installer leaves PATH untouched and prints a direct-run command for `atomic.cmd` instead. If the bin directory already holds a same-stem launcher that `PATHEXT` resolves before `atomic.cmd`, such as a stale `atomic.exe` from an older Node-based install, the installer reports it and stops before downloading anything; remove that entry and rerun. Because the shim is `atomic.cmd`, `PATHEXT` must include `.CMD` for bare `atomic` to resolve; if it does not, the installer says so and stops rather than reporting a success you could not use. An unexpected regular `current` entry under `ATOMIC_INSTALL_DIR`, or a regular `atomic-current` entry under `ATOMIC_BIN_DIR`, is reported and left untouched instead of being moved or deleted. A pinned `-Ref` is honored literally: if GitHub answers with a different release tag, the install stops before downloading anything. Package-manager installation remains available but requires Node.js; see the [Quickstart](/quickstart#package-managers). + +After installation, Atomic requires a bash shell for its shell tool. Checked locations (in order): 1. Custom path from `~/.atomic/agent/settings.json` (legacy `~/.pi/agent/settings.json` also supported) 2. Git Bash (`C:\Program Files\Git\bin\bash.exe`) @@ -26,4 +48,4 @@ On Windows, Atomic canonicalizes paths before starting native filesystem watcher When self-update starts on Windows, Atomic first cleans any previous `.atomic-native-quarantine` directory under the global package root. If native add-ons from the current install are loaded by the running process, Atomic moves those files into a per-run quarantine directory and copies them back into place before invoking the package manager. This lets the package manager replace native dependency files that Windows would otherwise keep locked. -If Atomic cannot safely self-update the current installation, it exits with a clear message instead of guessing. The message explains that the install is unsupported, unmanaged, or not writable; prints the detected executable path when available; and tells you to update Atomic with the package manager, wrapper, source checkout, or release artifact that originally installed it. Standalone Bun binaries direct users to the current [Atomic releases](https://github.com/bastani-inc/atomic/releases/latest), never upstream Pi artifacts. +If Atomic cannot safely self-update the current installation, it exits with a clear message instead of guessing. The message explains that the install is unsupported, unmanaged, or not writable; prints the detected executable path when available; and tells you to update Atomic with the package manager, wrapper, source checkout, or release artifact that originally installed it. Archive installs are not managed by `atomic update`; rerun the PowerShell installer to replace `current` with the requested release. Standalone Bun binaries direct users to the current [Atomic releases](https://github.com/bastani-inc/atomic/releases/latest), never upstream Pi artifacts. diff --git a/scripts/build-binaries.sh b/scripts/build-binaries.sh index ed4a7fddd..73f83d06e 100755 --- a/scripts/build-binaries.sh +++ b/scripts/build-binaries.sh @@ -39,13 +39,29 @@ SKIP_INSTALL=false SKIP_PACKAGE_BUILD=false PLATFORM="" +ALPINE_MUSL_RUNTIME_BRANCH="v3.22" +ALPINE_MUSL_RUNTIME_VERSION="14.2.0-r6" +ALPINE_MUSL_RUNTIME_BASE="https://dl-cdn.alpinelinux.org/alpine/$ALPINE_MUSL_RUNTIME_BRANCH/main" + CLIPBOARD_STAGE_DIR="" +MUSL_RUNTIME_STAGE_DIR="" cleanup_clipboard_stage() { if [[ -n "$CLIPBOARD_STAGE_DIR" ]]; then rm -rf "$CLIPBOARD_STAGE_DIR" fi } -trap cleanup_clipboard_stage EXIT + +cleanup_musl_runtime_stage() { + if [[ -n "$MUSL_RUNTIME_STAGE_DIR" ]]; then + rm -rf "$MUSL_RUNTIME_STAGE_DIR" + fi +} + +cleanup_build_stages() { + cleanup_clipboard_stage + cleanup_musl_runtime_stage +} +trap cleanup_build_stages EXIT while [[ $# -gt 0 ]]; do case $1 in @@ -225,6 +241,122 @@ win32_console_mode_arch() { esac } +download_build_asset() { + local url="$1" + local destination="$2" + if command -v curl >/dev/null 2>&1; then + curl -fsSL "$url" -o "$destination" + elif command -v wget >/dev/null 2>&1; then + wget -q -O "$destination" "$url" + else + echo "curl or wget is required to stage musl runtime libraries" >&2 + return 1 + fi +} + +verify_build_sha256() { + local expected="$1" + local file="$2" + if command -v sha256sum >/dev/null 2>&1; then + local checksum_manifest="$MUSL_RUNTIME_STAGE_DIR/checksum.$$.txt" + printf '%s %s\n' "$expected" "$file" > "$checksum_manifest" + sha256sum -c "$checksum_manifest" + rm -f "$checksum_manifest" + elif command -v shasum >/dev/null 2>&1; then + local actual + actual="$(shasum -a 256 "$file")" + [[ "${actual%% *}" == "$expected" ]] + else + echo "sha256sum or shasum is required to verify musl runtime libraries" >&2 + return 1 + fi +} + +stage_musl_runtime() { + local platform="$1" + local payload_dir="$2" + local alpine_arch="" + local libgcc_sha256="" + local libstdcpp_sha256="" + + case "$platform" in + linux-x64-musl) + alpine_arch="x86_64" + libgcc_sha256="04f3467bc967e705221a843fe4d3de5850db826e571686e0c0ed453d38cb5c59" + libstdcpp_sha256="939f7c99898f3e8154207a17f4acbe8bc40437e1bb1b43f5525620ca9e452a2e" + ;; + linux-arm64-musl) + alpine_arch="aarch64" + libgcc_sha256="ba1835eec3ad8a120efd3d5020e561d53553a0513763a08f509e3ce6d4baa9ca" + libstdcpp_sha256="0d2f054057a4f932e985a129eccb79908b40964185139a0a609aed3032aba064" + ;; + *) + echo "Cannot stage musl runtime for $platform" >&2 + return 1 + ;; + esac + + command -v patchelf >/dev/null 2>&1 || { + echo "patchelf is required to build musl release archives" >&2 + return 1 + } + + MUSL_RUNTIME_STAGE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/atomic-musl-runtime.XXXXXX")" + MUSL_RUNTIME_STAGE_DIR="$(cd -- "$MUSL_RUNTIME_STAGE_DIR" && pwd -P)" + local libgcc_apk="$MUSL_RUNTIME_STAGE_DIR/libgcc.apk" + local libstdcpp_apk="$MUSL_RUNTIME_STAGE_DIR/libstdc++.apk" + download_build_asset "$ALPINE_MUSL_RUNTIME_BASE/$alpine_arch/libgcc-$ALPINE_MUSL_RUNTIME_VERSION.apk" "$libgcc_apk" + download_build_asset "$ALPINE_MUSL_RUNTIME_BASE/$alpine_arch/libstdc++-$ALPINE_MUSL_RUNTIME_VERSION.apk" "$libstdcpp_apk" + verify_build_sha256 "$libgcc_sha256" "$libgcc_apk" + verify_build_sha256 "$libstdcpp_sha256" "$libstdcpp_apk" + tar -xzf "$libgcc_apk" -C "$MUSL_RUNTIME_STAGE_DIR" + tar -xzf "$libstdcpp_apk" -C "$MUSL_RUNTIME_STAGE_DIR" + + mkdir -p "$payload_dir/lib" + cp -L "$MUSL_RUNTIME_STAGE_DIR/usr/lib/libgcc_s.so.1" "$payload_dir/lib/libgcc_s.so.1" + cp -L "$MUSL_RUNTIME_STAGE_DIR/usr/lib/libstdc++.so.6" "$payload_dir/lib/libstdc++.so.6" + + local patched_count=0 + while IFS= read -r -d '' elf_file; do + local needed + if ! needed="$(patchelf --print-needed "$elf_file" 2>/dev/null)"; then + continue + fi + case "$needed" in + *libgcc_s.so.1*|*libstdc++.so.6*) ;; + *) continue ;; + esac + + local elf_dir + local relative_lib + local runtime_rpath + local current_rpath + local next_rpath + elf_dir="$(dirname -- "$elf_file")" + relative_lib="$(node -e 'process.stdout.write(require("node:path").relative(process.argv[1], process.argv[2]).replaceAll("\\\\", "/"))' "$elf_dir" "$payload_dir/lib")" + runtime_rpath='$ORIGIN' + if [[ "$relative_lib" != "." ]]; then + runtime_rpath="$runtime_rpath/$relative_lib" + fi + current_rpath="$(patchelf --print-rpath "$elf_file")" + case ":$current_rpath:" in + *":$runtime_rpath:"*) next_rpath="$current_rpath" ;; + ::) next_rpath="$runtime_rpath" ;; + *) next_rpath="$current_rpath:$runtime_rpath" ;; + esac + patchelf --set-rpath "$next_rpath" "$elf_file" + patched_count=$((patched_count + 1)) + done < <(find "$payload_dir" -type f \( -path "$payload_dir/atomic" -o -name '*.node' -o -name '*.so' -o -name '*.so.*' \) -print0) + + [[ "$patched_count" -gt 0 ]] || { + echo "No musl payload ELF declared libgcc_s.so.1 or libstdc++.so.6: $platform" >&2 + return 1 + } + + cleanup_musl_runtime_stage + MUSL_RUNTIME_STAGE_DIR="" +} + for platform in "${PLATFORMS[@]}"; do cp package.json "binaries/$platform/" cp README.md "binaries/$platform/" @@ -267,6 +399,10 @@ for platform in "${PLATFORMS[@]}"; do cp -r docs "binaries/$platform/" cp -r examples "binaries/$platform/" + if [[ "$platform" == linux-*-musl ]]; then + echo "==> Bundling musl C++ runtime for $platform..." + stage_musl_runtime "$platform" "binaries/$platform" + fi done rm -rf "$runtime_deps_dir" "$shared_app_dir" diff --git a/scripts/test-installers-containers.sh b/scripts/test-installers-containers.sh new file mode 100755 index 000000000..fddd2ed6a --- /dev/null +++ b/scripts/test-installers-containers.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash + +set -euo pipefail + +cd -- "$(dirname -- "$0")/.." + +workspace="$(mktemp -d "${TMPDIR:-/tmp}/atomic-installer-containers.XXXXXX")" +cleanup() { + rm -rf "$workspace" +} +trap cleanup EXIT + +mkdir -p "$workspace/payload/atomic/builtin" "$workspace/payload/atomic/node_modules/fixture" +cat > "$workspace/payload/atomic/atomic" <<'ATOMIC' +#!/bin/sh +if [ "${1:-}" = "--version" ]; then + printf '%s\n' '1.0.0' + exit 0 +fi +exit 1 +ATOMIC +chmod +x "$workspace/payload/atomic/atomic" +printf '%s\n' '{"name":"@bastani/atomic","version":"1.0.0"}' > "$workspace/payload/atomic/package.json" +printf '%s\n' 'fixture app' > "$workspace/payload/atomic/app.js" +printf '%s\n' 'fixture builtin' > "$workspace/payload/atomic/builtin/payload.txt" +printf '%s\n' 'fixture module' > "$workspace/payload/atomic/node_modules/fixture/payload.txt" + +COPYFILE_DISABLE=1 tar --no-xattrs -czf "$workspace/payload.tar.gz" -C "$workspace/payload" atomic +if command -v sha256sum >/dev/null 2>&1; then + archive_hash="$(sha256sum "$workspace/payload.tar.gz")" +else + archive_hash="$(shasum -a 256 "$workspace/payload.tar.gz")" +fi +archive_hash="${archive_hash%% *}" + +release_dir="$workspace/releases/1.0.0" +mkdir -p "$release_dir" "$workspace/bin" +: > "$release_dir/SHA256SUMS" +for asset in atomic-linux-x64.tar.gz atomic-linux-arm64.tar.gz atomic-linux-x64-musl.tar.gz atomic-linux-arm64-musl.tar.gz; do + cp "$workspace/payload.tar.gz" "$release_dir/$asset" + printf '%s *%s\n' "$archive_hash" "$asset" >> "$release_dir/SHA256SUMS" +done + +cat > "$workspace/bin/wget" <<'WGET' +#!/bin/sh +output= +url= +while [ "$#" -gt 0 ]; do + case $1 in + -O) shift; output=$1 ;; + -*) ;; + *) url=$1 ;; + esac + shift +done +case $url in + https://api.github.com/repos/bastani-inc/atomic/releases/tags/1.0.0) + printf '%s\n' '{"tag_name":"1.0.0"}' + ;; + https://github.com/bastani-inc/atomic/releases/download/1.0.0/*) + file=${url##*/} + /bin/cp "/fixture/releases/1.0.0/$file" "$output" + ;; + *) + printf 'unexpected fixture request: %s\n' "$url" >&2 + exit 1 + ;; +esac +WGET +chmod +x "$workspace/bin/wget" + +for container in alpine:3.22 debian:bookworm-slim; do + name=${container%%:*} + mkdir -p "$workspace/$name-home" "$workspace/$name-tmp" + docker run --rm \ + --user "$(id -u):$(id -g)" \ + -v "$PWD:/repo:ro" \ + -v "$workspace:/fixture" \ + -e HOME="/fixture/$name-home" \ + -e TMPDIR="/fixture/$name-tmp" \ + -e ATOMIC_INSTALL_DIR="/fixture/$name-install" \ + -e ATOMIC_BIN_DIR="/fixture/$name-bin" \ + -e PATH="/fixture/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" \ + "$container" \ + /bin/sh -c ' + set -eu + if [ -f /etc/alpine-release ]; then + /bin/ln -sf /usr/bin/sha256sum /fixture/bin/sha256sum + PATH=/fixture/bin:/bin + export PATH + ! command -v ldd >/dev/null 2>&1 + fi + /bin/sh /repo/install.sh --ref 1.0.0 + test -L "$ATOMIC_INSTALL_DIR/current" + test -L "$ATOMIC_BIN_DIR/atomic" + test -f "$ATOMIC_INSTALL_DIR/current/app.js" + test -f "$ATOMIC_INSTALL_DIR/current/builtin/payload.txt" + test -f "$ATOMIC_INSTALL_DIR/current/node_modules/fixture/payload.txt" + test "$("$ATOMIC_BIN_DIR/atomic" --version)" = 1.0.0 + ' +done diff --git a/scripts/test-musl-release-archive.sh b/scripts/test-musl-release-archive.sh new file mode 100755 index 000000000..5538adf19 --- /dev/null +++ b/scripts/test-musl-release-archive.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [[ $# -ne 2 ]]; then + echo "Usage: $0 " >&2 + exit 2 +fi + +archive="$1" +platform="$2" +case "$platform" in + linux-x64-musl) docker_platform="linux/amd64" ;; + linux-arm64-musl) docker_platform="linux/arm64" ;; + *) + echo "Unsupported musl archive platform: $platform" >&2 + exit 2 + ;; +esac + +[[ -f "$archive" ]] || { + echo "Musl release archive not found: $archive" >&2 + exit 1 +} + +workspace="$(mktemp -d "${TMPDIR:-/tmp}/atomic-musl-archive-smoke.XXXXXX")" +cleanup() { + rm -rf "$workspace" +} +trap cleanup EXIT + +tar -xzf "$archive" -C "$workspace" +for path in \ + atomic/atomic \ + atomic/app.js \ + atomic/package.json \ + atomic/builtin/workflows/package.json \ + atomic/node_modules/@bastani/atomic-natives/package.json \ + atomic/lib/libgcc_s.so.1 \ + atomic/lib/libstdc++.so.6; do + [[ -e "$workspace/$path" ]] || { + echo "Missing musl release archive path: $path" >&2 + exit 1 + } +done + +docker run --rm --platform "$docker_platform" \ + -v "$workspace:/smoke:ro" \ + alpine:3.22 \ + /bin/sh -c ' + set -eu + atomic=/smoke/atomic/atomic + test -f /smoke/atomic/app.js + test -d /smoke/atomic/builtin + test -d /smoke/atomic/node_modules + test -f /smoke/atomic/lib/libgcc_s.so.1 + test -f /smoke/atomic/lib/libstdc++.so.6 + "$atomic" --version + ' diff --git a/test/ci/ci-workflow-contracts.test.ts b/test/ci/ci-workflow-contracts.test.ts index 845920e5d..23f10cec2 100644 --- a/test/ci/ci-workflow-contracts.test.ts +++ b/test/ci/ci-workflow-contracts.test.ts @@ -278,15 +278,40 @@ test("native release matrix pins all shipped targets and the Linux glibc floor", ); }); -test("Alpine smoke consumes the x64 musl artifact and installs its runtime libraries", async () => { - const workflow = await readText(publishPath); +test("Alpine smoke covers both musl archives on stock Alpine without runtime package installation", async () => { + const [workflow, smoke] = await Promise.all([ + readText(publishPath), + readText(join(root, "scripts/test-musl-release-archive.sh")), + ]); const alpine = jobBlock(workflow, "alpine-binary-smoke", "build"); assert.match(alpine, /needs: \[integrity, native-artifacts\]/u); - assert.match(alpine, /name: atomic-natives-linux-x64-musl/u); - assert.match(alpine, /atomic-linux-x64-musl\.tar\.gz/u); - assert.match(alpine, /apk add --no-cache libgcc libstdc\+\+/u); - assert.match(alpine, /node:22-alpine/u); - assert.match(alpine, /require\("\/smoke\/atomic\/node_modules\/@bastani\/atomic-natives"\)/u); + assert.match(alpine, /atomic-natives-\$\{\{ matrix\.slug \}\}/u); + assert.match(alpine, /linux-x64-musl[\s\S]*linux-arm64-musl/u); + assert.match(alpine, /blacksmith-4vcpu-ubuntu-2404[\s\S]*blacksmith-4vcpu-ubuntu-2404-arm/u); + assert.match(alpine, /test-musl-release-archive\.sh/u); + assert.doesNotMatch(alpine, /apk add/u); + assert.match(smoke, /alpine:3\.22/u); + assert.match(smoke, /docker run --rm --platform/u); + assert.match(smoke, /atomic --version|"\$atomic" --version/u); + assert.match(smoke, /app\.js[\s\S]*builtin[\s\S]*node_modules/u); + assert.doesNotMatch(smoke, /apk add/u); + const nativeLoad = namedStep(jobSteps(alpine), "Load the musl native binding under musl libc"); + assert.match(nativeLoad, /^name: Load the musl native binding under musl libc$/mu); + assert.match(nativeLoad, /node:22-alpine/u); + assert.match(nativeLoad, /require\("\/smoke\/atomic\/node_modules\/@bastani\/atomic-natives"\)/u); + assert.match(nativeLoad, /\["glob", "grep"\]/u); + assert.match(nativeLoad, /typeof binding\[name\] !== "function"/u); +}); + +test("musl archive build bundles pinned C++ runtimes and patches payload-local search paths", async () => { + const buildScript = await readText(join(root, "scripts/build-binaries.sh")); + assert.match(buildScript, /ALPINE_MUSL_RUNTIME_VERSION="14\.2\.0-r6"/u); + assert.match(buildScript, /libgcc_s\.so\.1/u); + assert.match(buildScript, /libstdc\+\+\.so\.6/u); + assert.match(buildScript, /sha256sum -c/u); + assert.match(buildScript, /patchelf --print-needed/u); + assert.match(buildScript, /patchelf --set-rpath/u); + assert.match(buildScript, /\$ORIGIN/u); }); test("release build retains Atomic native, smoke, shrinkwrap, metadata, and asset contracts", async () => { @@ -299,8 +324,8 @@ test("release build retains Atomic native, smoke, shrinkwrap, metadata, and asse assert.match(workflow, /Failed to load extension/); assert.match(workflow, /native optionalDependencies must be the eight exact-version platform packages/u); assert.match(workflow, /test .* = 10/u); - assert.match(workflow, /Build Linux x64 musl archive[\s\S]*--platform linux-x64-musl/u); - assert.match(workflow, /apk add --no-cache libgcc libstdc\+\+/u); + assert.match(workflow, /Build Linux musl archive[\s\S]*--platform "\$\{\{ matrix\.platform \}\}"/u); + assert.match(workflow, /Install musl archive tooling[\s\S]*patchelf/u); assert.doesNotMatch( workflow, /Release-base-ref|Release-base-sha|RELEASE_BASE_REFS|deterministic release tree|create-event binding/iu, diff --git a/test/ci/installers-asset-contract.test.ts b/test/ci/installers-asset-contract.test.ts new file mode 100644 index 000000000..8e3bdda02 --- /dev/null +++ b/test/ci/installers-asset-contract.test.ts @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import { fileURLToPath } from "node:url"; +import { test } from "vitest"; +import { jobBlock, readText } from "./workflow-text.js"; + +const root = fileURLToPath(new URL("../..", import.meta.url)); +const archivePattern = /atomic-(?:darwin|linux|windows)-[a-z0-9-]+\.(?:tar\.gz|zip)/gu; + +function sorted(values: Iterable): string[] { + return [...values].sort(); +} + +function exactArchives(source: string): string[] { + return sorted(new Set(source.match(archivePattern) ?? [])); +} + +test("release builders, uploader, and installers agree on the exact archive asset set", async () => { + const [buildScript, publishWorkflow, shellInstaller, powershellInstaller] = await Promise.all([ + readText(`${root}/scripts/build-binaries.sh`), + readText(`${root}/.github/workflows/publish.yml`), + readText(`${root}/install.sh`), + readText(`${root}/install.ps1`), + ]); + + const platformDeclaration = /^\s*PLATFORMS=\(([a-z0-9-]+(?:\s+[a-z0-9-]+)*)\)\s*$/mu.exec(buildScript); + assert.ok(platformDeclaration, "build-binaries.sh must declare its default platform list explicitly"); + const platforms = (platformDeclaration[1] as string).trim().split(/\s+/u); + assert.deepEqual(platforms, [ + "darwin-arm64", + "darwin-x64", + "linux-x64", + "linux-arm64", + "linux-x64-musl", + "linux-arm64-musl", + "windows-x64", + "windows-arm64", + ]); + const builtArchives = sorted( + platforms.map((platform) => `atomic-${platform}.${platform.startsWith("windows-") ? "zip" : "tar.gz"}`), + ); + + const stageRelease = jobBlock(publishWorkflow, "stage-github-release", "publish-npm"); + const uploadDeclaration = /assets=\(([^)]+)\)/u.exec(stageRelease); + assert.ok(uploadDeclaration, "stage-github-release must declare its upload list explicitly"); + const uploadedAssets = sorted((uploadDeclaration[1] as string).trim().split(/\s+/u)); + const uploadedArchives = uploadedAssets.filter((asset) => asset !== "SHA256SUMS"); + + const shellArchives = exactArchives(shellInstaller); + const powershellArchives = exactArchives(powershellInstaller); + const installerArchives = sorted(new Set([...shellArchives, ...powershellArchives])); + + assert.deepEqual( + shellArchives, + builtArchives.filter((asset) => asset.endsWith(".tar.gz")), + ); + assert.deepEqual( + powershellArchives, + builtArchives.filter((asset) => asset.endsWith(".zip")), + ); + assert.deepEqual(uploadedArchives, builtArchives); + assert.deepEqual(installerArchives, builtArchives); + assert.deepEqual(uploadedAssets, sorted([...installerArchives, "SHA256SUMS"])); + assert.equal(uploadedAssets.filter((asset) => asset === "SHA256SUMS").length, 1); + assert.match(shellInstaller, /CHECKSUM_FILE=SHA256SUMS/u); + assert.match(powershellInstaller, /"SHA256SUMS"/u); + assert.ok(shellArchives.every((asset) => asset.endsWith(".tar.gz"))); + assert.ok(powershellArchives.every((asset) => asset.endsWith(".zip"))); +}); diff --git a/test/ci/installers-docs-contract.test.ts b/test/ci/installers-docs-contract.test.ts new file mode 100644 index 000000000..bbf6854ac --- /dev/null +++ b/test/ci/installers-docs-contract.test.ts @@ -0,0 +1,86 @@ +import assert from "node:assert/strict"; +import { fileURLToPath } from "node:url"; +import { test } from "vitest"; +import { readText } from "./workflow-text.js"; + +const root = fileURLToPath(new URL("../..", import.meta.url)); +const shellOneLiner = "curl -fsSL https://raw.githubusercontent.com/bastani-inc/atomic/main/install.sh | sh"; +const powershellOneLiner = "irm https://raw.githubusercontent.com/bastani-inc/atomic/main/install.ps1 | iex"; +const pinnedPowerShell = + "& ([scriptblock]::Create((irm https://raw.githubusercontent.com/bastani-inc/atomic/main/install.ps1))) -Ref 0.9.11"; + +test("installer documentation keeps the literal entry points, knobs, defaults, and platform limits", async () => { + const paths = { + readme: `${root}/README.md`, + quickstart: `${root}/packages/coding-agent/docs/quickstart.md`, + windows: `${root}/packages/coding-agent/docs/windows.md`, + index: `${root}/packages/coding-agent/docs/index.md`, + containerization: `${root}/packages/coding-agent/docs/containerization.md`, + termux: `${root}/packages/coding-agent/docs/termux.md`, + }; + const entries = await Promise.all( + Object.entries(paths).map(async ([name, path]) => [name, await readText(path)] as const), + ); + const docs = Object.fromEntries(entries) as Record; + + for (const name of ["readme", "quickstart", "index", "containerization"] as const) { + assert.ok(docs[name].includes(shellOneLiner), `${name} is missing the literal shell one-liner`); + } + for (const name of ["readme", "quickstart", "windows", "index"] as const) { + assert.ok(docs[name].includes(powershellOneLiner), `${name} is missing the literal PowerShell one-liner`); + } + for (const name of ["quickstart", "windows"] as const) { + assert.ok(docs[name].includes(pinnedPowerShell), `${name} is missing the literal pinned PowerShell form`); + } + + for (const name of ["readme", "quickstart"] as const) { + assert.match(docs[name], /need `tar` and either `curl` or `wget`/u); + assert.doesNotMatch(docs[name], /need `awk`/u); + } + for (const knob of ["ATOMIC_INSTALL_DIR", "ATOMIC_BIN_DIR", "ATOMIC_VERSION", "GITHUB_TOKEN", "GH_TOKEN"]) { + assert.ok(docs.quickstart.includes(knob), `quickstart is missing ${knob}`); + assert.ok(docs.windows.includes(knob), `windows docs are missing ${knob}`); + } + assert.match(docs.quickstart, /~\/\.local\/share\/atomic/u); + assert.match(docs.quickstart, /~\/\.local\/bin\/atomic/u); + assert.match(docs.windows, /%LOCALAPPDATA%\\atomic\\bin/u); + assert.match(docs.windows, /ASCII-only `atomic\.cmd` plus an `atomic-current` junction/u); + assert.match(docs.quickstart, /remove `atomic\.cmd` and the `atomic-current` junction/u); + assert.match(docs.quickstart, /Relative `ATOMIC_INSTALL_DIR` and `ATOMIC_BIN_DIR` values/u); + assert.match(docs.quickstart, /cannot equal or sit inside the launcher path/u); + assert.match(docs.quickstart, /cannot sit inside the install root's `current` or `versions` directories/u); + assert.match(docs.windows, /same-stem launcher that `PATHEXT` resolves before `atomic\.cmd`/u); + assert.match(docs.windows, /`PATHEXT` must include `\.CMD` for bare `atomic` to resolve/u); + assert.match(docs.windows, /is reported and left untouched instead of being moved or deleted/u); + assert.match(docs.quickstart, /MAJOR\.MINOR\.PATCH-alpha\.REVISION/u); + assert.match(docs.windows, /MAJOR\.MINOR\.PATCH-alpha\.REVISION/u); + assert.match(docs.quickstart, /protected temporary file instead of process arguments/u); + assert.match(docs.windows, /downloaded script cannot repair the connection used to fetch itself/u); + assert.match(docs.quickstart, /a pin is honored literally/u); + assert.match(docs.windows, /A pinned `-Ref` is honored literally/u); + assert.match(docs.windows, /containing `;` cannot be one Windows PATH entry/u); + assert.match(docs.quickstart, /including any trailing whitespace or newline/u); + assert.match(docs.readme, /relative install and bin directories resolve against the physical directory/u); + assert.match(docs.quickstart, /does not require Node\.js|Node\.js and a package manager are not required/u); + assert.match(docs.quickstart, /Package installs still require Node\.js/u); + assert.match(docs.quickstart, /bundle payload-local `libgcc` and `libstdc\+\+`/u); + assert.match(docs.quickstart, /stock Alpine needs no runtime package install/u); + assert.doesNotMatch(docs.quickstart, /apk add/u); + assert.match(docs.index, /stock Alpine without installing runtime packages/u); + assert.match(docs.readme, /run on stock Alpine without an `apk add` step/u); + assert.match(docs.containerization, /without Node\.js or npm/u); + assert.match(docs.termux, /Do not run the root `install\.sh`/u); + assert.match(docs.termux, /bionic libc/u); +}); + +test("CI runs the POSIX installer smoke in Alpine and Debian slim", async () => { + const [workflow, smoke] = await Promise.all([ + readText(`${root}/.github/workflows/test.yml`), + readText(`${root}/scripts/test-installers-containers.sh`), + ]); + assert.match(workflow, /run: \.\/scripts\/test-installers-containers\.sh/u); + assert.match(smoke, /alpine:3\.22/u); + assert.match(smoke, /debian:bookworm-slim/u); + assert.match(smoke, /\/bin\/sh \/repo\/install\.sh --ref 1\.0\.0/u); + assert.match(smoke, /! command -v ldd/u); +}); diff --git a/test/ci/installers-safety-contract.test.ts b/test/ci/installers-safety-contract.test.ts new file mode 100644 index 000000000..16d790ab7 --- /dev/null +++ b/test/ci/installers-safety-contract.test.ts @@ -0,0 +1,380 @@ +import assert from "node:assert/strict"; +import { fileURLToPath } from "node:url"; +import { test } from "vitest"; +import { readText } from "./workflow-text.js"; + +const root = fileURLToPath(new URL("../..", import.meta.url)); + +async function installers(): Promise<{ shell: string; powershell: string }> { + const [shell, powershell] = await Promise.all([readText(`${root}/install.sh`), readText(`${root}/install.ps1`)]); + return { shell, powershell }; +} + +test("POSIX path conflicts and unexpected launcher directories fail before I/O", async () => { + const { shell } = await installers(); + assert.equal(shell.match(/pwd -P/gu)?.length, 1); + assert.match(shell, /INSTALL_ROOT=\$\(normalize_absolute_path "\$INSTALL_ROOT" && printf '_'\)/u); + assert.match(shell, /BIN_DIR=\$\(normalize_absolute_path "\$BIN_DIR" && printf '_'\)/u); + assert.match(shell, /BIN_PATH=\$BIN_DIR\/atomic/u); + assert.match(shell, /reject_dangling_symlink_path\(\) \{/u); + assert.match(shell, /\[ -L "\$dangling_probe" \] && \[ ! -d "\$dangling_probe" \]/u); + assert.match(shell, /reject_dangling_symlink_path "\$INSTALL_ROOT" ATOMIC_INSTALL_DIR/u); + assert.match(shell, /reject_dangling_symlink_path "\$BIN_DIR" ATOMIC_BIN_DIR/u); + assert.match(shell, /canonical_physical=\$\(CDPATH= cd -P "\$canonical_probe"[^\n]+&& pwd && printf '_'\)/u); + assert.match(shell, /PHYSICAL_INSTALL_ROOT=\$\(canonicalize_existing_prefix "\$INSTALL_ROOT" && printf '_'\)/u); + assert.match(shell, /PHYSICAL_BIN_PATH=\$\(canonicalize_existing_prefix "\$BIN_PATH" && printf '_'\)/u); + assert.match(shell, /case \$PHYSICAL_INSTALL_ROOT\/ in[\s\S]+"\$PHYSICAL_BIN_PATH\/"\*/u); + assert.match(shell, /\[ -d "\$BIN_PATH" \] && \[ ! -L "\$BIN_PATH" \]/u); + for (const message of [ + "ATOMIC_INSTALL_DIR cannot equal ATOMIC_BIN_DIR/atomic", + "ATOMIC_BIN_DIR/atomic is an unexpected directory", + "ATOMIC_BIN_DIR cannot be inside ATOMIC_INSTALL_DIR/$owned_child", + ]) { + const failure = shell.indexOf(message); + assert.ok(failure >= 0); + assert.ok(failure < shell.indexOf("for required_command")); + assert.ok(failure < shell.indexOf("TEMP_BASE=")); + assert.ok(failure < shell.indexOf("if ! RELEASE_JSON=$(http_get")); + } +}); + +test("POSIX bin paths under transaction-owned install paths fail before any request or mutation", async () => { + const { shell } = await installers(); + assert.match(shell, /for owned_child in current versions; do/u); + assert.match(shell, /for owned_root in "\$INSTALL_ROOT" "\$PHYSICAL_INSTALL_ROOT"; do/u); + assert.match(shell, /for owned_candidate in "\$BIN_PATH" "\$PHYSICAL_BIN_PATH"; do/u); + assert.match(shell, /\/\) owned_path=\/\$owned_child ;;/u); + assert.match(shell, /"\$owned_path"\|"\$owned_path"\/\*\)/u); + assert.match(shell, /the installer replaces that path: \$BIN_DIR/u); + const danglingInstallPreflight = shell.indexOf('reject_dangling_symlink_path "$INSTALL_ROOT" ATOMIC_INSTALL_DIR'); + const danglingBinPreflight = shell.indexOf('reject_dangling_symlink_path "$BIN_DIR" ATOMIC_BIN_DIR'); + assert.ok(danglingInstallPreflight >= 0 && danglingBinPreflight > danglingInstallPreflight); + + const preflight = shell.indexOf("for owned_child in current versions; do"); + assert.ok(preflight >= 0); + for (const boundary of [ + "for required_command", + "TEMP_BASE=", + "if ! RELEASE_JSON=$(http_get", + "resolve_redirect_tag", + 'if ! download_file "$RELEASE_BASE/$ASSET_NAME"', + 'mkdir -p "$INSTALL_ROOT"', + ]) { + const boundaryIndex = shell.indexOf(boundary); + assert.ok(boundaryIndex >= 0, boundary); + assert.ok( + danglingInstallPreflight < boundaryIndex, + `the install-root dangling-symlink preflight runs after: ${boundary}`, + ); + assert.ok( + danglingBinPreflight < boundaryIndex, + `the bin-root dangling-symlink preflight runs after: ${boundary}`, + ); + assert.ok(preflight < boundaryIndex, `the transaction-owned preflight runs after: ${boundary}`); + } +}); + +test("POSIX owner-only modes cover temporary state only and both checksum row formats are accepted", async () => { + const { shell } = await installers(); + assert.match(shell, /ORIGINAL_UMASK=\$\(umask\)\numask 077\n/u); + assert.match(shell, /umask "\$ORIGINAL_UMASK"\nmkdir "\$EXTRACT_ROOT"/u); + const tighten = shell.indexOf("umask 077"); + const restore = shell.indexOf('umask "$ORIGINAL_UMASK"'); + assert.ok(tighten >= 0 && tighten < shell.indexOf("TEMP_BASE="), "the temp directory is created before umask 077"); + assert.ok(restore > tighten); + assert.ok( + restore > shell.indexOf('chmod 600 "$API_AUTH_PATH"'), + "the API token file is protected before the umask is restored", + ); + assert.ok( + restore < shell.indexOf('tar -xzf "$ARCHIVE_PATH"'), + "the payload is extracted before the umask is restored", + ); + assert.ok( + restore < shell.indexOf('mkdir -p "$INSTALL_ROOT"'), + "the install root is created before the umask is restored", + ); + assert.ok( + restore < shell.indexOf('mkdir -p "$BIN_DIR"'), + "the bin directory is created before the umask is restored", + ); + assert.match(shell, /\\\*\*\) checksum_name=\$\{checksum_name#\\\*\} ;;/u); +}); + +test("POSIX container installer keeps bind-mounted fixture trees host-owned", async () => { + const smoke = await readText(`${root}/scripts/test-installers-containers.sh`); + assert.match(smoke, /docker run --rm \\\n\s+--user "\$\(id -u\):\$\(id -g\)" \\\n/u); + assert.match(smoke, /printf '%s \*%s\\n' "\$archive_hash" "\$asset"/u); +}); + +test("Windows same-stem PATHEXT launchers are rejected before any request", async () => { + const { powershell } = await installers(); + assert.match(powershell, /function Get-AtomicShimShadowingExtensions/u); + assert.match(powershell, /\$pathExtValue = \$env:PATHEXT/u); + assert.match( + powershell, + /if \(\[string\]::IsNullOrWhiteSpace\(\$pathExtValue\)\) \{[\s\S]+\$pathExtValue = "\.COM;/u, + ); + assert.doesNotMatch(powershell, /foreach \(\$pathExtValue in @\(\$env:PATHEXT,/u); + assert.match(powershell, /foreach \(\$pathExtEntry in \(\$pathExtValue -split ';'\)/u); + assert.match(powershell, /if \(\$extension -eq "\.CMD"\) \{\r?\n\s+\$cmdSeen = \$true\r?\n\s+break/u); + assert.match(powershell, /which PATHEXT resolves before atomic\.cmd; remove it and rerun the installer\./u); + + const preflight = powershell.indexOf("foreach ($shadowingExtension in @(Get-AtomicShimShadowingExtensions))"); + assert.ok(preflight >= 0); + for (const boundary of [ + '$apiHeaders = @{ Accept = "application/vnd.github+json" }', + '$redirectTag = Get-AtomicRedirectTag "https://github.com', + "New-Item -ItemType Directory -Path $tempDir", + 'Invoke-AtomicDownload "$releaseBase/$assetName" $archivePath', + ]) { + const boundaryIndex = powershell.indexOf(boundary); + assert.ok(boundaryIndex >= 0, boundary); + assert.ok(preflight < boundaryIndex, `the Windows shadowing preflight runs after: ${boundary}`); + } +}); + +test("Windows safety and version checks all precede temp creation and archive downloads", async () => { + const { shell, powershell } = await installers(); + + const outerTry = powershell.indexOf("$previousSecurityProtocol = [Net.ServicePointManager]::SecurityProtocol"); + assert.ok(outerTry >= 0); + for (const declaration of ["$transaction = $null", "$transactionCommitted = $false", "$tempDir = $null"]) { + const declarationIndex = powershell.indexOf(declaration); + assert.ok(declarationIndex >= 0, declaration); + assert.ok(declarationIndex < outerTry, `cleanup state ${declaration} is initialized after the outer try`); + } + assert.match(powershell, /if \(\$null -ne \$tempDir -and \(Test-Path -LiteralPath \$tempDir\)\)/u); + + const currentGuard = powershell.indexOf("ATOMIC_INSTALL_DIR contains an unexpected current entry"); + const atomicCurrentGuard = powershell.indexOf("ATOMIC_BIN_DIR contains an unexpected atomic-current entry"); + const missingCmdGuard = powershell.indexOf("PATHEXT does not include .CMD"); + const requestedTagGuard = powershell.indexOf("(Test-AtomicReleaseTag $requestedRef)"); + const resolvedTagGuard = powershell.indexOf("if (-not (Test-AtomicReleaseTag $releaseTag))"); + for (const [name, index] of [ + ["current-pointer guard", currentGuard], + ["atomic-current guard", atomicCurrentGuard], + ["missing-.CMD guard", missingCmdGuard], + ["requested-tag grammar", requestedTagGuard], + ["resolved-tag grammar", resolvedTagGuard], + ] as const) { + assert.ok(index >= 0, `${name} is missing`); + } + + const apiHeaders = powershell.indexOf('$apiHeaders = @{ Accept = "application/vnd.github+json" }'); + const apiRequest = powershell.indexOf("Invoke-AtomicApiRequest $latestApi"); + const releaseBase = powershell.indexOf("$releaseBase = "); + const tempCreation = powershell.indexOf("New-Item -ItemType Directory -Path $tempDir"); + const archiveDownload = powershell.indexOf('Invoke-AtomicDownload "$releaseBase/$assetName" $archivePath'); + assert.ok(currentGuard < apiHeaders, "the current-pointer guard runs after the API headers"); + assert.ok(atomicCurrentGuard < apiHeaders, "the atomic-current guard runs after the API headers"); + assert.ok(missingCmdGuard < apiHeaders, "the missing-.CMD guard runs after the API headers"); + assert.ok(requestedTagGuard < apiRequest, "the requested-tag grammar check runs after the API request"); + assert.ok(requestedTagGuard < apiHeaders, "the requested-tag grammar check runs after the API headers"); + assert.ok(resolvedTagGuard < releaseBase, "the resolved-tag grammar check runs after the download base"); + for (const guard of [currentGuard, atomicCurrentGuard, missingCmdGuard, requestedTagGuard, resolvedTagGuard]) { + assert.ok(guard < tempCreation, "a preflight guard runs after the temp directory is created"); + assert.ok(guard < archiveDownload, "a preflight guard runs after the archive download"); + } + + assert.doesNotMatch( + powershell.slice(powershell.indexOf("$existingShimItem = Get-AtomicDirectoryEntry $shimPath"), apiHeaders), + /Move-Item|Remove-Item|New-Item/u, + "the Windows pointer preflight must not move or delete caller entries", + ); + + assert.ok( + powershell.includes("'^(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)(?:-alpha\\.(?:[1-9][0-9]*))?$'"), + "the Windows tag grammar does not mirror install.sh", + ); + assert.match(shell, /MAJOR\.MINOR\.PATCH or MAJOR\.MINOR\.PATCH-alpha\.REVISION/u); + assert.match( + powershell, + /unsupported release tag: expected MAJOR\.MINOR\.PATCH or MAJOR\.MINOR\.PATCH-alpha\.REVISION/u, + ); +}); + +test("POSIX release identities stay within Atomic's supported tag grammar", async () => { + const { shell } = await installers(); + assert.match(shell, /is_atomic_release_tag/u); + assert.match(shell, /MAJOR\.MINOR\.PATCH or MAJOR\.MINOR\.PATCH-alpha\.REVISION/u); + assert.doesNotMatch(shell, /\bawk\b/u); + assert.match(shell, /REQUESTED_REF_ENCODED=\$\(percent_encode "\$REQUESTED_REF"\)/u); + assert.match(shell, /API_URL=\$TAGS_API\/\$REQUESTED_REF_ENCODED/u); + assert.match(shell, /RELEASE_TAG_ENCODED=\$\(percent_encode "\$RELEASE_TAG"\)/u); + assert.match(shell, /releases\/download\/\$RELEASE_TAG_ENCODED/u); + assert.match(shell, /VERSION_PATH=\$VERSIONS_DIR\/\$RELEASE_TAG_ENCODED/u); + assert.match(shell, /ln -s "versions\/\$RELEASE_TAG_ENCODED"/u); + assert.match(shell, /Atomic %s installed successfully[^\n]+"\$RELEASE_TAG"/u); +}); + +test("installers pin the requested exact ref and fail closed on a mismatched release identity", async () => { + const { shell, powershell } = await installers(); + const shellCheck = shell.indexOf('[ "$RELEASE_TAG" != "$REQUESTED_REF" ]'); + assert.ok(shellCheck >= 0, "POSIX installer does not compare the resolved tag with the requested ref"); + assert.ok(shellCheck < shell.indexOf("RELEASE_BASE="), "POSIX identity check runs after the download base"); + assert.match(shell, /GitHub returned release \$RELEASE_TAG for requested tag \$REQUESTED_REF/u); + + const powershellCheck = powershell.indexOf("$releaseTag -cne $requestedRef"); + assert.ok(powershellCheck >= 0, "PowerShell installer does not compare the resolved tag with the requested ref"); + assert.ok( + powershellCheck < powershell.indexOf("$releaseBase ="), + "PowerShell identity check runs after the download base", + ); + assert.match(powershell, /GitHub returned release \$releaseTag for requested tag \$requestedRef/u); +}); + +test("POSIX path normalization preserves caller-controlled trailing newlines", async () => { + const { shell } = await installers(); + assert.match(shell, /NEWLINE=\$\(printf '\\n_'\)/u); + assert.match(shell, /START_WORKING_DIR=\$\(pwd -P && printf '_'\)/u); + assert.match(shell, /START_WORKING_DIR=\$\{START_WORKING_DIR%"\$NEWLINE"\}/u); + for (const name of ["INSTALL_ROOT", "BIN_DIR", "PHYSICAL_INSTALL_ROOT", "PHYSICAL_BIN_PATH"]) { + assert.match(shell, new RegExp(`${name}=\\$\\{${name}%_\\}`, "u")); + } + assert.match(shell, /printf '\/%s' "\$normalize_result"/u); + assert.match(shell, /printf '%s' "\$existing_candidate"/u); +}); + +test("Windows PATH updates refuse a bin directory that cannot be one PATH entry", async () => { + const { powershell } = await installers(); + assert.match(powershell, /\$binDirHasPathSeparator = \$binDir\.Contains\(";"\)/u); + const userPathUpdate = powershell.indexOf('[Environment]::SetEnvironmentVariable("Path", $newUserPath, "User")'); + const processPathUpdate = powershell.indexOf("$env:Path = if ([string]::IsNullOrWhiteSpace($env:Path))"); + assert.ok(userPathUpdate >= 0 && processPathUpdate >= 0); + for (const guard of [userPathUpdate, processPathUpdate]) { + const enclosing = powershell.lastIndexOf("if (-not $binDirHasPathSeparator -and", guard); + assert.ok(enclosing >= 0 && guard - enclosing < 400, "a PATH mutation is not guarded by the separator check"); + } + assert.match(powershell, /cannot be represented as one Windows PATH entry/u); + assert.match(powershell, /Choose a semicolon-free ATOMIC_BIN_DIR/u); +}); + +test("POSIX API authentication uses protected files and never authenticates release downloads", async () => { + const { shell } = await installers(); + assert.match(shell, /printf 'Authorization: Bearer %s\\n' "\$TOKEN" > "\$API_AUTH_PATH"/u); + assert.match(shell, /printf 'header = Authorization: Bearer %s\\n' "\$TOKEN" > "\$API_AUTH_PATH"/u); + assert.match(shell, /chmod 600 "\$API_AUTH_PATH"/u); + assert.match(shell, /curl[^\n]+-H "@\$API_AUTH_PATH" "\$http_url"/u); + assert.match(shell, /WGETRC="\$API_AUTH_PATH" wget[^\n]+"\$http_url"/u); + assert.doesNotMatch(shell, /--location-trusted/u); + assert.doesNotMatch(shell, /(?:-H|--header=)"Authorization: Bearer \$TOKEN"/u); + const clearAuth = shell.indexOf("clear_api_auth"); + const releaseBase = shell.indexOf("RELEASE_BASE="); + assert.ok(clearAuth >= 0 && clearAuth < releaseBase); + const download = shell.slice(shell.indexOf("download_file()"), shell.indexOf("tag_from_release_url()")); + assert.doesNotMatch(download, /TOKEN|API_AUTH|Authorization/u); +}); + +test("POSIX rollback retries failed restores and removes created empty parent chains", async () => { + const { shell } = await installers(); + assert.match(shell, /ROLLBACK_RETRY_LIMIT=3/u); + assert.match(shell, /while \[ "\$rollback_attempt" -lt "\$ROLLBACK_RETRY_LIMIT" \][^\n]+"\$rollback_incomplete"/u); + assert.match(shell, /failed to restore the previous atomic launcher/u); + assert.match(shell, /rollback remains incomplete[^\n]+backups were retained for recovery/u); + assert.match(shell, /nearest_existing_directory/u); + assert.match(shell, /remove_created_empty_path "\$BIN_DIR" "\$BIN_DIRECTORY_STOP"/u); + assert.match(shell, /remove_created_empty_path "\$INSTALL_ROOT" "\$INSTALL_DIRECTORY_STOP"/u); +}); + +test("PowerShell rolls back uncommitted move intents from finally and cleans created parents to a fixed point", async () => { + const { powershell } = await installers(); + assert.match(powershell, /function Invoke-AtomicTransactionRollback/u); + assert.match(powershell, /\$null -eq \$Transaction -or \$Transaction\.RollbackCompleted/u); + assert.doesNotMatch(powershell, /\$Transaction\.RollbackCompleted\s*=\s*\$true/u); + assert.match(powershell, /\$Transaction\.RollbackCompleted\s*=\s*-not\s+\$rollbackIncomplete/u); + assert.match(powershell, /RollbackCompleted = \$false/u); + assert.match(powershell, /\$transactionCommitted = \$false/u); + assert.match(powershell, /\$transactionCommitted = \$true[\s\S]+Remove-AtomicTransactionBackups/u); + for (const name of [ + "VersionBackup", + "VersionInstall", + "CurrentBackup", + "CurrentInstall", + "AtomicCurrentBackup", + "AtomicCurrentInstall", + "ShimBackup", + "ShimInstall", + ]) { + const intent = powershell.indexOf(`$transaction.${name}Intended = $true`); + const move = powershell.indexOf("Move-Item", intent); + assert.ok(intent >= 0 && move > intent, `${name} intent must precede its move`); + } + const successOutput = powershell.indexOf(' Write-Output "Atomic $releaseTag installed successfully."'); + const transactionCatch = powershell.lastIndexOf(" catch {", successOutput); + assert.ok(transactionCatch >= 0 && successOutput > transactionCatch); + assert.match(powershell.slice(transactionCatch, successOutput), /Invoke-AtomicTransactionRollback \$transaction/u); + const finallyBlock = powershell.slice(powershell.indexOf("finally {", successOutput)); + assert.match(finallyBlock, /-not \$transactionCommitted[\s\S]+Invoke-AtomicTransactionRollback \$transaction/u); + assert.match(powershell, /\$rollbackRetryLimit\s*=\s*[2-9]/u); + assert.match(finallyBlock, /while \(\$rollbackAttempt -lt \$rollbackRetryLimit/u); + assert.match(finallyBlock, /Write-Warning.*rollback.*incomplete.*-WarningAction Continue/iu); + assert.match(powershell, /function Add-AtomicMissingDirectoryPaths/u); + assert.match(powershell, /Sort-Object -Property Length -Descending/u); + assert.match(powershell, /do \{[\s\S]+\} while \(\$removedDirectory\)/u); + assert.doesNotMatch( + powershell.slice( + powershell.indexOf("function Remove-AtomicCreatedEmptyDirectories"), + powershell.indexOf("function Invoke-AtomicTransactionRollback"), + ), + /Remove-Item|-Recurse/u, + ); +}); + +test("Windows temporary download directories are removed with bounded verified retries", async () => { + const { powershell } = await installers(); + + const helperStart = powershell.indexOf("function Remove-AtomicTemporaryDirectory"); + assert.ok(helperStart >= 0, "the bounded temp-directory removal helper is missing"); + const helper = powershell.slice(helperStart, powershell.indexOf("function Remove-AtomicEmptyDirectory")); + assert.ok(helper.length > 0, "the removal helper is not declared before Remove-AtomicEmptyDirectory"); + assert.match(helper, /while \(\$attempt -lt \$RetryLimit\)/u); + assert.doesNotMatch(helper, /while \(\$true\)|do \{/u); + assert.match(helper, /Remove-Item -LiteralPath \$Path -Recurse -Force -ErrorAction Stop/u); + assert.doesNotMatch(helper, /SilentlyContinue/u); + assert.match(helper, /\[IO\.Directory\]::Delete\(\$Path, \$true\)/u); + assert.match(helper, /if \(-not \[IO\.Directory\]::Exists\(\$Path\)\)/u); + assert.match(helper, /\[IO\.FileAttributes\]::ReadOnly/u); + assert.match(helper, /after \$attempt attempts; last error: \$lastCleanupDetail/u); + assert.doesNotMatch(helper, /Remove-Item -LiteralPath (?!\$Path\b)/u); + assert.doesNotMatch(helper, /\[IO\.Directory\]::Delete\((?!\$Path,)/u); + + assert.match(powershell, /\$tempCleanupRetryLimit = [2-9]/u); + assert.doesNotMatch(powershell, /Remove-Item -LiteralPath \$tempDir/u); + assert.match( + powershell, + /Remove-AtomicTemporaryDirectory \$tempDir \$tempCleanupRetryLimit \$tempCleanupRetryDelayMilliseconds/u, + ); + assert.doesNotMatch( + powershell, + /Remove-AtomicTemporaryDirectory \$(?:binDir|installRoot|versionsDir|currentPath|shimPath)\b/u, + "the bounded removal helper must only be used for the installer-owned temp directory", + ); + + const successOutput = powershell.indexOf(' Write-Output "Atomic $releaseTag installed successfully."'); + const finallyBlock = powershell.slice(powershell.indexOf("finally {", successOutput)); + const rollback = finallyBlock.indexOf("Invoke-AtomicTransactionRollback"); + const tempCleanup = finallyBlock.indexOf("Remove-AtomicTemporaryDirectory $tempDir", rollback); + const parentCleanup = finallyBlock.indexOf( + "Remove-AtomicCreatedEmptyDirectories $transactionMissingDirectories", + tempCleanup, + ); + const deferredReport = finallyBlock.indexOf("if ($null -ne $tempCleanupError)", parentCleanup); + assert.ok( + rollback >= 0 && tempCleanup > rollback && parentCleanup > tempCleanup && deferredReport > parentCleanup, + "a temp cleanup failure is surfaced before rollback and created-parent cleanup complete", + ); + assert.match(finallyBlock.slice(tempCleanup), /catch \{\r?\n\s+\$tempCleanupError = \$_/u); + assert.match( + finallyBlock.slice(deferredReport), + /if \(\$null -ne \$primaryError\) \{[\s\S]{0,200}Write-Warning[^\r\n]+cleanup remains incomplete[^\r\n]+-WarningAction Continue[\s\S]{0,80}else \{\r?\n\s+throw \$tempCleanupError/u, + ); + for (const warning of powershell.matchAll(/^\s*Write-Warning[^\r\n]*$/gmu)) { + assert.match( + warning[0], + /-WarningAction Continue/u, + `warning can inherit a terminating preference: ${warning[0]}`, + ); + } + assert.match(powershell, /catch \{\r?\n\s+\$primaryError = \$_\r?\n\s+throw \$primaryError\r?\n\}/u); +}); diff --git a/test/unit/install-powershell.test.ts b/test/unit/install-powershell.test.ts new file mode 100644 index 000000000..0c5f2fe15 --- /dev/null +++ b/test/unit/install-powershell.test.ts @@ -0,0 +1,3060 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { delimiter, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { test } from "vitest"; +import { spawnSyncCollect } from "../helpers/runtime.js"; + +const root = fileURLToPath(new URL("../..", import.meta.url)); +const installerPath = join(root, "install.ps1"); + +function installerSource(): string { + return readFileSync(installerPath, "utf8"); +} + +test("Windows installer declares the PowerShell 5.1 archive installation contract", () => { + const source = installerSource(); + + assert.match(source, /param\([\s\S]*\[string\]\$Ref[\s\S]*\[switch\]\$Help[\s\S]*\)/u); + assert.match(source, /ATOMIC_VERSION/u); + assert.match(source, /ATOMIC_INSTALL_DIR/u); + assert.match(source, /ATOMIC_BIN_DIR/u); + const explicitRef = source.indexOf('$PSBoundParameters.ContainsKey("Ref")'); + const versionFallback = source.indexOf("$env:ATOMIC_VERSION", explicitRef); + assert.ok(explicitRef >= 0 && versionFallback > explicitRef); + const githubToken = source.indexOf("$env:GITHUB_TOKEN"); + const ghToken = source.indexOf("$env:GH_TOKEN", githubToken); + assert.ok(githubToken >= 0 && ghToken > githubToken); + assert.match(source, /https:\/\/github\.com\/bastani-inc\/atomic\/releases\/latest/u); + assert.match(source, /\/repos\/bastani-inc\/atomic\/releases\/latest/u); + assert.match(source, /\/repos\/bastani-inc\/atomic\/releases\/tags/u); + const redirectAttempt = source.indexOf("$redirectTag = Get-AtomicRedirectTag"); + const latestApiFallback = source.indexOf("Invoke-AtomicApiRequest $latestApi"); + assert.ok(redirectAttempt >= 0 && latestApiFallback > redirectAttempt); + assert.match(source, /\$releaseTag\s*=\s*\[string\]\$release\.tag_name/u); + + const wow64Architecture = source.indexOf("PROCESSOR_ARCHITEW6432"); + const processArchitecture = source.indexOf("PROCESSOR_ARCHITECTURE"); + assert.ok(wow64Architecture >= 0 && processArchitecture > wow64Architecture); + assert.match(source, /atomic-windows-x64\.zip/u); + assert.match(source, /atomic-windows-arm64\.zip/u); + + assert.match(source, /Invoke-WebRequest[\s\S]*-UseBasicParsing/u); + assert.match(source, /function Get-AtomicFileSha256/u); + assert.match(source, /Get-FileHash\s+-LiteralPath\s+\$Path\s+-Algorithm\s+SHA256/u); + assert.match(source, /Get-AtomicFileSha256\s+\$archivePath/u); + assert.match(source, /Expand-Archive\s+-LiteralPath\s+\$archivePath/u); + assert.doesNotMatch(source, /ConvertFrom-Json\s+-AsHashtable/u); + assert.doesNotMatch(source, /\?\?|ForEach-Object\s+-Parallel|\?\s+[^:\r\n]+\s+:/u); + assert.doesNotMatch(source, /\b(?:npm|pnpm|yarn|bun|node|git|jq)(?:\.exe)?\b/iu); + assert.match(source, /\^\(\[A-Fa-f0-9\]\{64\}\) \(\[ \*\]\)\(\[\^\\\\\/\\r\\n\]\+\)\$/u); + assert.ok(source.includes("$assetRowPattern = '(^|[ \\t*])'")); + assert.match(source, /\$checksumAssetRows\.Count\s+-ne\s+1/u); + + const checksumComparison = source.indexOf("Checksum verification failed"); + const installMutation = source.indexOf("New-Item -ItemType Directory -Path $versionsDir"); + assert.ok(checksumComparison >= 0 && installMutation > checksumComparison); + assert.match(source, /New-Item\s+-ItemType\s+Junction/u); + assert.match(source, /\[Environment\]::SetEnvironmentVariable\("Path",\s*\$newUserPath,\s*"User"\)/u); + assert.match(source, /&\s+\$stagedAtomic\s+"--version"[\s\S]*\$LASTEXITCODE/u); + assert.match(source, /&\s+\$env:ComSpec\s+\/d\s+\/c\s+\$shimCommand[\s\S]*\$LASTEXITCODE/u); + + assert.match(source, /LOCALAPPDATA[\\/]atomic/u); + assert.match(source, /Default bin directory:[^\r\n]*LOCALAPPDATA[\\/]atomic[\\/]bin/u); + assert.match(source, /Restart your terminal/u); + const unexpectedShim = source.indexOf("unexpected atomic.cmd directory"); + const unexpectedPointer = source.indexOf("unexpected atomic-current entry"); + const apiHeaders = source.indexOf('$apiHeaders = @{ Accept = "application/vnd.github+json" }'); + assert.ok(unexpectedShim >= 0 && unexpectedShim < apiHeaders); + assert.ok(unexpectedPointer >= 0 && unexpectedPointer < apiHeaders); +}); + +test("Windows installer rejects PATHEXT launchers that shadow atomic.cmd before any request", () => { + const source = installerSource(); + + assert.match(source, /function Get-AtomicShimShadowingExtensions/u); + assert.ok( + source.includes('".COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC"'), + "the default PATHEXT order is not used as a fallback", + ); + assert.match(source, /\$pathExtValue = \$env:PATHEXT/u); + assert.match(source, /if \(\[string\]::IsNullOrWhiteSpace\(\$pathExtValue\)\) \{[\s\S]+\$pathExtValue = "\.COM;/u); + assert.doesNotMatch(source, /foreach \(\$pathExtValue in @\(\$env:PATHEXT,/u); + assert.match(source, /foreach \(\$pathExtEntry in \(\$pathExtValue -split ';'\)/u); + assert.match(source, /\$cmdSeen = \$false/u); + assert.match(source, /if \(\$extension -eq "\.CMD"\) \{\r?\n\s+\$cmdSeen = \$true\r?\n\s+break\r?\n\s+\}/u); + assert.match(source, /\$extension = \$extension\.ToUpperInvariant\(\)/u); + assert.match(source, /\$extension = \$pathExtEntry\.Trim\(\)\.Trim\('"'\)\.Trim\(\)/u); + const missingCmdThrow = source.indexOf("PATHEXT does not include .CMD"); + assert.ok(missingCmdThrow >= 0, "the installer does not reject a PATHEXT without .CMD"); + assert.match(source, /if \(-not \$cmdSeen\) \{[\s\S]{0,200}PATHEXT does not include \.CMD/u); + assert.match(source, /PATHEXT does not include \.CMD; bare atomic cannot resolve the installed atomic\.cmd shim\./u); + for (const boundary of [ + '$apiHeaders = @{ Accept = "application/vnd.github+json" }', + "New-Item -ItemType Directory -Path $tempDir", + 'Invoke-AtomicDownload "$releaseBase/$assetName" $archivePath', + ]) { + assert.ok(missingCmdThrow < source.indexOf(boundary), `the missing-.CMD rejection runs after: ${boundary}`); + } + + const shadowLoop = source.indexOf("foreach ($shadowingExtension in @(Get-AtomicShimShadowingExtensions))"); + assert.ok(shadowLoop >= 0, "the shadowing preflight loop is missing"); + assert.match( + source.slice(shadowLoop), + /Get-AtomicDirectoryEntry \(Join-Path \$binDir \("atomic" \+ \$shadowingExtension\)\)/u, + ); + assert.match(source, /which PATHEXT resolves before atomic\.cmd; remove it and rerun the installer\./u); + assert.doesNotMatch( + source.slice(shadowLoop, source.indexOf("$apiHeaders = @{")), + /PSIsContainer|Remove-Item|Move-Item/u, + "the shadowing preflight must report rather than delete or narrow to files", + ); + + for (const boundary of [ + '$apiHeaders = @{ Accept = "application/vnd.github+json" }', + '$redirectTag = Get-AtomicRedirectTag "https://github.com', + "$releaseBase = ", + 'Invoke-AtomicDownload "$releaseBase/$assetName" $archivePath', + "New-Item -ItemType Directory -Path $tempDir", + ]) { + const boundaryIndex = source.indexOf(boundary); + assert.ok(boundaryIndex >= 0, boundary); + assert.ok(shadowLoop < boundaryIndex, `the shadowing preflight runs after: ${boundary}`); + } +}); + +test("Windows installer initializes cleanup state before preflight and API resolution", () => { + const source = installerSource(); + const outerTry = source.indexOf("$previousSecurityProtocol = [Net.ServicePointManager]::SecurityProtocol"); + assert.ok(outerTry >= 0, "the TLS guard was not found"); + + for (const declaration of [ + "$tempDir = $null", + "$versionStagePath = $null", + "$currentNextPath = $null", + "$atomicCurrentNextPath = $null", + "$shimNextPath = $null", + "$transaction = $null", + "$transactionCommitted = $false", + "$transactionMissingDirectories = New-Object System.Collections.ArrayList", + "$rollbackRetryLimit = 3", + "$tempCleanupRetryLimit = 5", + "$tempCleanupRetryDelayMilliseconds = 125", + "$primaryError = $null", + "$tempCleanupError = $null", + ]) { + const declarationIndex = source.indexOf(declaration); + assert.ok(declarationIndex >= 0, `${declaration} is missing`); + assert.ok(declarationIndex < outerTry, `${declaration} is initialized after the outer try`); + assert.equal( + (source.match(new RegExp(declaration.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"), "gu")) ?? []).length, + 1, + `${declaration} is initialized more than once`, + ); + } + + assert.match(source, /if \(\$null -ne \$tempDir -and \(Test-Path -LiteralPath \$tempDir\)\)/u); + const preflightThrow = source.indexOf("ATOMIC_BIN_DIR contains an unexpected atomic.cmd directory"); + assert.ok(outerTry < preflightThrow, "a preflight throw precedes the cleanup-state initialization"); +}); + +test("Windows installer enforces Atomic release grammar before archive downloads", () => { + const source = installerSource(); + assert.match(source, /function Test-AtomicReleaseTag/u); + assert.ok( + source.includes("'^(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)(?:-alpha\\.(?:[1-9][0-9]*))?$'"), + "the PowerShell grammar does not match install.sh", + ); + assert.match(source, /-cmatch/u); + assert.equal( + ( + source.match( + /throw "unsupported release tag: expected MAJOR\.MINOR\.PATCH or MAJOR\.MINOR\.PATCH-alpha\.REVISION"/gu, + ) ?? [] + ).length, + 2, + "the requested ref and the resolved tag are not both validated", + ); + + const requestedCheck = source.indexOf( + "if (-not [string]::IsNullOrWhiteSpace($requestedRef) -and -not (Test-AtomicReleaseTag $requestedRef))", + ); + const resolvedCheck = source.indexOf("if (-not (Test-AtomicReleaseTag $releaseTag))"); + assert.ok(requestedCheck >= 0 && resolvedCheck > requestedCheck); + assert.ok(requestedCheck < source.indexOf("Invoke-AtomicApiRequest $latestApi")); + assert.ok(requestedCheck < source.indexOf('$apiHeaders = @{ Accept = "application/vnd.github+json" }')); + assert.ok(resolvedCheck < source.indexOf("$releaseBase = ")); + assert.ok(resolvedCheck < source.indexOf("New-Item -ItemType Directory -Path $tempDir")); +}); + +test("Windows installer protects transaction pointer types before I/O", () => { + const source = installerSource(); + const currentPath = source.indexOf('$currentPath = Join-Path $installRoot "current"'); + const currentItem = source.indexOf("$existingCurrentItem = Get-AtomicDirectoryEntry $currentPath"); + const currentThrow = source.indexOf( + "ATOMIC_INSTALL_DIR contains an unexpected current entry; refusing to replace it.", + ); + const apiHeaders = source.indexOf('$apiHeaders = @{ Accept = "application/vnd.github+json" }'); + assert.ok(currentPath >= 0 && currentItem > currentPath && currentThrow > currentItem); + assert.ok(currentThrow < apiHeaders, "the current-pointer guard runs after the API headers"); + assert.ok(currentThrow < source.indexOf("New-Item -ItemType Directory -Path $tempDir")); + assert.match( + source.slice(currentItem, currentThrow), + /\(\$existingCurrentItem\.Attributes -band \[IO\.FileAttributes\]::ReparsePoint\) -eq 0/u, + ); + assert.match(source, /ATOMIC_BIN_DIR contains an unexpected atomic-current entry; refusing to replace it\./u); + assert.doesNotMatch( + source.slice(source.indexOf("$existingShimItem = Get-AtomicDirectoryEntry $shimPath"), apiHeaders), + /Move-Item|Remove-Item|New-Item/u, + "the pointer preflight must not mutate caller entries", + ); +}); + +test("Windows installer isolates IEX state and scopes TLS 1.2 to controlled requests", () => { + const source = installerSource(); + assert.match(source, /& \{\r?\nparam\(/u); + assert.ok(source.trimEnd().endsWith("} @args")); + assert.match( + source, + /\$previousSecurityProtocol = \[Net\.ServicePointManager\]::SecurityProtocol[\s\S]+-bor \[Net\.SecurityProtocolType\]::Tls12/u, + ); + const requestStart = source.indexOf('$redirectTag = Get-AtomicRedirectTag "https://github.com'); + const tlsEnable = source.indexOf("[Net.ServicePointManager]::SecurityProtocol = $previousSecurityProtocol -bor"); + const tlsRestore = source.lastIndexOf("[Net.ServicePointManager]::SecurityProtocol = $previousSecurityProtocol"); + assert.ok(tlsEnable >= 0 && tlsEnable < requestStart && tlsRestore > requestStart); +}); + +test("Windows installer pins the requested ref and refuses an unusable PATH entry", () => { + const source = installerSource(); + + const identityCheck = source.indexOf("$releaseTag -cne $requestedRef"); + assert.ok(identityCheck >= 0, "the resolved tag is not compared with the requested ref"); + assert.ok(identityCheck < source.indexOf("$releaseBase ="), "the identity check runs after the download base"); + assert.match(source, /throw "GitHub returned release \$releaseTag for requested tag \$requestedRef\."/u); + + assert.match(source, /\$binDirHasPathSeparator = \$binDir\.Contains\(";"\)/u); + assert.equal( + (source.match(/if \(-not \$binDirHasPathSeparator -and -not \(Test-AtomicPathContains/gu) ?? []).length, + 2, + "both PATH mutations are not guarded by the separator check", + ); + assert.match(source, /cannot be represented as one Windows PATH entry/u); + assert.match(source, /Choose a semicolon-free ATOMIC_BIN_DIR to add Atomic to PATH\./u); + const separatorBranch = source.indexOf("if ($binDirHasPathSeparator) {", source.indexOf('Write-Output "Shim:')); + assert.ok(separatorBranch >= 0, "success output does not branch on the separator case"); + assert.ok(source.indexOf("Restart your terminal", separatorBranch) > separatorBranch); +}); + +test("Windows installer uses a successful latest redirect without querying the GitHub API", () => { + const source = installerSource(); + const redirectAttempt = source.indexOf("$redirectTag = Get-AtomicRedirectTag"); + const latestApiFallback = source.indexOf("Invoke-AtomicApiRequest $latestApi", redirectAttempt); + const redirectSuccessStart = source.indexOf("else {", latestApiFallback); + const requestedRefStart = source.indexOf("\nelse {", redirectSuccessStart + 1); + assert.ok( + redirectAttempt >= 0 && latestApiFallback > redirectAttempt && redirectSuccessStart > latestApiFallback, + "latest release resolution branches were not found", + ); + assert.ok(requestedRefStart > redirectSuccessStart, "explicit ref resolution branch was not found"); + + const redirectSuccess = source.slice(redirectSuccessStart, requestedRefStart); + assert.match(redirectSuccess, /\$releaseTag\s*=\s*\$redirectTag/u); + assert.doesNotMatch(redirectSuccess, /Invoke-AtomicApiRequest|api\.github\.com/u); + + const apiRequest = source.slice( + source.indexOf("function Invoke-AtomicApiRequest"), + source.indexOf("function Invoke-AtomicDownload"), + ); + const assetDownload = source.slice( + source.indexOf("function Invoke-AtomicDownload"), + source.indexOf("function Test-AtomicPathContains"), + ); + assert.match(apiRequest, /Invoke-WebRequest[^\r\n]+-Headers\s+\$Headers/u); + assert.doesNotMatch(assetDownload, /-Headers/u); +}); + +test("Windows installer extracts PS7 and PS5.1 redirect response shapes in compatibility order", () => { + const source = installerSource(); + const redirect = source.slice( + source.indexOf("function Get-AtomicRedirectTag"), + source.indexOf("function Invoke-AtomicApiRequest"), + ); + const exceptionResponse = redirect.indexOf("$response = $_.Exception.Response"); + const typedLocation = redirect.indexOf("$response.Headers.Location"); + const tryGetValues = redirect.indexOf("TryGetValues", typedLocation); + const getValues = redirect.indexOf("GetValues", tryGetValues + "TryGetValues".length); + const stringIndexer = redirect.indexOf('$response.Headers["Location"]', getValues); + const baseResponse = redirect.indexOf("$response.BaseResponse.ResponseUri.AbsoluteUri", stringIndexer); + + assert.ok(exceptionResponse >= 0, "redirect failures do not inspect Exception.Response"); + assert.ok( + typedLocation >= 0 && + tryGetValues > typedLocation && + getValues > tryGetValues && + stringIndexer > getValues && + baseResponse > stringIndexer, + "redirect locations are not extracted as typed, TryGetValues/GetValues, PS5 indexer, then BaseResponse", + ); + assert.match(redirect, /\[Uri\]::UnescapeDataString\(\$Matches\[1\]\)/u); + assert.equal( + (redirect.match(/\[Uri\]::UnescapeDataString\(\$Matches\[1\]\)/gu) ?? []).length, + 1, + "redirect tags are not decoded exactly once", + ); + const laterEncode = source.indexOf("$encodedReleaseTag = [Uri]::EscapeDataString($releaseTag)"); + assert.ok(laterEncode > source.indexOf("$redirectTag = Get-AtomicRedirectTag")); + assert.doesNotMatch(redirect, /-SkipHttpErrorCheck/u); +}); + +test("Windows installer writes an exact ASCII shim through a sibling atomic-current junction", () => { + const source = installerSource(); + const expectedShimAssignment = + '$shimContent = "@echo off`r`n`"%~dp0atomic-current\\atomic.exe`" %*`r`nexit /b %ERRORLEVEL%`r`n"'; + assert.ok(source.includes(expectedShimAssignment), "shim source is not the exact relative atomic-current command"); + + const shimWrite = source.match(/Set-Content\s+-LiteralPath\s+\$shimNextPath[^\r\n]*/u)?.[0] ?? ""; + assert.match(shimWrite, /-Encoding\s+ASCII/u); + assert.doesNotMatch(shimWrite, /-Encoding\s+Unicode/iu); + assert.match(source, /\$atomicCurrentPath\s*=\s*Join-Path\s+\$binDir\s+"atomic-current"/u); + assert.match(source, /New-Item\s+-ItemType\s+Junction\s+-Path\s+\$atomicCurrentNextPath\s+-Target\s+\$versionPath/u); +}); + +test("Windows installer finds and removes junctions through their parent directory entries", () => { + const source = installerSource(); + const entryLookup = source.slice( + source.indexOf("function Get-AtomicDirectoryEntry"), + source.indexOf("function Remove-AtomicDirectoryLinkOrTree"), + ); + const linkRemoval = source.slice( + source.indexOf("function Remove-AtomicDirectoryLinkOrTree"), + source.indexOf("$requestedRef = $null"), + ); + + assert.match(entryLookup, /Get-ChildItem\s+-LiteralPath\s+\$parentPath\s+-Force/u); + assert.match(entryLookup, /\.Name\s+-ieq\s+\$leafName/u); + assert.match(linkRemoval, /\$item\s*=\s*Get-AtomicDirectoryEntry\s+\$Path/u); + assert.match(linkRemoval, /\[IO\.Directory\]::Delete\(\$item\.FullName\)/u); + assert.match(source, /Get-AtomicDirectoryEntry\s+\$currentPath/u); + assert.match(source, /Get-AtomicDirectoryEntry\s+\$currentNextPath/u); + assert.match(source, /Get-AtomicDirectoryEntry\s+\$Transaction\.CurrentBackupPath/u); + assert.doesNotMatch(source, /Test-Path\s+-LiteralPath\s+\$(?:currentPath|currentNextPath|currentBackupPath)\b/u); +}); + +test("Windows installer records move intent and idempotently rolls back from catch and finally before commit", () => { + const source = installerSource(); + const rollbackStart = source.indexOf("function Invoke-AtomicTransactionRollback"); + const rollbackEnd = source.indexOf("function Remove-AtomicTransactionBackups", rollbackStart); + assert.ok(rollbackStart >= 0 && rollbackEnd > rollbackStart, "transaction rollback routine was not found"); + const rollback = source.slice(rollbackStart, rollbackEnd); + const shimRollback = rollback.indexOf("ShimInstallIntended"); + const atomicCurrentRollback = rollback.indexOf("AtomicCurrentInstallIntended", shimRollback); + const currentRollback = rollback.indexOf("CurrentInstallIntended", atomicCurrentRollback); + const versionRollback = rollback.indexOf("VersionInstallIntended", currentRollback); + assert.ok( + shimRollback >= 0 && + atomicCurrentRollback > shimRollback && + currentRollback > atomicCurrentRollback && + versionRollback > currentRollback, + "rollback is not ordered shim, atomic-current, current, then version", + ); + assert.match( + rollback, + /Get-AtomicDirectoryEntry\s+\$Transaction\.(?:Shim|AtomicCurrent|Current|Version)BackupPath/u, + ); + assert.doesNotMatch(rollback, /throw\s+\$_/u); + assert.doesNotMatch(rollback, /\$Transaction\.RollbackCompleted\s*=\s*\$true/u); + assert.match(rollback, /\$Transaction\.RollbackCompleted\s*=\s*-not\s+\$rollbackIncomplete/u); + for (const intent of [ + "VersionBackupIntended", + "VersionInstallIntended", + "CurrentBackupIntended", + "CurrentInstallIntended", + "AtomicCurrentBackupIntended", + "AtomicCurrentInstallIntended", + "ShimBackupIntended", + "ShimInstallIntended", + "UserPathChangeIntended", + "CurrentPathChangeIntended", + ]) { + assert.match(rollback, new RegExp(`\\$Transaction\\.${intent}\\s*=\\s*\\$false`, "u")); + } + + for (const intent of [ + "VersionBackupIntended", + "VersionInstallIntended", + "CurrentBackupIntended", + "CurrentInstallIntended", + "AtomicCurrentBackupIntended", + "AtomicCurrentInstallIntended", + "ShimBackupIntended", + "ShimInstallIntended", + ]) { + const intentAssignment = source.indexOf(`$transaction.${intent} = $true`); + const move = source.indexOf("Move-Item", intentAssignment); + assert.ok(intentAssignment >= 0 && move > intentAssignment, `${intent} is not recorded before its move`); + } + + const finalSmoke = source.indexOf("Installed atomic.cmd --version failed"); + const commit = source.indexOf("$transactionCommitted = $true", finalSmoke); + const committedCleanup = source.indexOf("Remove-AtomicTransactionBackups", commit); + assert.ok( + finalSmoke >= 0 && commit > finalSmoke && committedCleanup > commit, + "transaction commits before final smoke succeeds", + ); + const catchBlock = source.slice( + source.indexOf(" catch {", finalSmoke), + source.indexOf(" Write-Output", finalSmoke), + ); + assert.match(catchBlock, /if \(-not \$transactionCommitted\)[\s\S]+Invoke-AtomicTransactionRollback/u); + assert.equal((catchBlock.match(/Invoke-AtomicTransactionRollback/gu) ?? []).length, 1); + const finalBlock = source.slice(source.indexOf("finally {")); + assert.match( + finalBlock, + /if \(\$null -ne \$transaction -and -not \$transactionCommitted\)[\s\S]+Invoke-AtomicTransactionRollback/u, + ); + assert.match(source, /\$rollbackRetryLimit\s*=\s*[2-9]/u); + assert.match( + finalBlock, + /while \(\$rollbackAttempt -lt \$rollbackRetryLimit -and -not \$transaction\.RollbackCompleted\)/u, + ); + assert.match(finalBlock, /Write-Warning.*rollback.*incomplete.*-WarningAction Continue/iu); + const committedFinally = finalBlock.slice( + finalBlock.indexOf("if ($null -ne $transaction -and $transactionCommitted)"), + finalBlock.indexOf("if ($null -ne $shimNextPath"), + ); + assert.match(committedFinally, /Remove-AtomicTransactionBackups/u); + assert.doesNotMatch(committedFinally, /Invoke-AtomicTransactionRollback/u); +}); + +test("Windows installer cleans staged children before only snapshotted empty transaction-created parents", () => { + const source = installerSource(); + const emptyDirectoryRemoval = source.slice( + source.indexOf("function Remove-AtomicEmptyDirectory"), + source.indexOf("function Add-AtomicMissingDirectoryPaths"), + ); + assert.match(emptyDirectoryRemoval, /\[IO\.Directory\]::Delete\(\$item\.FullName,\s*\$false\)/u); + assert.doesNotMatch(emptyDirectoryRemoval, /Remove-Item|-Recurse/u); + + const missingSnapshot = source.slice( + source.indexOf("function Add-AtomicMissingDirectoryPaths"), + source.indexOf("function Remove-AtomicCreatedEmptyDirectories"), + ); + assert.match(missingSnapshot, /Get-AtomicDirectoryEntry\s+\$candidate/u); + assert.match(missingSnapshot, /\$MissingPaths\.Add\(\$candidate\)/u); + assert.match(source, /Add-AtomicMissingDirectoryPaths\s+\$transactionMissingDirectories\s+\$versionsDir/u); + assert.match(source, /Add-AtomicMissingDirectoryPaths\s+\$transactionMissingDirectories\s+\$binDir/u); + + const createdCleanup = source.slice( + source.indexOf("function Remove-AtomicCreatedEmptyDirectories"), + source.indexOf("function Invoke-AtomicTransactionRollback"), + ); + assert.match(createdCleanup, /Sort-Object[\s\S]+Descending/u); + assert.match(createdCleanup, /Remove-AtomicEmptyDirectory/u); + assert.doesNotMatch(createdCleanup, /Remove-Item|-Recurse/u); + + const cleanup = source.slice(source.indexOf("finally {")); + const rollback = cleanup.indexOf("Invoke-AtomicTransactionRollback"); + const shimStageCleanup = cleanup.indexOf("$shimNextPath", rollback); + const atomicJunctionStageCleanup = cleanup.indexOf("$atomicCurrentNextPath", shimStageCleanup); + const currentJunctionStageCleanup = cleanup.indexOf("$currentNextPath", atomicJunctionStageCleanup); + const versionStageCleanup = cleanup.indexOf("$versionStagePath", currentJunctionStageCleanup); + const downloadCleanup = cleanup.indexOf("$tempDir", versionStageCleanup); + const parentCleanup = cleanup.indexOf( + "Remove-AtomicCreatedEmptyDirectories $transactionMissingDirectories", + downloadCleanup, + ); + assert.ok( + rollback >= 0 && + shimStageCleanup > rollback && + atomicJunctionStageCleanup > shimStageCleanup && + currentJunctionStageCleanup > atomicJunctionStageCleanup && + versionStageCleanup > currentJunctionStageCleanup && + parentCleanup > downloadCleanup, + "cleanup does not roll back and remove staged children/downloads before empty parent directories", + ); + assert.doesNotMatch(source, /Remove-Item\s+-LiteralPath\s+\$(?:binDir|versionsDir|installRoot)\b/u); +}); + +test("Windows installer removes its temporary download directory with bounded verified retries", () => { + const source = installerSource(); + const helperStart = source.indexOf("function Remove-AtomicTemporaryDirectory"); + assert.ok(helperStart >= 0, "the verified temporary-directory removal helper is missing"); + const helper = source.slice(helperStart, source.indexOf("function Remove-AtomicEmptyDirectory")); + assert.ok(helper.length > 0, "the removal helper is not declared before Remove-AtomicEmptyDirectory"); + + assert.match(helper, /\[string\]\$Path,\r?\n\s+\[int\]\$RetryLimit,\r?\n\s+\[int\]\$RetryDelayMilliseconds/u); + assert.match( + helper, + /if \(\[string\]::IsNullOrWhiteSpace\(\$Path\) -or -not \[IO\.Directory\]::Exists\(\$Path\)\)/u, + ); + assert.match(helper, /while \(\$attempt -lt \$RetryLimit\)/u); + assert.doesNotMatch(helper, /while \(\$true\)|do \{/u, "the removal helper must not loop without a bound"); + assert.match(helper, /\[IO\.FileAttributes\]::ReadOnly/u); + assert.match(helper, /\[IO\.File\]::SetAttributes\(/u); + assert.match(helper, /Remove-Item -LiteralPath \$Path -Recurse -Force -ErrorAction Stop/u); + assert.doesNotMatch(helper, /SilentlyContinue/u, "the removal helper must not suppress deletion failures"); + assert.match(helper, /\[IO\.Directory\]::Delete\(\$Path, \$true\)/u); + assert.equal( + (helper.match(/if \(-not \[IO\.Directory\]::Exists\(\$Path\)\) \{\r?\n\s+return\r?\n\s+\}/gu) ?? []).length, + 2, + "the removal helper does not verify absence after both removal strategies", + ); + assert.match(helper, /Start-Sleep -Milliseconds \(\$RetryDelayMilliseconds \* \$attempt\)/u); + assert.match( + helper, + /throw "Failed to remove the temporary download directory \$\{Path\} after \$attempt attempts; last error: \$lastCleanupDetail"/u, + ); + assert.doesNotMatch(helper, /Remove-Item -LiteralPath (?!\$Path\b)/u, "the helper removes a path it was not given"); + assert.doesNotMatch(helper, /\[IO\.Directory\]::Delete\((?!\$Path,)/u, "the helper deletes a path it was not given"); + + assert.match(source, /\$tempCleanupRetryLimit = [2-9]\r?\n/u); + assert.match(source, /\$tempCleanupRetryDelayMilliseconds = [1-9][0-9]*\r?\n/u); + assert.match(source, /catch \{\r?\n\s+\$primaryError = \$_\r?\n\s+throw \$primaryError\r?\n\}/u); + assert.doesNotMatch(source, /Remove-Item -LiteralPath \$tempDir/u, "the suppressed temp deletion is still present"); + + const cleanup = source.slice( + source.indexOf("finally {", source.indexOf('Write-Output "Atomic $releaseTag installed successfully."')), + ); + assert.match( + cleanup, + /if \(\$null -ne \$tempDir -and \(Test-Path -LiteralPath \$tempDir\)\) \{\r?\n\s+try \{\r?\n\s+Remove-AtomicTemporaryDirectory \$tempDir \$tempCleanupRetryLimit \$tempCleanupRetryDelayMilliseconds\r?\n\s+\}\r?\n\s+catch \{\r?\n\s+\$tempCleanupError = \$_/u, + ); + const tempCleanupCall = cleanup.indexOf("Remove-AtomicTemporaryDirectory $tempDir"); + const parentCleanup = cleanup.indexOf( + "Remove-AtomicCreatedEmptyDirectories $transactionMissingDirectories", + tempCleanupCall, + ); + const deferredReport = cleanup.indexOf("if ($null -ne $tempCleanupError)", tempCleanupCall); + assert.ok( + tempCleanupCall >= 0 && parentCleanup > tempCleanupCall && deferredReport > parentCleanup, + "the cleanup failure is surfaced before every later cleanup step completes", + ); + assert.match( + cleanup.slice(deferredReport), + /if \(\$null -ne \$primaryError\) \{\r?\n\s+Write-Warning -Message "Temporary download directory cleanup remains incomplete: \$tempCleanupError" -WarningAction Continue\r?\n\s+\}\r?\n\s+else \{\r?\n\s+throw \$tempCleanupError/u, + ); + for (const warning of source.matchAll(/^\s*Write-Warning[^\r\n]*$/gmu)) { + assert.match( + warning[0], + /-WarningAction Continue/u, + `warning can inherit a terminating preference: ${warning[0]}`, + ); + } +}); + +interface PowerShellEngine { + executable: string; + major: number; + label: string; +} + +function findPowerShellEngines(): PowerShellEngine[] { + const candidates = ["powershell.exe", "powershell", "pwsh.exe", "pwsh"]; + const engines: PowerShellEngine[] = []; + const visited = new Set(); + for (const directoryEntry of (process.env.PATH ?? "").split(delimiter)) { + const directory = directoryEntry.trim().replace(/^"(.*)"$/u, "$1"); + if (!directory) continue; + for (const candidate of candidates) { + const path = join(directory, candidate); + const identity = process.platform === "win32" ? path.toLowerCase() : path; + if (!existsSync(path) || visited.has(identity)) continue; + visited.add(identity); + const probe = spawnSyncCollect( + [ + path, + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + '[Console]::Write("ENGINE:" + $PSVersionTable.PSVersion.Major)', + ], + { timeout: 10_000 }, + ); + const match = probe.stdout.toString().match(/ENGINE:(\d+)/u); + if (probe.exitCode === 0 && match) { + const major = Number.parseInt(match[1] ?? "", 10); + if (major === 5 || major >= 7) { + engines.push({ + executable: path, + major, + label: major === 5 ? "Windows PowerShell 5.1" : `PowerShell ${major}`, + }); + } + } + } + } + return engines; +} + +const powershellEngines = findPowerShellEngines(); +const powershellExecutable = powershellEngines.find( + (engine) => process.platform === "win32" && engine.major === 5, +)?.executable; +const powershellTest = powershellExecutable === undefined ? test.skip : test; +const POWERSHELL_FIXTURE_TIMEOUT_MS = 120_000; +const TRANSACTION_FAILURE_FIXTURE_STRUCTURAL_TIMEOUT_MS = 240_000; +const ROLLBACK_RETRY_FIXTURE_STRUCTURAL_TIMEOUT_MS = 300_000; +const CTRL_C_FIXTURE_STRUCTURAL_TIMEOUT_MS = 360_000; +const TEMP_CLEANUP_FIXTURE_STRUCTURAL_TIMEOUT_MS = 180_000; + +if (powershellEngines.length === 0) { + test.skip("available PowerShell engines isolate literal IEX failure state", () => {}); +} +for (const engine of powershellEngines) { + test(`${engine.label} isolates literal IEX failure state`, () => { + const workspace = mkdtempSync(join(tmpdir(), "atomic-ps-scope-")); + const probePath = join(workspace, "scope-probe.ps1"); + const quotedInstallerPath = installerPath.replaceAll("'", "''"); + writeFileSync( + probePath, + [ + '$ErrorActionPreference = "Continue"', + '$ProgressPreference = "Continue"', + "Set-StrictMode -Off", + '$Ref = "caller-ref"', + '$Help = "caller-help"', + '$helpText = "caller-help-text"', + "$beforeTls = [Net.ServicePointManager]::SecurityProtocol", + "$oldWow = $env:PROCESSOR_ARCHITEW6432", + "$oldArch = $env:PROCESSOR_ARCHITECTURE", + '$env:PROCESSOR_ARCHITEW6432 = "unsupported"', + '$env:PROCESSOR_ARCHITECTURE = "unsupported"', + `$source = [IO.File]::ReadAllText('${quotedInstallerPath}')`, + 'try { Invoke-Expression $source } catch { if ($_ -notmatch "Unsupported Windows processor architecture") { throw } }', + "$env:PROCESSOR_ARCHITEW6432 = $oldWow", + "$env:PROCESSOR_ARCHITECTURE = $oldArch", + "$missingReadWorked = $true; try { $null = $AtomicInstallerMissingScopeProbe } catch { $missingReadWorked = $false }", + 'if ($ErrorActionPreference -ne "Continue" -or $ProgressPreference -ne "Continue" -or $Ref -ne "caller-ref" -or $Help -ne "caller-help" -or $helpText -ne "caller-help-text" -or -not $missingReadWorked -or [Net.ServicePointManager]::SecurityProtocol -ne $beforeTls) { throw "caller scope changed" }', + 'Write-Output "IEX_SCOPE_OK"', + ].join("\n"), + ); + try { + const result = spawnSyncCollect( + [engine.executable, "-NoLogo", "-NoProfile", "-NonInteractive", "-File", probePath], + { timeout: 30_000 }, + ); + assert.equal(result.exitCode, 0, `${result.stdout.toString()}${result.stderr.toString()}`); + assert.match(result.stdout.toString(), /IEX_SCOPE_OK/u); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); +} + +const shimShadowProbeHarness = String.raw` +param( + [Parameter(Mandatory=$true)][string]$InstallerPath, + [Parameter(Mandatory=$true)][string]$Workspace +) + +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" +Set-StrictMode -Version Latest + +$binDir = Join-Path $Workspace "bin root" +$installRoot = Join-Path $Workspace "install root" +$originalPathExt = $env:PATHEXT +$env:ATOMIC_INSTALL_DIR = $installRoot +$env:ATOMIC_BIN_DIR = $binDir +$env:PROCESSOR_ARCHITEW6432 = "AMD64" +$env:PROCESSOR_ARCHITECTURE = "AMD64" +New-Item -ItemType Directory -Path $binDir -Force | Out-Null +Set-Content -LiteralPath (Join-Path $binDir "keep.txt") -Value "caller-data" -Encoding ASCII -NoNewline + +try { + foreach ($case in @( + @{ PathExt = $null; Stale = "atomic.exe"; Kind = "File" }, + @{ PathExt = $null; Stale = "atomic.com"; Kind = "File" }, + @{ PathExt = $null; Stale = "atomic.bat"; Kind = "File" }, + @{ PathExt = $null; Stale = "ATOMIC.EXE"; Kind = "File" }, + @{ PathExt = $null; Stale = "atomic.exe"; Kind = "Directory" }, + @{ PathExt = ".WSF;.CMD;.EXE"; Stale = "atomic.wsf"; Kind = "File" } + )) { + $env:PATHEXT = $case.PathExt + $stalePath = Join-Path $binDir $case.Stale + if ($case.Kind -eq "Directory") { + New-Item -ItemType Directory -Path $stalePath -Force | Out-Null + } + else { + Set-Content -LiteralPath $stalePath -Value "stale launcher" -Encoding ASCII -NoNewline + } + $failure = $null + try { & $InstallerPath -Ref "1.0.0" | Out-Null } + catch { $failure = $_ } + if ($null -eq $failure) { throw "$($case.Stale) ($($case.Kind)) was accepted" } + if ($failure.Exception.Message -notmatch 'PATHEXT resolves before atomic\.cmd') { + throw "$($case.Stale) failed for the wrong reason: $($failure.Exception.Message)" + } + if ($failure.Exception.Message -notmatch [regex]::Escape($case.Stale)) { + throw "$($case.Stale) rejection did not name the shadowing launcher" + } + if (Test-Path -LiteralPath $installRoot) { throw "$($case.Stale) rejection created an install root" } + if (Test-Path -LiteralPath (Join-Path $binDir "atomic.cmd")) { throw "$($case.Stale) rejection created a shim" } + if ((Get-Content -LiteralPath (Join-Path $binDir "keep.txt") -Raw) -ne "caller-data") { + throw "$($case.Stale) rejection mutated the bin directory" + } + if ($case.Kind -eq "File" -and (Get-Content -LiteralPath $stalePath -Raw) -ne "stale launcher") { + throw "$($case.Stale) rejection replaced the pre-existing launcher" + } + Remove-Item -LiteralPath $stalePath -Recurse -Force + } + Write-Output "SHIM_SHADOW_OK" +} +finally { + $env:PATHEXT = $originalPathExt +} +`; + +if (powershellEngines.length === 0) { + test.skip("available PowerShell engines refuse PATHEXT launchers that shadow the shim", () => {}); +} +for (const engine of powershellEngines) { + test(`${engine.label} refuses PATHEXT launchers that shadow the shim before any request`, () => { + const workspace = mkdtempSync(join(tmpdir(), "atomic-ps-shadow-")); + const probePath = join(workspace, "shim-shadow-probe.ps1"); + writeFileSync(probePath, shimShadowProbeHarness); + try { + const result = spawnSyncCollect( + [ + engine.executable, + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + probePath, + "-InstallerPath", + installerPath, + "-Workspace", + workspace, + ], + { timeout: 60_000 }, + ); + assert.equal(result.exitCode, 0, `${result.stdout.toString()}${result.stderr.toString()}`); + assert.match(result.stdout.toString(), /SHIM_SHADOW_OK/u); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); +} + +const preflightGuardProbeHarness = String.raw` +param( + [Parameter(Mandatory=$true)][string]$InstallerPath, + [Parameter(Mandatory=$true)][string]$Workspace +) + +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" +Set-StrictMode -Version Latest + +$originalPathExt = $env:PATHEXT +$env:PROCESSOR_ARCHITEW6432 = "AMD64" +$env:PROCESSOR_ARCHITECTURE = "AMD64" +$caseIndex = 0 + +function New-ProbeSpace { + $script:caseIndex++ + $root = Join-Path $Workspace ("case-" + $script:caseIndex) + New-Item -ItemType Directory -Path $root -Force | Out-Null + $env:ATOMIC_INSTALL_DIR = Join-Path $root "install root" + $env:ATOMIC_BIN_DIR = Join-Path $root "bin root" + return $root +} + +function Assert-Rejected { + param([string]$Label, [string]$Pattern) + $failure = $null + try { & $InstallerPath @args | Out-Null } + catch { $failure = $_ } + if ($null -eq $failure) { throw "$Label was accepted" } + if ($failure.Exception.Message -notmatch $Pattern) { + throw "$Label failed for the wrong reason: $($failure.Exception.Message)" + } + if ($failure.Exception.Message -match 'variable') { + throw "$Label surfaced an uninitialized-variable error: $($failure.Exception.Message)" + } + if (Test-Path -LiteralPath $env:ATOMIC_INSTALL_DIR) { throw "$Label created an install root" } + return $failure +} + +try { + # Missing .CMD in an effective PATHEXT must fail before any request or mutation. + $null = New-ProbeSpace + $env:PATHEXT = ".EXE;.BAT" + $missingCmd = Assert-Rejected "PATHEXT without .CMD" 'PATHEXT does not include \.CMD' -Ref "1.0.0" + if ($missingCmd.Exception.Message -notmatch 'bare atomic') { throw "the missing-.CMD rejection did not name bare atomic" } + if (Test-Path -LiteralPath $env:ATOMIC_BIN_DIR) { throw "the missing-.CMD rejection created a bin directory" } + $env:PATHEXT = $null + + # Unsupported release tag grammar must fail before the tags API request. + foreach ($invalidTag in @("v1.0.0", "1.0", "1.0.0.0", "1.0.0-alpha.0", "1.0.0-beta.1", "1.0.0-alpha", "01.0.0", "release/1.0", "hash#tag", "percent%tag")) { + $null = New-ProbeSpace + $null = Assert-Rejected "explicit ref $invalidTag" 'unsupported release tag: expected MAJOR\.MINOR\.PATCH or MAJOR\.MINOR\.PATCH-alpha\.REVISION' -Ref $invalidTag + } + $null = New-ProbeSpace + $env:ATOMIC_VERSION = "not-a-tag" + $null = Assert-Rejected "ATOMIC_VERSION not-a-tag" 'unsupported release tag' + $env:ATOMIC_VERSION = $null + + # A regular installRoot\current entry must be reported, never moved or deleted. + foreach ($kind in @("Directory", "File")) { + $null = New-ProbeSpace + New-Item -ItemType Directory -Path $env:ATOMIC_INSTALL_DIR -Force | Out-Null + $conflictPath = Join-Path $env:ATOMIC_INSTALL_DIR "current" + if ($kind -eq "Directory") { + New-Item -ItemType Directory -Path $conflictPath -Force | Out-Null + Set-Content -LiteralPath (Join-Path $conflictPath "marker.txt") -Value "caller-data" -NoNewline + } + else { + Set-Content -LiteralPath $conflictPath -Value "caller-data" -NoNewline + } + $failure = $null + try { & $InstallerPath -Ref "1.0.0" | Out-Null } + catch { $failure = $_ } + if ($null -eq $failure) { throw "a regular current $kind was accepted" } + if ($failure.Exception.Message -notmatch 'ATOMIC_INSTALL_DIR contains an unexpected current entry') { + throw "the regular current $kind failed for the wrong reason: $($failure.Exception.Message)" + } + $preserved = if ($kind -eq "Directory") { Get-Content -LiteralPath (Join-Path $conflictPath "marker.txt") -Raw } else { Get-Content -LiteralPath $conflictPath -Raw } + if ($preserved -ne "caller-data") { throw "the regular current $kind lost caller data" } + if (Test-Path -LiteralPath (Join-Path $env:ATOMIC_INSTALL_DIR "versions")) { throw "the regular current $kind rejection created versions" } + if (Test-Path -LiteralPath $env:ATOMIC_BIN_DIR) { throw "the regular current $kind rejection created a bin directory" } + } + + # A preflight blocker must surface its own message rather than a cleanup error. + $null = New-ProbeSpace + New-Item -ItemType Directory -Path $env:ATOMIC_BIN_DIR -Force | Out-Null + Set-Content -LiteralPath (Join-Path $env:ATOMIC_BIN_DIR "atomic.exe") -Value "stale launcher" -NoNewline + $preflight = Assert-Rejected "stale same-stem launcher" 'PATHEXT resolves before atomic\.cmd' -Ref "1.0.0" + if ($preflight.Exception.Message -cne "ATOMIC_BIN_DIR contains atomic.exe, which PATHEXT resolves before atomic.cmd; remove it and rerun the installer.") { + throw "the preflight error text changed: $($preflight.Exception.Message)" + } + + Write-Output "PREFLIGHT_GUARDS_OK" +} +finally { + $env:PATHEXT = $originalPathExt + $env:ATOMIC_VERSION = $null +} +`; + +if (powershellEngines.length === 0) { + test.skip("available PowerShell engines enforce every preflight guard before I/O", () => {}); +} +for (const engine of powershellEngines) { + test(`${engine.label} enforces PATHEXT, tag grammar, and pointer guards before any request`, () => { + const workspace = mkdtempSync(join(tmpdir(), "atomic-ps-preflight-")); + const probePath = join(workspace, "preflight-guard-probe.ps1"); + writeFileSync(probePath, preflightGuardProbeHarness); + try { + const result = spawnSyncCollect( + [ + engine.executable, + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + probePath, + "-InstallerPath", + installerPath, + "-Workspace", + workspace, + ], + { timeout: 90_000 }, + ); + assert.equal(result.exitCode, 0, `${result.stdout.toString()}${result.stderr.toString()}`); + assert.match(result.stdout.toString(), /PREFLIGHT_GUARDS_OK/u); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); +} + +interface AsyncProcessResult { + exitCode: number | null; + stdout: string; + stderr: string; +} + +function spawnCollectAsync(command: string, args: string[], timeout: number): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] }); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + child.kill("SIGKILL"); + }, timeout); + child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk)); + child.stderr.on("data", (chunk: Buffer) => stderr.push(chunk)); + child.once("error", (error) => { + clearTimeout(timer); + reject(error); + }); + child.once("close", (exitCode) => { + clearTimeout(timer); + const result = { + exitCode, + stdout: Buffer.concat(stdout).toString(), + stderr: Buffer.concat(stderr).toString(), + }; + if (timedOut) { + reject( + new Error( + `PowerShell redirect fixture timed out.\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + ), + ); + return; + } + resolve(result); + }); + }); +} + +function redirectFunctionSource(): string { + const source = installerSource(); + return source.slice( + source.indexOf("function Get-AtomicRedirectTag"), + source.indexOf("function Invoke-AtomicApiRequest"), + ); +} + +async function runRealRedirectFixture(engine: PowerShellEngine): Promise { + let apiRequests = 0; + const server = createServer((request, response) => { + response.setHeader("Connection", "close"); + if (request.url === "/latest") { + response.writeHead(302, { + Location: "/bastani-inc/atomic/releases/tag/release%2F1.0", + }); + response.end(); + return; + } + if (request.url === "/api") { + apiRequests += 1; + response.writeHead(200, { "Content-Type": "application/json" }); + response.end('{"tag_name":"fallback"}'); + return; + } + response.writeHead(200, { "Content-Type": "text/plain" }); + response.end("not a redirect"); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + assert.ok(address && typeof address !== "string"); + const baseUri = `http://127.0.0.1:${address.port}`; + const workspace = mkdtempSync(join(tmpdir(), "atomic-ps-redirect-")); + const harnessPath = join(workspace, "redirect-fixture.ps1"); + const harness = String.raw` +param( + [Parameter(Mandatory=$true)][string]$BaseUri, + [Parameter(Mandatory=$true)][string]$Mode +) +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" +Set-StrictMode -Version Latest +${redirectFunctionSource()} + +function Invoke-WebRequest { + [CmdletBinding()] + param( + [Parameter(Mandatory=$true)][string]$Uri, + [switch]$UseBasicParsing, + [int]$MaximumRedirection + ) + return [pscustomobject]@{ + StatusCode = 302 + Headers = [pscustomobject]@{ Location = [Uri]"/bastani-inc/atomic/releases/tag/returned%2Ftag" } + } +} +$returnedTag = Get-AtomicRedirectTag "mock://returned-response" +Remove-Item -LiteralPath "Function:\Invoke-WebRequest" -Force +if ($returnedTag -cne "returned/tag") { + throw "Returned 302 response did not yield its redirect tag: $returnedTag" +} +Write-Output ("RETURNED_REDIRECT_TAG:" + $returnedTag) + + +$redirectUri = if ($Mode -eq "success") { "$BaseUri/latest" } else { "$BaseUri/not-a-redirect" } +$tag = Get-AtomicRedirectTag $redirectUri +if ([string]::IsNullOrWhiteSpace($tag)) { + Invoke-WebRequest -Uri "$BaseUri/api" -UseBasicParsing -ErrorAction Stop | Out-Null + Write-Output "API_FALLBACK" +} +else { + Write-Output ("REDIRECT_TAG:" + $tag) +} +`; + writeFileSync(harnessPath, `\uFEFF${harness}`, "utf8"); + + try { + const commonArgs = ["-NoLogo", "-NoProfile", "-NonInteractive", "-File", harnessPath, "-BaseUri", baseUri]; + const success = await spawnCollectAsync(engine.executable, [...commonArgs, "-Mode", "success"], 20_000); + assert.equal( + success.exitCode, + 0, + `${engine.label} real 302 fixture failed.\nstdout:\n${success.stdout}\nstderr:\n${success.stderr}`, + ); + assert.match(success.stdout, /REDIRECT_TAG:release\/1\.0/u); + assert.match(success.stdout, /RETURNED_REDIRECT_TAG:returned\/tag/u); + assert.equal(apiRequests, 0, `${engine.label} queried the API after a successful real 302`); + + const fallback = await spawnCollectAsync(engine.executable, [...commonArgs, "-Mode", "fallback"], 20_000); + assert.equal( + fallback.exitCode, + 0, + `${engine.label} redirect fallback fixture failed.\nstdout:\n${fallback.stdout}\nstderr:\n${fallback.stderr}`, + ); + assert.match(fallback.stdout, /API_FALLBACK/u); + assert.equal(apiRequests, 1, `${engine.label} did not make exactly one API fallback request`); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + rmSync(workspace, { recursive: true, force: true }); + } +} + +if (powershellEngines.length === 0) { + test.skip("available PowerShell engines extract tags from a real local HTTP 302 and fall back only on failure", () => {}); +} +for (const engine of powershellEngines) { + test( + `${engine.label} extracts a tag from a real local HTTP 302 and falls back only on failure`, + async () => runRealRedirectFixture(engine), + 45_000, + ); +} + +const fixtureHarness = String.raw` +param( + [Parameter(Mandatory=$true)][string]$InstallerPath, + [Parameter(Mandatory=$true)][string]$Scenario +) + +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" +Set-StrictMode -Version Latest + +if ($PSVersionTable.PSVersion.Major -ne 5 -or $PSVersionTable.PSVersion.Minor -lt 1) { + throw "Dynamic installer fixtures require Windows PowerShell 5.1." +} + +function Assert-Fixture { + param([bool]$Condition, [string]$Message) + if (-not $Condition) { throw "Fixture assertion failed: $Message" } +} + +function Test-ExactPathEntry { + param([AllowNull()][string]$PathValue, [string]$Entry) + if ([string]::IsNullOrWhiteSpace($PathValue)) { return $false } + $target = $Entry.Trim().Trim('"').TrimEnd([char[]]@('\', '/')) + foreach ($candidate in ($PathValue -split ';')) { + if ($candidate.Trim().Trim('"').TrimEnd([char[]]@('\', '/')) -ieq $target) { return $true } + } + return $false +} + +function Get-ExactPathEntryCount { + param([AllowNull()][string]$PathValue, [string]$Entry) + if ([string]::IsNullOrWhiteSpace($PathValue)) { return 0 } + $target = $Entry.Trim().Trim('"').TrimEnd([char[]]@('\', '/')) + $count = 0 + foreach ($candidate in ($PathValue -split ';')) { + if ($candidate.Trim().Trim('"').TrimEnd([char[]]@('\', '/')) -ieq $target) { $count++ } + } + return $count +} + +function Assert-NoTransactionResidue { + param([string]$InstallRoot, [string]$BinDir) + $versionsDir = Join-Path $InstallRoot "versions" + if ([IO.Directory]::Exists($versionsDir)) { + Assert-Fixture (@(Get-ChildItem -LiteralPath $versionsDir -Force | Where-Object { $_.Name -like ".stage-*" -or $_.Name -like ".backup-*" }).Count -eq 0) "version transaction artifacts were not cleaned" + } + if ([IO.Directory]::Exists($InstallRoot)) { + Assert-Fixture (@(Get-ChildItem -LiteralPath $InstallRoot -Force | Where-Object { $_.Name -like ".current-*" }).Count -eq 0) "current transaction artifacts were not cleaned" + } + if ([IO.Directory]::Exists($BinDir)) { + Assert-Fixture (@(Get-ChildItem -LiteralPath $BinDir -Force | Where-Object { $_.Name -like ".atomic-*" }).Count -eq 0) "shim transaction artifacts were not cleaned" + } +} + +function Get-TempResidueReport { + param([string]$Root) + $entries = @(Get-ChildItem -LiteralPath $Root -Filter "atomic-install-*" -Force) + return [pscustomobject]@{ + Count = $entries.Count + Paths = (($entries | ForEach-Object { $_.FullName }) -join '; ') + } +} + +function Test-ByteSequence { + param([byte[]]$Bytes, [byte[]]$Sequence) + if ($Sequence.Length -eq 0 -or $Bytes.Length -lt $Sequence.Length) { return $false } + for ($offset = 0; $offset -le $Bytes.Length - $Sequence.Length; $offset++) { + $matches = $true + for ($index = 0; $index -lt $Sequence.Length; $index++) { + if ($Bytes[$offset + $index] -ne $Sequence[$index]) { + $matches = $false + break + } + } + if ($matches) { return $true } + } + return $false +} + +function Invoke-FixtureShim { + param([string]$ShimPath, [string]$ArgumentText) + $commandLine = '"' + $ShimPath + '"' + if (-not [string]::IsNullOrWhiteSpace($ArgumentText)) { + $commandLine += " $ArgumentText" + } + $cmdExe = Join-Path $env:SystemRoot "System32\cmd.exe" + $output = & $cmdExe /d /c $commandLine + $exitCode = $LASTEXITCODE + return [pscustomobject]@{ + ExitCode = $exitCode + Output = (($output | Out-String).Trim()) + } +} + +$workspace = Split-Path -Parent $MyInvocation.MyCommand.Path +$assetRoot = Join-Path $workspace "releases" +$installRoot = if ($Scenario -eq "unicode") { Join-Path $workspace "安装-Δοκιμή" } else { Join-Path $workspace "install root" } +$binDir = if ($Scenario -eq "unicode") { Join-Path $workspace "自訂-bin-Δ" } else { Join-Path $workspace "bin root" } +$fixtureTemp = Join-Path $workspace "temp" +New-Item -ItemType Directory -Path $assetRoot, $fixtureTemp -Force | Out-Null + +$fixtureExecutable = Join-Path $workspace "fixture-atomic.exe" +$fixtureSource = @' +using System; +using System.IO; +using System.Reflection; + +public static class Program +{ + public static int Main(string[] args) + { + string directory = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + string version = File.ReadAllText(Path.Combine(directory, "version.txt")).Trim(); + string failVersion = Environment.GetEnvironmentVariable("ATOMIC_FIXTURE_FAIL_INSTALLED_VERSION"); + if (String.Equals(version, failVersion, StringComparison.Ordinal) && + directory.IndexOf("atomic-install-", StringComparison.OrdinalIgnoreCase) < 0) + { + return 23; + } + if (args.Length == 1 && args[0] == "--version") + { + Console.WriteLine(version); + return 0; + } + if (args.Length == 2 && args[0] == "--exit") + { + return Int32.Parse(args[1]); + } + Console.WriteLine(version + ":" + String.Join("|", args)); + return 0; + } +} +'@ +Add-Type -TypeDefinition $fixtureSource -OutputAssembly $fixtureExecutable -OutputType ConsoleApplication + +function Get-FixtureSha256 { + param([string]$Path) + + $algorithm = [Security.Cryptography.SHA256]::Create() + $stream = [IO.File]::OpenRead($Path) + try { + return ([BitConverter]::ToString($algorithm.ComputeHash($stream))).Replace("-", "").ToLowerInvariant() + } + finally { + $stream.Dispose() + $algorithm.Dispose() + } +} + +function New-FixtureRelease { + param([string]$Tag) + + $releaseDir = Join-Path $assetRoot $Tag + $payloadDir = Join-Path $workspace ("payload-" + $Tag) + New-Item -ItemType Directory -Path (Join-Path $payloadDir "nested") -Force | Out-Null + Copy-Item -LiteralPath $fixtureExecutable -Destination (Join-Path $payloadDir "atomic.exe") + Set-Content -LiteralPath (Join-Path $payloadDir "version.txt") -Value $Tag -Encoding ASCII -NoNewline + Set-Content -LiteralPath (Join-Path $payloadDir "nested\full-payload.txt") -Value ("payload-" + $Tag) -Encoding ASCII -NoNewline + New-Item -ItemType Directory -Path $releaseDir -Force | Out-Null + + $rows = @() + foreach ($assetName in @("atomic-windows-x64.zip", "atomic-windows-arm64.zip")) { + Set-Content -LiteralPath (Join-Path $payloadDir "asset.txt") -Value $assetName -Encoding ASCII -NoNewline + $archivePath = Join-Path $releaseDir $assetName + Compress-Archive -Path (Join-Path $payloadDir "*") -DestinationPath $archivePath -CompressionLevel Optimal + $hash = Get-FixtureSha256 $archivePath + $marker = if ($assetName -eq "atomic-windows-arm64.zip") { " *" } else { " " } + $rows += "$hash$marker$assetName" + } + Set-Content -LiteralPath (Join-Path $releaseDir "SHA256SUMS") -Value ($rows -join [Environment]::NewLine) -Encoding ASCII -NoNewline + Remove-Item -LiteralPath $payloadDir -Recurse -Force +} + +New-FixtureRelease "1.0.0" +New-FixtureRelease "2.0.0" +if ($Scenario -eq "tag-grammar") { + New-FixtureRelease "1.0.0-alpha.1" +} + +$global:AtomicFixtureAssetRoot = $assetRoot +$global:AtomicFixtureRequests = New-Object System.Collections.ArrayList +$global:AtomicFixtureBadChecksumTag = $null +$global:AtomicFixtureLastAssetName = $null +$global:AtomicFixtureFailApi = $false +$global:AtomicFixtureRedirectFails = $false +$global:AtomicFixtureRedirectTag = "2.0.0" +$global:AtomicFixtureLatestApiTag = "2.0.0" + +function global:Invoke-WebRequest { + [CmdletBinding()] + param( + [Parameter(Mandatory=$true)][string]$Uri, + [string]$OutFile, + [hashtable]$Headers, + [switch]$UseBasicParsing, + [int]$MaximumRedirection + ) + + $authorization = $null + if ($null -ne $Headers -and $Headers.ContainsKey("Authorization")) { + $authorization = [string]$Headers["Authorization"] + } + [void]$global:AtomicFixtureRequests.Add([pscustomobject]@{ Uri = $Uri; Authorization = $authorization }) + + if ($Uri -eq "https://github.com/bastani-inc/atomic/releases/latest") { + if ($global:AtomicFixtureRedirectFails) { + return [pscustomobject]@{ + Headers = @{} + BaseResponse = [pscustomobject]@{ ResponseUri = [Uri]"https://github.com/bastani-inc/atomic/releases/latest" } + } + } + return [pscustomobject]@{ + Headers = @{ Location = "/bastani-inc/atomic/releases/tag/" + $global:AtomicFixtureRedirectTag } + BaseResponse = [pscustomobject]@{ ResponseUri = [Uri]"https://github.com/bastani-inc/atomic/releases/latest" } + } + } + + if ($Uri -match '^https://api\.github\.com/') { + if ($global:AtomicFixtureFailApi) { throw "GitHub API is unavailable in this fixture scenario: $Uri" } + if ($Uri -match '/repos/bastani-inc/atomic/releases/latest$') { + return [pscustomobject]@{ Content = ('{"tag_name":"' + $global:AtomicFixtureLatestApiTag + '"}'); Headers = @{} } + } + if ($Uri -match '/repos/bastani-inc/atomic/releases/tags/([^/]+)$') { + $requestedTag = [Uri]::UnescapeDataString($Matches[1]) + $canonicalTag = if ($requestedTag -eq "1.0.1") { "1.0.0" } else { $requestedTag } + return [pscustomobject]@{ Content = ('{"tag_name":"' + $canonicalTag + '"}'); Headers = @{} } + } + } + + if (-not [string]::IsNullOrWhiteSpace($OutFile) -and $Uri -match '/releases/download/([^/]+)/([^/]+)$') { + $tag = [Uri]::UnescapeDataString($Matches[1]) + $fileName = $Matches[2] + if ($fileName -eq "SHA256SUMS" -and $global:AtomicFixtureBadChecksumTag -eq $tag) { + $badHash = "0" * 64 + Set-Content -LiteralPath $OutFile -Value "$badHash $global:AtomicFixtureLastAssetName" -Encoding ASCII -NoNewline + } + else { + Copy-Item -LiteralPath (Join-Path (Join-Path $global:AtomicFixtureAssetRoot $tag) $fileName) -Destination $OutFile + if ($fileName -ne "SHA256SUMS") { $global:AtomicFixtureLastAssetName = $fileName } + } + return [pscustomobject]@{ StatusCode = 200; Headers = @{} } + } + + throw "Unexpected fixture request: $Uri" +} + +$global:AtomicFixturePayloadCopyCount = 0 +$global:AtomicFixtureFailurePoint = $null +$global:AtomicFixtureRollbackFailurePoint = $null +$global:AtomicFixtureRollbackFailureDelivered = $false +$global:AtomicFixtureRollbackArmed = $false +$global:AtomicFixtureTempLockMode = $null +$global:AtomicFixtureTempLockPath = $null +$global:AtomicFixtureTempLockStream = $null +$global:AtomicFixtureTempRemovalAttempts = 0 +$global:AtomicFixtureWarnings = New-Object System.Collections.ArrayList + +function global:Write-Warning { + param( + [string]$Message, + [string]$WarningAction + ) + + [void]$global:AtomicFixtureWarnings.Add($Message) + if ([string]::IsNullOrWhiteSpace($WarningAction)) { + Microsoft.PowerShell.Utility\Write-Warning -Message $Message + } + else { + Microsoft.PowerShell.Utility\Write-Warning -Message $Message -WarningAction $WarningAction + } +} + +function global:Remove-Item { + param( + [string]$LiteralPath, + [switch]$Recurse, + [switch]$Force, + [string]$ErrorAction + ) + + if ([IO.Path]::GetFileName($LiteralPath) -match '^atomic-install-[0-9a-f]{32}$') { + $global:AtomicFixtureTempRemovalAttempts++ + if ($global:AtomicFixtureTempLockMode -eq "one-shot" -and + $global:AtomicFixtureTempRemovalAttempts -ge 2 -and + $null -ne $global:AtomicFixtureTempLockStream) { + $global:AtomicFixtureTempLockStream.Dispose() + $global:AtomicFixtureTempLockStream = $null + } + } + + $effectiveErrorAction = if ([string]::IsNullOrWhiteSpace($ErrorAction)) { $ErrorActionPreference } else { $ErrorAction } + Microsoft.PowerShell.Management\Remove-Item -LiteralPath $LiteralPath -Recurse:$Recurse -Force:$Force -ErrorAction $effectiveErrorAction +} + +function global:Get-ChildItem { + [CmdletBinding()] + param( + [Parameter(Mandatory=$true)][string]$LiteralPath, + [string]$Filter, + [switch]$Force + ) + + if ($global:AtomicFixtureRollbackArmed -and + -not $global:AtomicFixtureRollbackFailureDelivered -and + $global:AtomicFixtureRollbackFailurePoint -like "*-remove") { + $requestedLeaf = $null + try { $requestedLeaf = [string](Get-Variable -Name leafName -Scope 1 -ValueOnly -ErrorAction Stop) } + catch { $requestedLeaf = $null } + $failureLeaf = switch ($global:AtomicFixtureRollbackFailurePoint) { + "shim-remove" { "atomic.cmd" } + "atomic-current-remove" { "atomic-current" } + "current-remove" { "current" } + "version-remove" { "2.0.0" } + default { $null } + } + if (-not [string]::IsNullOrWhiteSpace($failureLeaf) -and $requestedLeaf -ieq $failureLeaf) { + $global:AtomicFixtureRollbackFailureDelivered = $true + throw "Injected one-shot rollback removal failure: $($global:AtomicFixtureRollbackFailurePoint)" + } + } + + if ([string]::IsNullOrWhiteSpace($Filter)) { + return Microsoft.PowerShell.Management\Get-ChildItem -LiteralPath $LiteralPath -Force:$Force + } + return Microsoft.PowerShell.Management\Get-ChildItem -LiteralPath $LiteralPath -Filter $Filter -Force:$Force +} + +function global:New-Item { + [CmdletBinding(SupportsShouldProcess=$true)] + param( + [Alias("Type")] + [Parameter(Mandatory=$true)][string]$ItemType, + [Parameter(Mandatory=$true)][string]$Path, + [string]$Target, + [switch]$Force + ) + + $leafName = [IO.Path]::GetFileName($Path) + if ($global:AtomicFixtureFailurePoint -eq "current-create" -and + $ItemType -eq "Junction" -and $leafName -match '^\.current-[0-9a-f]{32}$') { + Microsoft.PowerShell.Management\New-Item -ItemType $ItemType -Path $Path -Target $Target -Force:$Force | Out-Null + throw "Injected transaction failure: current-create" + } + if ($global:AtomicFixtureFailurePoint -eq "atomic-current-create" -and + $ItemType -eq "Junction" -and $leafName -match '^\.atomic-current-[0-9a-f]{32}$') { + Microsoft.PowerShell.Management\New-Item -ItemType $ItemType -Path $Path -Target $Target -Force:$Force | Out-Null + throw "Injected transaction failure: atomic-current-create" + } + if (-not [string]::IsNullOrWhiteSpace($global:AtomicFixtureTempLockMode) -and + $ItemType -eq "Directory" -and $leafName -match '^atomic-install-[0-9a-f]{32}$') { + $createdTempDirectory = Microsoft.PowerShell.Management\New-Item -ItemType $ItemType -Path $Path -Force:$Force + $lockPath = Join-Path $Path "fixture-open-handle.bin" + [IO.File]::WriteAllText($lockPath, "fixture-open-handle") + $global:AtomicFixtureTempLockPath = $lockPath + $global:AtomicFixtureTempLockStream = [IO.File]::Open($lockPath, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::None) + return $createdTempDirectory + } + if ($ItemType -eq "Junction") { + return Microsoft.PowerShell.Management\New-Item -ItemType $ItemType -Path $Path -Target $Target -Force:$Force + } + return Microsoft.PowerShell.Management\New-Item -ItemType $ItemType -Path $Path -Force:$Force +} + +function global:Copy-Item { + [CmdletBinding()] + param( + [Parameter(Mandatory=$true)][string]$LiteralPath, + [Parameter(Mandatory=$true)][string]$Destination, + [switch]$Recurse, + [switch]$Force + ) + + $destinationLeaf = [IO.Path]::GetFileName($Destination) + if ($global:AtomicFixtureFailurePoint -eq "payload-copy" -and + $destinationLeaf -match '^\.stage-[0-9a-f]{32}$') { + $global:AtomicFixturePayloadCopyCount++ + if ($global:AtomicFixturePayloadCopyCount -ge 2) { + throw "Injected transaction failure: payload-copy" + } + } + Microsoft.PowerShell.Management\Copy-Item -LiteralPath $LiteralPath -Destination $Destination -Recurse:$Recurse -Force:$Force +} + +function global:Set-Content { + [CmdletBinding()] + param( + [Parameter(Mandatory=$true)][string]$LiteralPath, + [Parameter(Mandatory=$true)]$Value, + [string]$Encoding, + [switch]$NoNewline + ) + + $leafName = [IO.Path]::GetFileName($LiteralPath) + if ($global:AtomicFixtureFailurePoint -eq "shim-stage" -and + $leafName -match '^\.atomic-[0-9a-f]{32}\.cmd$') { + Microsoft.PowerShell.Management\Set-Content -LiteralPath $LiteralPath -Value "partial-shim" -Encoding ASCII -NoNewline + throw "Injected transaction failure: shim-stage" + } + if ([string]::IsNullOrWhiteSpace($Encoding)) { + return Microsoft.PowerShell.Management\Set-Content -LiteralPath $LiteralPath -Value $Value -NoNewline:$NoNewline + } + Microsoft.PowerShell.Management\Set-Content -LiteralPath $LiteralPath -Value $Value -Encoding $Encoding -NoNewline:$NoNewline +} + +function global:Move-Item { + [CmdletBinding()] + param( + [Parameter(Mandatory=$true)][string]$LiteralPath, + [Parameter(Mandatory=$true)][string]$Destination + ) + + $leafName = [IO.Path]::GetFileName($LiteralPath) + $destinationLeaf = [IO.Path]::GetFileName($Destination) + if ($global:AtomicFixtureFailurePoint -eq "current-move" -and + $leafName -match '^\.current-[0-9a-f]{32}$') { + throw "Injected transaction failure: current-move" + } + if ($global:AtomicFixtureFailurePoint -eq "atomic-current-move" -and + $leafName -match '^\.atomic-current-[0-9a-f]{32}$') { + throw "Injected transaction failure: atomic-current-move" + } + + $restoreName = $null + if ($leafName -match '^\.atomic-backup-[0-9a-f]{32}\.cmd$' -and $destinationLeaf -eq "atomic.cmd") { $restoreName = "shim-restore" } + elseif ($leafName -match '^\.atomic-current-backup-[0-9a-f]{32}$' -and $destinationLeaf -eq "atomic-current") { $restoreName = "atomic-current-restore" } + elseif ($leafName -match '^\.current-backup-[0-9a-f]{32}$' -and $destinationLeaf -eq "current") { $restoreName = "current-restore" } + elseif ($leafName -match '^\.backup-[0-9a-f]{32}$' -and $destinationLeaf -eq "2.0.0") { $restoreName = "version-restore" } + if ($global:AtomicFixtureRollbackArmed -and + -not $global:AtomicFixtureRollbackFailureDelivered -and + $global:AtomicFixtureRollbackFailurePoint -eq $restoreName) { + $global:AtomicFixtureRollbackFailureDelivered = $true + throw "Injected one-shot rollback restore failure: $restoreName" + } + + Microsoft.PowerShell.Management\Move-Item -LiteralPath $LiteralPath -Destination $Destination + if (-not [string]::IsNullOrWhiteSpace($global:AtomicFixtureRollbackFailurePoint) -and + $leafName -match '^\.atomic-[0-9a-f]{32}\.cmd$' -and $destinationLeaf -eq "atomic.cmd") { + $global:AtomicFixtureRollbackArmed = $true + } +} + +$environmentNames = @( + "ATOMIC_INSTALL_DIR", "ATOMIC_BIN_DIR", "ATOMIC_VERSION", "GITHUB_TOKEN", "GH_TOKEN", + "PROCESSOR_ARCHITEW6432", "PROCESSOR_ARCHITECTURE", "TEMP", "TMP", "PATHEXT", + "ATOMIC_FIXTURE_FAIL_INSTALLED_VERSION" +) +$originalEnvironment = @{} +foreach ($name in $environmentNames) { + $originalEnvironment[$name] = [Environment]::GetEnvironmentVariable($name, "Process") +} +$originalUserPath = [Environment]::GetEnvironmentVariable("Path", "User") + +try { + $env:ATOMIC_INSTALL_DIR = $installRoot + $env:ATOMIC_BIN_DIR = $binDir + $env:TEMP = $fixtureTemp + $env:TMP = $fixtureTemp + $env:GITHUB_TOKEN = "github-token" + $env:GH_TOKEN = "gh-token" + $env:ATOMIC_FIXTURE_FAIL_INSTALLED_VERSION = $null + $env:PATHEXT = ".COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC" + if ($Scenario -eq "hash-fallback") { + function global:Get-Command { + [CmdletBinding()] + param([string]$Name) + if ($Name -eq "Get-FileHash") { + return $null + } + throw "Unexpected Get-Command lookup: $Name" + } + } + + if ($Scenario -eq "install") { + $helpOutput = & $InstallerPath -Help | Out-String + Assert-Fixture ($helpOutput -match 'Usage:[\s\S]+-Ref [\s\S]+-Help') "-Help did not describe supported parameters" + Assert-Fixture ($helpOutput -match 'Default bin directory:.*LOCALAPPDATA\\atomic\\bin') "-Help did not document the bin default" + Assert-Fixture ($global:AtomicFixtureRequests.Count -eq 0) "-Help performed a network request" + + $env:ATOMIC_VERSION = "environment-must-not-win" + $env:PROCESSOR_ARCHITEW6432 = "AMD64" + $env:PROCESSOR_ARCHITECTURE = "ARM64" + & $InstallerPath -Ref "1.0.0" | Out-Null + + $versionOne = Join-Path $installRoot "versions\1.0.0" + $current = Join-Path $installRoot "current" + $atomicCurrent = Join-Path $binDir "atomic-current" + $shim = Join-Path $binDir "atomic.cmd" + Assert-Fixture (Test-Path -LiteralPath (Join-Path $versionOne "nested\full-payload.txt")) "flat ZIP full payload was not retained" + Assert-Fixture (Test-Path -LiteralPath (Join-Path $current "atomic.exe")) "current pointer does not resolve atomic.exe" + Assert-Fixture (((Get-Item -LiteralPath $current -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) "current is not a junction" + Assert-Fixture (((Get-Item -LiteralPath $atomicCurrent -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) "atomic-current is not a sibling junction" + Assert-Fixture ((Get-Content -LiteralPath (Join-Path $atomicCurrent "version.txt") -Raw) -eq "1.0.0") "atomic-current does not target the installed version" + $expectedShimContent = '@echo off' + [Environment]::NewLine + '"%~dp0atomic-current\atomic.exe" %*' + [Environment]::NewLine + 'exit /b %ERRORLEVEL%' + [Environment]::NewLine + Assert-Fixture ((Get-Content -LiteralPath $shim -Raw) -ceq $expectedShimContent) "shim source is not the exact relative atomic-current command" + $versionProbe = Invoke-FixtureShim $shim "--version" + Assert-Fixture ($versionProbe.ExitCode -eq 0 -and $versionProbe.Output -eq "1.0.0") "cmd.exe shim --version failed" + $argumentProbe = Invoke-FixtureShim $shim '--probe "hello world"' + Assert-Fixture ($argumentProbe.ExitCode -eq 0 -and $argumentProbe.Output -eq "1.0.0:--probe|hello world") "cmd.exe shim did not forward arguments" + $exitProbe = Invoke-FixtureShim $shim "--exit 37" + Assert-Fixture ($exitProbe.ExitCode -eq 37) "shim did not preserve atomic.exe exit status" + Assert-Fixture (Test-ExactPathEntry ([Environment]::GetEnvironmentVariable("Path", "User")) $binDir) "User PATH was not persisted" + Assert-Fixture (Test-ExactPathEntry $env:Path $binDir) "current PATH was not refreshed" + + $firstApiRequest = @($global:AtomicFixtureRequests | Where-Object { $_.Uri -match '/releases/tags/1\.0\.0$' })[0] + Assert-Fixture ($null -ne $firstApiRequest) "explicit -Ref did not use the exact tag endpoint" + Assert-Fixture ($firstApiRequest.Authorization -eq "Bearer github-token") "GITHUB_TOKEN did not win over GH_TOKEN" + Assert-Fixture (@($global:AtomicFixtureRequests | Where-Object { $_.Uri -match '/releases/download/1\.0\.0/' }).Count -eq 2) "explicit -Ref did not download the requested release" + Assert-Fixture (@($global:AtomicFixtureRequests | Where-Object { $_.Uri -match 'environment-must-not-win' }).Count -eq 0) "ATOMIC_VERSION overrode explicit -Ref" + Assert-Fixture (@($global:AtomicFixtureRequests | Where-Object { $_.Uri -match 'atomic-windows-x64\.zip$' }).Count -eq 1) "WOW64 architecture did not select x64" + + Set-Content -LiteralPath (Join-Path $versionOne "stale.txt") -Value "stale" + $env:GITHUB_TOKEN = $null + $env:GH_TOKEN = "gh-token" + $env:ATOMIC_VERSION = "1.0.0" + & $InstallerPath | Out-Null + Assert-Fixture (-not (Test-Path -LiteralPath (Join-Path $versionOne "stale.txt"))) "same-version reinstall was not clean" + $fallbackApiRequests = @($global:AtomicFixtureRequests | Where-Object { $_.Uri -match '/releases/tags/1\.0\.0$' }) + Assert-Fixture ($fallbackApiRequests[$fallbackApiRequests.Count - 1].Authorization -eq "Bearer gh-token") "GH_TOKEN was not used when GITHUB_TOKEN was unset" + + $env:PROCESSOR_ARCHITEW6432 = $null + $env:PROCESSOR_ARCHITECTURE = "ARM64" + & $InstallerPath -Ref "2.0.0" | Out-Null + Assert-Fixture (Test-Path -LiteralPath (Join-Path $installRoot "versions\1.0.0\atomic.exe")) "upgrade removed the older version" + Assert-Fixture ((Get-Content -LiteralPath (Join-Path $current "version.txt") -Raw) -eq "2.0.0") "upgrade did not repoint current" + Assert-Fixture ((Get-Content -LiteralPath (Join-Path $current "asset.txt") -Raw) -eq "atomic-windows-arm64.zip") "ARM64 archive was not extracted" + Assert-Fixture (@($global:AtomicFixtureRequests | Where-Object { $_.Uri -match 'atomic-windows-arm64\.zip$' }).Count -eq 1) "ARM64 architecture did not select arm64" + + $env:ATOMIC_VERSION = $null + $defaultRequestStart = $global:AtomicFixtureRequests.Count + $global:AtomicFixtureFailApi = $true + & $InstallerPath | Out-Null + $defaultRequests = @($global:AtomicFixtureRequests | Select-Object -Skip $defaultRequestStart) + Assert-Fixture (@($defaultRequests | Where-Object { $_.Uri -eq 'https://github.com/bastani-inc/atomic/releases/latest' }).Count -eq 1) "default install did not try the stable release redirect" + Assert-Fixture (@($defaultRequests | Where-Object { $_.Uri -match '^https://api\.github\.com/' }).Count -eq 0) "successful stable redirect queried the GitHub API" + Assert-Fixture (@($defaultRequests | Where-Object { $_.Uri -match '/releases/download/2\.0\.0/' -and $null -ne $_.Authorization }).Count -eq 0) "token header leaked to release downloads" + + $global:AtomicFixtureFailApi = $false + $global:AtomicFixtureRedirectFails = $true + $fallbackRequestStart = $global:AtomicFixtureRequests.Count + & $InstallerPath | Out-Null + $fallbackRequests = @($global:AtomicFixtureRequests | Select-Object -Skip $fallbackRequestStart) + Assert-Fixture (@($fallbackRequests | Where-Object { $_.Uri -match '/repos/bastani-inc/atomic/releases/latest$' }).Count -eq 1) "failed stable redirect did not query the latest release API" + Assert-Fixture (@($fallbackRequests | Where-Object { $_.Uri -match '/repos/bastani-inc/atomic/releases/latest$' -and $_.Authorization -eq 'Bearer gh-token' }).Count -eq 1) "latest API fallback did not use the configured token" + $global:AtomicFixtureRedirectFails = $false + + Assert-Fixture ((Get-ExactPathEntryCount ([Environment]::GetEnvironmentVariable("Path", "User")) $binDir) -eq 1) "User PATH contains duplicate bin entries" + Assert-Fixture ((Get-ExactPathEntryCount $env:Path $binDir) -eq 1) "current PATH contains duplicate bin entries" + Assert-Fixture (@(Get-ChildItem -LiteralPath (Join-Path $installRoot "versions") -Force | Where-Object { $_.Name -like ".stage-*" -or $_.Name -like ".backup-*" }).Count -eq 0) "version transaction artifacts were not cleaned" + Assert-Fixture (@(Get-ChildItem -LiteralPath $installRoot -Force | Where-Object { $_.Name -like ".current-*" }).Count -eq 0) "current transaction artifacts were not cleaned" + Assert-Fixture (@(Get-ChildItem -LiteralPath $binDir -Force | Where-Object { $_.Name -like ".atomic-*" }).Count -eq 0) "shim transaction artifacts were not cleaned" + } + elseif ($Scenario -eq "tag-grammar") { + $env:PROCESSOR_ARCHITEW6432 = "AMD64" + $env:PROCESSOR_ARCHITECTURE = "AMD64" + + foreach ($invalidTag in @("v1.0.0", "1.0", "1.0.0.0", "1.0.0-alpha.0", "1.0.0-beta.1", "1.0.0-alpha", "01.0.0", "release/1.0", "hash#tag", "percent%tag")) { + $requestStart = @($global:AtomicFixtureRequests).Count + $rejected = $null + try { & $InstallerPath -Ref $invalidTag | Out-Null } + catch { $rejected = $_ } + Assert-Fixture ($null -ne $rejected) "explicit ref $invalidTag was accepted" + Assert-Fixture ($rejected.Exception.Message -match 'unsupported release tag: expected MAJOR\.MINOR\.PATCH or MAJOR\.MINOR\.PATCH-alpha\.REVISION') "explicit ref $invalidTag was rejected for the wrong reason: $($rejected.Exception.Message)" + Assert-Fixture (@($global:AtomicFixtureRequests).Count -eq $requestStart) "explicit ref $invalidTag performed a request" + Assert-Fixture (-not (Test-Path -LiteralPath $installRoot)) "explicit ref $invalidTag created an install root" + Assert-Fixture (-not (Test-Path -LiteralPath $binDir)) "explicit ref $invalidTag created a bin directory" + Assert-Fixture (@(Get-ChildItem -LiteralPath $fixtureTemp -Filter "atomic-install-*" -Force).Count -eq 0) "explicit ref $invalidTag created transaction temp state" + } + + $env:ATOMIC_VERSION = "not-a-tag" + $envRejected = $null + $envRequestStart = @($global:AtomicFixtureRequests).Count + try { & $InstallerPath | Out-Null } + catch { $envRejected = $_ } + Assert-Fixture ($null -ne $envRejected) "ATOMIC_VERSION was not validated" + Assert-Fixture ($envRejected.Exception.Message -match 'unsupported release tag') "ATOMIC_VERSION rejection used the wrong message" + Assert-Fixture (@($global:AtomicFixtureRequests).Count -eq $envRequestStart) "ATOMIC_VERSION rejection performed a request" + $env:ATOMIC_VERSION = $null + + foreach ($validTag in @("1.0.0", "1.0.0-alpha.1")) { + & $InstallerPath -Ref $validTag | Out-Null + Assert-Fixture (Test-Path -LiteralPath (Join-Path $installRoot ("versions\" + [Uri]::EscapeDataString($validTag) + "\atomic.exe"))) "valid tag $validTag did not install" + Assert-NoTransactionResidue $installRoot $binDir + } + Remove-Item -LiteralPath $installRoot -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $binDir -Recurse -Force -ErrorAction SilentlyContinue + + $global:AtomicFixtureRedirectTag = "release/1.0" + $redirectRequestStart = @($global:AtomicFixtureRequests).Count + $redirectRejected = $null + try { & $InstallerPath | Out-Null } + catch { $redirectRejected = $_ } + Assert-Fixture ($null -ne $redirectRejected) "an unsupported latest redirect tag was accepted" + Assert-Fixture ($redirectRejected.Exception.Message -match 'unsupported release tag') "latest redirect rejection used the wrong message" + $redirectRequests = @($global:AtomicFixtureRequests | Select-Object -Skip $redirectRequestStart) + Assert-Fixture (@($redirectRequests | Where-Object { $_.Uri -match '/releases/download/' }).Count -eq 0) "an unsupported latest redirect tag still downloaded a release" + Assert-Fixture (-not (Test-Path -LiteralPath $installRoot)) "an unsupported latest redirect tag created an install root" + $global:AtomicFixtureRedirectTag = "2.0.0" + + $global:AtomicFixtureRedirectFails = $true + $global:AtomicFixtureLatestApiTag = "not-a-tag" + $apiRequestStart = @($global:AtomicFixtureRequests).Count + $apiRejected = $null + try { & $InstallerPath | Out-Null } + catch { $apiRejected = $_ } + Assert-Fixture ($null -ne $apiRejected) "an unsupported latest API tag_name was accepted" + Assert-Fixture ($apiRejected.Exception.Message -match 'unsupported release tag') "latest API rejection used the wrong message" + $apiRequests = @($global:AtomicFixtureRequests | Select-Object -Skip $apiRequestStart) + Assert-Fixture (@($apiRequests | Where-Object { $_.Uri -match '/releases/download/' }).Count -eq 0) "an unsupported latest API tag_name still downloaded a release" + Assert-Fixture (-not (Test-Path -LiteralPath $installRoot)) "an unsupported latest API tag_name created an install root" + $global:AtomicFixtureLatestApiTag = "2.0.0" + $global:AtomicFixtureRedirectFails = $false + } + elseif ($Scenario -eq "missing-cmd-pathext") { + $env:PROCESSOR_ARCHITEW6432 = "AMD64" + $env:PROCESSOR_ARCHITECTURE = "AMD64" + $env:PATHEXT = ".EXE;.BAT" + $requestStart = @($global:AtomicFixtureRequests).Count + $rejected = $null + try { & $InstallerPath -Ref "1.0.0" | Out-Null } + catch { $rejected = $_ } + Assert-Fixture ($null -ne $rejected) "a PATHEXT without .CMD reported success" + Assert-Fixture ($rejected.Exception.Message -match '\.CMD') "the missing-.CMD rejection did not name .CMD" + Assert-Fixture ($rejected.Exception.Message -match 'bare atomic') "the missing-.CMD rejection did not name bare atomic" + Assert-Fixture (@($global:AtomicFixtureRequests).Count -eq $requestStart) "the missing-.CMD rejection performed a request" + Assert-Fixture (-not (Test-Path -LiteralPath $installRoot)) "the missing-.CMD rejection created an install root" + Assert-Fixture (-not (Test-Path -LiteralPath $binDir)) "the missing-.CMD rejection created a bin directory" + $rejectionResidue = Get-TempResidueReport $fixtureTemp + Assert-Fixture ($rejectionResidue.Count -eq 0) "the missing-.CMD rejection created transaction temp state: $($rejectionResidue.Paths)" + + foreach ($acceptedPathExt in @(".cmd;.EXE", ' " .CMD " ; ".EXE" ', ".EXE;.CMD;.BAT", $null)) { + Remove-Item -LiteralPath $installRoot -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $binDir -Recurse -Force -ErrorAction SilentlyContinue + $env:PATHEXT = $acceptedPathExt + & $InstallerPath -Ref "1.0.0" | Out-Null + Assert-Fixture (Test-Path -LiteralPath (Join-Path $binDir "atomic.cmd")) "PATHEXT '$acceptedPathExt' blocked a valid install" + Assert-NoTransactionResidue $installRoot $binDir + } + $env:PATHEXT = ".COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC" + } + elseif ($Scenario -eq "pointer-conflicts") { + $env:PROCESSOR_ARCHITEW6432 = "AMD64" + $env:PROCESSOR_ARCHITECTURE = "AMD64" + + foreach ($case in @( + [pscustomobject]@{ Label = "current directory"; Parent = "install"; Name = "current"; Kind = "Directory"; Message = 'ATOMIC_INSTALL_DIR contains an unexpected current entry' }, + [pscustomobject]@{ Label = "current file"; Parent = "install"; Name = "current"; Kind = "File"; Message = 'ATOMIC_INSTALL_DIR contains an unexpected current entry' }, + [pscustomobject]@{ Label = "atomic-current directory"; Parent = "bin"; Name = "atomic-current"; Kind = "Directory"; Message = 'ATOMIC_BIN_DIR contains an unexpected atomic-current entry' }, + [pscustomobject]@{ Label = "atomic-current file"; Parent = "bin"; Name = "atomic-current"; Kind = "File"; Message = 'ATOMIC_BIN_DIR contains an unexpected atomic-current entry' } + )) { + Remove-Item -LiteralPath $installRoot -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $binDir -Recurse -Force -ErrorAction SilentlyContinue + $parentPath = if ($case.Parent -eq "install") { $installRoot } else { $binDir } + New-Item -ItemType Directory -Path $parentPath -Force | Out-Null + $conflictPath = Join-Path $parentPath $case.Name + if ($case.Kind -eq "Directory") { + New-Item -ItemType Directory -Path $conflictPath -Force | Out-Null + Set-Content -LiteralPath (Join-Path $conflictPath "marker.txt") -Value "caller-data" -Encoding ASCII -NoNewline + } + else { + Set-Content -LiteralPath $conflictPath -Value "caller-data" -Encoding ASCII -NoNewline + } + + $requestStart = @($global:AtomicFixtureRequests).Count + $rejected = $null + try { & $InstallerPath -Ref "1.0.0" | Out-Null } + catch { $rejected = $_ } + Assert-Fixture ($null -ne $rejected) "a regular $($case.Label) was accepted" + Assert-Fixture ($rejected.Exception.Message -match $case.Message) "the $($case.Label) rejection used the wrong message: $($rejected.Exception.Message)" + Assert-Fixture (@($global:AtomicFixtureRequests).Count -eq $requestStart) "the $($case.Label) rejection performed a request" + if ($case.Kind -eq "Directory") { + Assert-Fixture ((Get-Content -LiteralPath (Join-Path $conflictPath "marker.txt") -Raw) -eq "caller-data") "the $($case.Label) marker data was not preserved" + } + else { + Assert-Fixture ((Get-Content -LiteralPath $conflictPath -Raw) -eq "caller-data") "the $($case.Label) file content was not preserved" + } + Assert-Fixture (-not (Test-Path -LiteralPath (Join-Path $installRoot "versions"))) "the $($case.Label) rejection created a versions directory" + Assert-Fixture (-not (Test-Path -LiteralPath (Join-Path $binDir "atomic.cmd"))) "the $($case.Label) rejection created a shim" + Assert-Fixture (@(Get-ChildItem -LiteralPath $fixtureTemp -Filter "atomic-install-*" -Force).Count -eq 0) "the $($case.Label) rejection created transaction temp state" + Assert-NoTransactionResidue $installRoot $binDir + } + + Remove-Item -LiteralPath $installRoot -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $binDir -Recurse -Force -ErrorAction SilentlyContinue + & $InstallerPath -Ref "1.0.0" | Out-Null + Assert-Fixture (Test-Path -LiteralPath (Join-Path $binDir "atomic.cmd")) "the reparse-point control install did not complete" + & $InstallerPath -Ref "2.0.0" | Out-Null + Assert-Fixture ((Get-Content -LiteralPath (Join-Path $installRoot "current\version.txt") -Raw) -eq "2.0.0") "an installer-owned current junction was not replaced" + Assert-NoTransactionResidue $installRoot $binDir + } + elseif ($Scenario -eq "preflight-errors") { + $env:PROCESSOR_ARCHITEW6432 = "AMD64" + $env:PROCESSOR_ARCHITECTURE = "AMD64" + New-Item -ItemType Directory -Path $binDir -Force | Out-Null + $stalePath = Join-Path $binDir "atomic.exe" + Copy-Item -LiteralPath $fixtureExecutable -Destination $stalePath + + $requestStart = @($global:AtomicFixtureRequests).Count + $preflightError = $null + try { & $InstallerPath -Ref "1.0.0" | Out-Null } + catch { $preflightError = $_ } + Assert-Fixture ($null -ne $preflightError) "the preflight blocker did not fail" + Assert-Fixture ($preflightError.Exception.Message -ceq "ATOMIC_BIN_DIR contains atomic.exe, which PATHEXT resolves before atomic.cmd; remove it and rerun the installer.") "the preflight error was replaced: $($preflightError.Exception.Message)" + Assert-Fixture ($preflightError.Exception.Message -notmatch 'variable') "the preflight error mentions an uninitialized variable" + Assert-Fixture (@($global:AtomicFixtureRequests).Count -eq $requestStart) "the preflight blocker performed a request" + Assert-Fixture (-not (Test-Path -LiteralPath $installRoot)) "the preflight blocker created an install root" + Remove-Item -LiteralPath $stalePath -Force + + $global:AtomicFixtureFailApi = $true + $global:AtomicFixtureRedirectFails = $true + $apiError = $null + try { & $InstallerPath -Ref "1.0.0" | Out-Null } + catch { $apiError = $_ } + $global:AtomicFixtureFailApi = $false + $global:AtomicFixtureRedirectFails = $false + Assert-Fixture ($null -ne $apiError) "the failing API request did not fail the install" + Assert-Fixture ($apiError.Exception.Message -match 'Failed to query GitHub release API at https://api\.github\.com/repos/bastani-inc/atomic/releases/tags/1\.0\.0') "the API error was replaced: $($apiError.Exception.Message)" + Assert-Fixture ($apiError.Exception.Message -notmatch 'variable') "the API error mentions an uninitialized variable" + Assert-Fixture (-not (Test-Path -LiteralPath $installRoot)) "the failing API request created an install root" + Assert-Fixture (@(Get-ChildItem -LiteralPath $fixtureTemp -Filter "atomic-install-*" -Force).Count -eq 0) "the failing API request created transaction temp state" + } + elseif ($Scenario -eq "checksum") { + $env:PROCESSOR_ARCHITEW6432 = "AMD64" + $env:PROCESSOR_ARCHITECTURE = "AMD64" + & $InstallerPath -Ref "1.0.0" | Out-Null + $oldMarker = Join-Path $installRoot "versions\1.0.0\preserve.txt" + Set-Content -LiteralPath $oldMarker -Value "old-state" -Encoding ASCII -NoNewline + $global:AtomicFixtureBadChecksumTag = "2.0.0" + + $failure = $null + try { & $InstallerPath -Ref "2.0.0" | Out-Null } + catch { $failure = $_ } + Assert-Fixture ($null -ne $failure -and $failure.Exception.Message -match 'Checksum verification failed') "bad checksum was not rejected" + Assert-Fixture ((Get-Content -LiteralPath $oldMarker -Raw) -eq "old-state") "checksum rejection mutated the old version" + Assert-Fixture ((Get-Content -LiteralPath (Join-Path $installRoot "current\version.txt") -Raw) -eq "1.0.0") "checksum rejection changed current" + Assert-Fixture (-not (Test-Path -LiteralPath (Join-Path $installRoot "versions\2.0.0"))) "checksum rejection installed the new version" + } + elseif ($Scenario -eq "final-smoke") { + $env:PROCESSOR_ARCHITEW6432 = "AMD64" + $env:PROCESSOR_ARCHITECTURE = "AMD64" + $beforeUserPath = [Environment]::GetEnvironmentVariable("Path", "User") + $beforeProcessPath = $env:Path + $env:ATOMIC_FIXTURE_FAIL_INSTALLED_VERSION = "2.0.0" + + $failure = $null + try { & $InstallerPath -Ref "2.0.0" | Out-Null } + catch { $failure = $_ } + Assert-Fixture ($null -ne $failure -and $failure.Exception.Message -match 'Installed atomic\.cmd --version failed') "final shim smoke failure was not reported" + Assert-Fixture ([Environment]::GetEnvironmentVariable("Path", "User") -eq $beforeUserPath) "failed final smoke did not restore User PATH" + Assert-Fixture ($env:Path -eq $beforeProcessPath) "failed final smoke did not restore current PATH" + Assert-Fixture (-not (Test-Path -LiteralPath $installRoot)) "failed final smoke left a new installation" + Assert-Fixture (-not (Test-Path -LiteralPath $binDir)) "failed final smoke left a new bin directory" + } + elseif ($Scenario -eq "unicode") { + $env:PROCESSOR_ARCHITEW6432 = "AMD64" + $env:PROCESSOR_ARCHITECTURE = "AMD64" + & $InstallerPath -Ref "1.0.0" | Out-Null + + $current = Join-Path $installRoot "current" + $atomicCurrent = Join-Path $binDir "atomic-current" + $shim = Join-Path $binDir "atomic.cmd" + $expectedShimContent = '@echo off' + [Environment]::NewLine + '"%~dp0atomic-current\atomic.exe" %*' + [Environment]::NewLine + 'exit /b %ERRORLEVEL%' + [Environment]::NewLine + $shimBytes = [IO.File]::ReadAllBytes($shim) + Assert-Fixture ($installRoot.Contains("安装") -and $installRoot.Contains("Δοκιμή")) "Unicode fixture root lost CJK or Greek text" + Assert-Fixture ($binDir.Contains("自訂") -and -not $binDir.StartsWith($installRoot, [StringComparison]::OrdinalIgnoreCase)) "Unicode fixture did not use a separate custom bin directory" + Assert-Fixture (-not (Test-ByteSequence $shimBytes ([byte[]]@(0xFF, 0xFE)))) "shim retained a UTF-16LE BOM" + Assert-Fixture (-not (Test-ByteSequence $shimBytes ([byte[]]@(0xEF, 0xBB, 0xBF)))) "shim retained a UTF-8 BOM" + Assert-Fixture (@($shimBytes | Where-Object { $_ -eq 0 }).Count -eq 0) "shim contains NUL bytes" + Assert-Fixture (@($shimBytes | Where-Object { $_ -gt 0x7F }).Count -eq 0) "shim contains non-ASCII bytes" + Assert-Fixture (([Text.Encoding]::ASCII.GetString($shimBytes)) -ceq $expectedShimContent) "shim bytes do not decode to the exact relative command" + Assert-Fixture (((Get-Item -LiteralPath $atomicCurrent -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) "custom bin atomic-current is not a junction" + Assert-Fixture ((Get-Content -LiteralPath (Join-Path $atomicCurrent "version.txt") -Raw) -eq "1.0.0") "custom bin atomic-current does not target the installed version" + $versionProbe = Invoke-FixtureShim $shim "--version" + Assert-Fixture ($versionProbe.ExitCode -eq 0 -and $versionProbe.Output -eq "1.0.0") "Unicode install shim --version failed through cmd.exe" + $argumentProbe = Invoke-FixtureShim $shim '--probe "hello world"' + Assert-Fixture ($argumentProbe.ExitCode -eq 0 -and $argumentProbe.Output -eq "1.0.0:--probe|hello world") "Unicode install shim did not forward arguments through cmd.exe" + + $oldShimContent = '@rem rollback-marker' + [Environment]::NewLine + $expectedShimContent + Set-Content -LiteralPath $shim -Value $oldShimContent -Encoding ASCII -NoNewline + $oldShimBytes = [IO.File]::ReadAllBytes($shim) + $oldPairProbe = Invoke-FixtureShim $shim "--version" + Assert-Fixture ($oldPairProbe.ExitCode -eq 0 -and $oldPairProbe.Output -eq "1.0.0") "old shim and junction pair was not executable before rollback test" + $beforeUserPath = [Environment]::GetEnvironmentVariable("Path", "User") + $beforeProcessPath = $env:Path + $env:ATOMIC_FIXTURE_FAIL_INSTALLED_VERSION = "2.0.0" + $failure = $null + try { & $InstallerPath -Ref "2.0.0" | Out-Null } + catch { $failure = $_ } + Assert-Fixture ($null -ne $failure -and $failure.Exception.Message -match 'Installed atomic\.cmd --version failed') "Unicode-path final shim smoke failure was not reported" + Assert-Fixture ([Environment]::GetEnvironmentVariable("Path", "User") -eq $beforeUserPath) "Unicode-path rollback changed User PATH" + Assert-Fixture ($env:Path -eq $beforeProcessPath) "Unicode-path rollback changed current PATH" + Assert-Fixture ((Get-Content -LiteralPath (Join-Path $current "version.txt") -Raw) -eq "1.0.0") "Unicode-path rollback did not restore current" + Assert-Fixture ((Get-Content -LiteralPath (Join-Path $atomicCurrent "version.txt") -Raw) -eq "1.0.0") "Unicode-path rollback did not restore the old atomic-current junction" + Assert-Fixture ([Convert]::ToBase64String([IO.File]::ReadAllBytes($shim)) -ceq [Convert]::ToBase64String($oldShimBytes)) "Unicode-path rollback did not restore the old shim bytes" + Assert-Fixture (-not (Test-Path -LiteralPath (Join-Path $installRoot "versions\2.0.0"))) "Unicode-path rollback retained the failed version" + $rollbackProbe = Invoke-FixtureShim $shim "--version" + Assert-Fixture ($rollbackProbe.ExitCode -eq 0 -and $rollbackProbe.Output -eq "1.0.0") "Unicode-path rollback did not restore the executable shim and junction pair" + Assert-NoTransactionResidue $installRoot $binDir + } + elseif ($Scenario -eq "dangling-junction") { + $env:PROCESSOR_ARCHITEW6432 = "AMD64" + $env:PROCESSOR_ARCHITECTURE = "AMD64" + & $InstallerPath -Ref "1.0.0" | Out-Null + + $versionOne = Join-Path $installRoot "versions\1.0.0" + $current = Join-Path $installRoot "current" + $atomicCurrent = Join-Path $binDir "atomic-current" + $shim = Join-Path $binDir "atomic.cmd" + Remove-Item -LiteralPath $versionOne -Recurse -Force + $danglingCurrent = @(Get-ChildItem -LiteralPath $installRoot -Force | Where-Object { $_.Name -eq "current" }) + Assert-Fixture ($danglingCurrent.Count -eq 1) "deleting the version also removed the current junction entry" + Assert-Fixture (($danglingCurrent[0].Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) "dangling current entry is not a junction" + Assert-Fixture (-not [IO.Directory]::Exists($versionOne)) "version target was not deleted" + + $danglingAtomicCurrent = @(Get-ChildItem -LiteralPath $binDir -Force | Where-Object { $_.Name -eq "atomic-current" }) + Assert-Fixture ($danglingAtomicCurrent.Count -eq 1 -and ($danglingAtomicCurrent[0].Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) "deleting the version did not leave the atomic-current junction dangling" + + & $InstallerPath -Ref "1.0.0" | Out-Null + $repairedCurrent = @(Get-ChildItem -LiteralPath $installRoot -Force | Where-Object { $_.Name -eq "current" }) + Assert-Fixture ($repairedCurrent.Count -eq 1) "same-version reinstall did not leave one current entry" + Assert-Fixture (($repairedCurrent[0].Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) "same-version reinstall did not recreate current as a junction" + Assert-Fixture (Test-Path -LiteralPath (Join-Path $current "atomic.exe")) "recreated current junction does not resolve atomic.exe" + Assert-Fixture ((Get-Content -LiteralPath (Join-Path $atomicCurrent "version.txt") -Raw) -eq "1.0.0") "recreated atomic-current junction does not resolve the installed version" + $reinstallProbe = Invoke-FixtureShim $shim "--version" + Assert-Fixture ($reinstallProbe.ExitCode -eq 0 -and $reinstallProbe.Output -eq "1.0.0") "shim failed through cmd.exe after dangling junction recovery" + Assert-NoTransactionResidue $installRoot $binDir + $global:AtomicFixtureFailurePoint = "current-move" + $failure = $null + try { & $InstallerPath -Ref "2.0.0" | Out-Null } + catch { $failure = $_ } + $global:AtomicFixtureFailurePoint = $null + Assert-Fixture ($null -ne $failure -and $failure.Exception.Message -match 'Injected transaction failure: current-move') "temporary current failure was not injected" + Assert-Fixture ((Get-Content -LiteralPath (Join-Path $current "version.txt") -Raw) -eq "1.0.0") "failed transaction did not restore current" + Assert-Fixture (-not (Test-Path -LiteralPath (Join-Path $installRoot "versions\2.0.0"))) "failed transaction retained the new version" + $rollbackProbe = Invoke-FixtureShim $shim "--version" + Assert-Fixture ($rollbackProbe.ExitCode -eq 0 -and $rollbackProbe.Output -eq "1.0.0") "shim failed through cmd.exe after temporary dangling junction cleanup" + Assert-NoTransactionResidue $installRoot $binDir + } + elseif ($Scenario -eq "rollback-retries") { + $env:PROCESSOR_ARCHITEW6432 = "AMD64" + $env:PROCESSOR_ARCHITECTURE = "AMD64" + $rollbackFailurePoints = @( + "shim-remove", "shim-restore", + "atomic-current-remove", "atomic-current-restore", + "current-remove", "current-restore", + "version-remove", "version-restore" + ) + $caseIndex = 0 + foreach ($rollbackFailurePoint in $rollbackFailurePoints) { + $caseIndex++ + $caseRoot = Join-Path $workspace ("rollback-retry-" + $rollbackFailurePoint) + $installRoot = Join-Path $caseRoot "install-root" + $binDir = Join-Path $caseRoot "bin-root" + $caseTemp = Join-Path $caseRoot "temp" + New-Item -ItemType Directory -Path $caseTemp -Force | Out-Null + $env:ATOMIC_INSTALL_DIR = $installRoot + $env:ATOMIC_BIN_DIR = $binDir + $env:TEMP = $caseTemp + $env:TMP = $caseTemp + $env:ATOMIC_FIXTURE_FAIL_INSTALLED_VERSION = $null + $global:AtomicFixtureRollbackFailurePoint = $null + $global:AtomicFixtureRollbackFailureDelivered = $false + $global:AtomicFixtureRollbackArmed = $false + + $beforeCaseUserPath = [Environment]::GetEnvironmentVariable("Path", "User") + $beforeCaseProcessPath = $env:Path + & $InstallerPath -Ref "2.0.0" | Out-Null + $versionPath = Join-Path $installRoot "versions\2.0.0" + $currentPath = Join-Path $installRoot "current" + $atomicCurrentPath = Join-Path $binDir "atomic-current" + $shimPath = Join-Path $binDir "atomic.cmd" + $payloadMarker = Join-Path $versionPath "rollback-payload.bin" + [IO.File]::WriteAllBytes($payloadMarker, [byte[]]@(0, 3, 17, 127, 128, 244, 255)) + $oldPayloadBytes = [Convert]::ToBase64String([IO.File]::ReadAllBytes($payloadMarker)) + $oldAtomicBytes = [Convert]::ToBase64String([IO.File]::ReadAllBytes((Join-Path $versionPath "atomic.exe"))) + $oldShimContent = '@rem rollback-retry-' + $rollbackFailurePoint + [Environment]::NewLine + '@echo off' + [Environment]::NewLine + '"%~dp0atomic-current\atomic.exe" %*' + [Environment]::NewLine + 'exit /b %ERRORLEVEL%' + [Environment]::NewLine + Set-Content -LiteralPath $shimPath -Value $oldShimContent -Encoding ASCII -NoNewline + $oldShimBytes = [Convert]::ToBase64String([IO.File]::ReadAllBytes($shimPath)) + $oldUserPath = "C:\AtomicRollbackUser-$caseIndex" + $oldProcessPath = "C:\AtomicRollbackProcess-$caseIndex" + [Environment]::SetEnvironmentVariable("Path", $oldUserPath, "User") + $env:Path = $oldProcessPath + + $global:AtomicFixtureRollbackFailurePoint = $rollbackFailurePoint + $global:AtomicFixtureRollbackFailureDelivered = $false + $global:AtomicFixtureRollbackArmed = $false + $env:ATOMIC_FIXTURE_FAIL_INSTALLED_VERSION = "2.0.0" + $failure = $null + try { & $InstallerPath -Ref "2.0.0" | Out-Null } + catch { $failure = $_ } + $env:ATOMIC_FIXTURE_FAIL_INSTALLED_VERSION = $null + + Assert-Fixture ($null -ne $failure -and $failure.Exception.Message -match 'Installed atomic\.cmd --version failed') "$rollbackFailurePoint did not reach the real final smoke failure" + Assert-Fixture ($global:AtomicFixtureRollbackFailureDelivered) "$rollbackFailurePoint one-shot rollback failure was not delivered" + Assert-Fixture ([Environment]::GetEnvironmentVariable("Path", "User") -ceq $oldUserPath) "$rollbackFailurePoint did not restore User PATH on a later rollback attempt" + Assert-Fixture ($env:Path -ceq $oldProcessPath) "$rollbackFailurePoint did not restore current PATH on a later rollback attempt" + Assert-Fixture ([Convert]::ToBase64String([IO.File]::ReadAllBytes($payloadMarker)) -ceq $oldPayloadBytes) "$rollbackFailurePoint changed old payload bytes" + Assert-Fixture ([Convert]::ToBase64String([IO.File]::ReadAllBytes((Join-Path $currentPath "rollback-payload.bin"))) -ceq $oldPayloadBytes) "$rollbackFailurePoint did not restore current payload bytes" + Assert-Fixture ([Convert]::ToBase64String([IO.File]::ReadAllBytes((Join-Path $atomicCurrentPath "rollback-payload.bin"))) -ceq $oldPayloadBytes) "$rollbackFailurePoint did not restore atomic-current payload bytes" + Assert-Fixture ([Convert]::ToBase64String([IO.File]::ReadAllBytes((Join-Path $versionPath "atomic.exe"))) -ceq $oldAtomicBytes) "$rollbackFailurePoint changed old executable bytes" + Assert-Fixture ([Convert]::ToBase64String([IO.File]::ReadAllBytes($shimPath)) -ceq $oldShimBytes) "$rollbackFailurePoint did not restore old shim bytes" + Assert-Fixture (((Get-Item -LiteralPath $currentPath -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) "$rollbackFailurePoint did not restore the current junction" + Assert-Fixture (((Get-Item -LiteralPath $atomicCurrentPath -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) "$rollbackFailurePoint did not restore the atomic-current junction" + $rollbackProbe = Invoke-FixtureShim $shimPath "--version" + Assert-Fixture ($rollbackProbe.ExitCode -eq 0 -and $rollbackProbe.Output -eq "2.0.0") "$rollbackFailurePoint did not leave the old shim pair executable" + Assert-NoTransactionResidue $installRoot $binDir + Assert-Fixture (@(Get-ChildItem -LiteralPath $caseTemp -Filter "atomic-install-*" -Force).Count -eq 0) "$rollbackFailurePoint left a temporary download directory" + + $global:AtomicFixtureRollbackFailurePoint = $null + $global:AtomicFixtureRollbackArmed = $false + [Environment]::SetEnvironmentVariable("Path", $beforeCaseUserPath, "User") + $env:Path = $beforeCaseProcessPath + Remove-Item -LiteralPath $caseRoot -Recurse -Force + } + } + elseif ($Scenario -eq "ctrl-c") { + $env:PROCESSOR_ARCHITEW6432 = "AMD64" + $env:PROCESSOR_ARCHITECTURE = "AMD64" + $ctrlChildPath = Join-Path $workspace "ctrl-c-child.ps1" + $ctrlHelperSourcePath = Join-Path $workspace "ctrl-c-helper.cs" + $ctrlHelperPath = Join-Path $workspace "ctrl-c-helper.exe" + Add-Type -Path $ctrlHelperSourcePath -OutputAssembly $ctrlHelperPath -OutputType ConsoleApplication + $powershellPath = Join-Path $PSHOME "powershell.exe" + + $caseSpecs = @( + [pscustomobject]@{ State = "fresh"; Move = "version-install"; RollbackFailure = "version-remove" }, + [pscustomobject]@{ State = "fresh"; Move = "current-install"; RollbackFailure = "current-remove" }, + [pscustomobject]@{ State = "fresh"; Move = "atomic-current-install"; RollbackFailure = "atomic-current-remove" }, + [pscustomobject]@{ State = "fresh"; Move = "shim-install"; RollbackFailure = "shim-remove" }, + [pscustomobject]@{ State = "existing"; Move = "version-backup"; RollbackFailure = "version-restore" }, + [pscustomobject]@{ State = "existing"; Move = "version-install"; RollbackFailure = "version-remove" }, + [pscustomobject]@{ State = "existing"; Move = "current-backup"; RollbackFailure = "current-restore" }, + [pscustomobject]@{ State = "existing"; Move = "current-install"; RollbackFailure = "current-remove" }, + [pscustomobject]@{ State = "existing"; Move = "atomic-current-backup"; RollbackFailure = "atomic-current-restore" }, + [pscustomobject]@{ State = "existing"; Move = "atomic-current-install"; RollbackFailure = "atomic-current-remove" }, + [pscustomobject]@{ State = "existing"; Move = "shim-backup"; RollbackFailure = "shim-restore" }, + [pscustomobject]@{ State = "existing"; Move = "shim-install"; RollbackFailure = "shim-remove" } + ) + + foreach ($case in $caseSpecs) { + $caseName = $case.State + "-" + $case.Move + "-" + $case.RollbackFailure + $caseRoot = Join-Path $workspace ("ctrl-c-" + $caseName) + $installContainer = Join-Path $caseRoot "created-install-parent" + $binContainer = Join-Path $caseRoot "created-bin-parent" + $installRoot = Join-Path $installContainer "install-root" + $binDir = Join-Path $binContainer "bin-root" + $caseTemp = Join-Path $caseRoot "temp" + New-Item -ItemType Directory -Path $caseRoot -Force | Out-Null + New-Item -ItemType Directory -Path $caseTemp -Force | Out-Null + $parentMarker = Join-Path $caseRoot "pre-existing-parent.txt" + Set-Content -LiteralPath $parentMarker -Value ("keep-" + $caseName) -Encoding ASCII -NoNewline + $env:ATOMIC_INSTALL_DIR = $installRoot + $env:ATOMIC_BIN_DIR = $binDir + $env:TEMP = $caseTemp + $env:TMP = $caseTemp + $beforeCaseUserPath = [Environment]::GetEnvironmentVariable("Path", "User") + $beforeCaseProcessPath = $env:Path + + $oldPayloadBytes = $null + $oldAtomicBytes = $null + $oldShimBytes = $null + if ($case.State -eq "existing") { + & $InstallerPath -Ref "2.0.0" | Out-Null + $versionPath = Join-Path $installRoot "versions\2.0.0" + $payloadMarker = Join-Path $versionPath "preserve.bin" + [IO.File]::WriteAllBytes($payloadMarker, [byte[]]@(0, 1, 2, 127, 128, 255)) + $oldPayloadBytes = [Convert]::ToBase64String([IO.File]::ReadAllBytes($payloadMarker)) + $oldAtomicBytes = [Convert]::ToBase64String([IO.File]::ReadAllBytes((Join-Path $versionPath "atomic.exe"))) + $shimPath = Join-Path $binDir "atomic.cmd" + $oldShimContent = '@rem ctrl-c-' + $case.Move + '-' + $case.RollbackFailure + [Environment]::NewLine + '@echo off' + [Environment]::NewLine + '"%~dp0atomic-current\atomic.exe" %*' + [Environment]::NewLine + 'exit /b %ERRORLEVEL%' + [Environment]::NewLine + Set-Content -LiteralPath $shimPath -Value $oldShimContent -Encoding ASCII -NoNewline + $oldShimBytes = [Convert]::ToBase64String([IO.File]::ReadAllBytes($shimPath)) + } + + $expectedUserPath = [Environment]::GetEnvironmentVariable("Path", "User") + $expectedProcessPath = $env:Path + $readyPath = Join-Path $caseRoot "move-ready.txt" + $moveLogPath = Join-Path $caseRoot "moves.log" + $observedProcessPath = Join-Path $caseRoot "observed-process-path.txt" + $childFinallyPath = Join-Path $caseRoot "child-finally.txt" + $ctrlArguments = @( + $powershellPath, $readyPath, $ctrlChildPath, + "-InstallerPath", $InstallerPath, + "-AssetRoot", $assetRoot, + "-InstallRoot", $installRoot, + "-BinDir", $binDir, + "-TempRoot", $caseTemp, + "-PauseMove", $case.Move, + "-RollbackFailure", $case.RollbackFailure, + "-ReadyPath", $readyPath, + "-MoveLogPath", $moveLogPath, + "-ObservedProcessPath", $observedProcessPath, + "-FinallyPath", $childFinallyPath + ) + $helperOutput = & $ctrlHelperPath $ctrlArguments 2>&1 | Out-String + $helperExitCode = $LASTEXITCODE + Assert-Fixture ($helperExitCode -eq 0) "$caseName console Ctrl+C helper failed: $helperOutput" + Assert-Fixture ((Get-Content -LiteralPath $readyPath -Raw) -eq ("READY:" + $case.Move)) "$caseName did not pause after the requested move" + Assert-Fixture ((Get-Content -LiteralPath $moveLogPath -Raw) -match ([regex]::Escape("MOVED:" + $case.Move))) "$caseName did not record the state-changing move" + Assert-Fixture ((Get-Content -LiteralPath $moveLogPath -Raw) -match ([regex]::Escape("ROLLBACK_FAILURE:" + $case.RollbackFailure))) "$caseName did not deliver the one-shot rollback failure" + Assert-Fixture ((Get-Content -LiteralPath $childFinallyPath -Raw) -eq "CHILD_FINALLY") "$caseName did not execute the child finally block" + Assert-Fixture ((Get-Content -LiteralPath $observedProcessPath -Raw -Encoding Unicode) -ceq $expectedProcessPath) "$caseName did not restore the child process PATH" + Assert-Fixture ([Environment]::GetEnvironmentVariable("Path", "User") -ceq $expectedUserPath) "$caseName changed the User PATH" + Assert-NoTransactionResidue $installRoot $binDir + Assert-Fixture ((Get-Content -LiteralPath $parentMarker -Raw) -eq ("keep-" + $caseName)) "$caseName removed the pre-existing parent marker" + Assert-Fixture (@(Get-ChildItem -LiteralPath $caseTemp -Filter "atomic-install-*" -Force).Count -eq 0) "$caseName left a temporary download directory" + + if ($case.State -eq "fresh") { + Assert-Fixture (-not (Test-Path -LiteralPath $installRoot)) "$caseName left a fresh install root" + Assert-Fixture (-not (Test-Path -LiteralPath $binDir)) "$caseName left a fresh bin directory" + Assert-Fixture (-not (Test-Path -LiteralPath $installContainer)) "$caseName left a transaction-created install parent" + Assert-Fixture (-not (Test-Path -LiteralPath $binContainer)) "$caseName left a transaction-created bin parent" + } + else { + $versionPath = Join-Path $installRoot "versions\2.0.0" + $currentPath = Join-Path $installRoot "current" + $atomicCurrentPath = Join-Path $binDir "atomic-current" + $shimPath = Join-Path $binDir "atomic.cmd" + Assert-Fixture ([Convert]::ToBase64String([IO.File]::ReadAllBytes((Join-Path $versionPath "preserve.bin"))) -ceq $oldPayloadBytes) "$caseName changed old version bytes" + Assert-Fixture ([Convert]::ToBase64String([IO.File]::ReadAllBytes((Join-Path $currentPath "preserve.bin"))) -ceq $oldPayloadBytes) "$caseName changed old current bytes" + Assert-Fixture ([Convert]::ToBase64String([IO.File]::ReadAllBytes((Join-Path $atomicCurrentPath "preserve.bin"))) -ceq $oldPayloadBytes) "$caseName changed old atomic-current bytes" + Assert-Fixture ([Convert]::ToBase64String([IO.File]::ReadAllBytes((Join-Path $versionPath "atomic.exe"))) -ceq $oldAtomicBytes) "$caseName changed old executable bytes" + Assert-Fixture ([Convert]::ToBase64String([IO.File]::ReadAllBytes($shimPath)) -ceq $oldShimBytes) "$caseName changed old shim bytes" + Assert-Fixture (((Get-Item -LiteralPath $currentPath -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) "$caseName did not restore the current junction" + Assert-Fixture (((Get-Item -LiteralPath $atomicCurrentPath -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) "$caseName did not restore the atomic-current junction" + $rollbackProbe = Invoke-FixtureShim $shimPath "--version" + Assert-Fixture ($rollbackProbe.ExitCode -eq 0 -and $rollbackProbe.Output -eq "2.0.0") "$caseName did not leave the old shim pair executable" + } + + [Environment]::SetEnvironmentVariable("Path", $beforeCaseUserPath, "User") + $env:Path = $beforeCaseProcessPath + Remove-Item -LiteralPath $caseRoot -Recurse -Force + } + } + elseif ($Scenario -eq "transaction-failures") { + $env:PROCESSOR_ARCHITEW6432 = "AMD64" + $env:PROCESSOR_ARCHITECTURE = "AMD64" + $failurePoints = @("payload-copy", "current-create", "current-move", "atomic-current-create", "atomic-current-move", "shim-stage") + foreach ($state in @("fresh", "existing")) { + $topologies = if ($state -eq "fresh") { @("separate", "bin-parent", "install-parent") } else { @("separate") } + foreach ($topology in $topologies) { + foreach ($failurePoint in $failurePoints) { + $caseRoot = Join-Path $workspace ("transaction-" + $state + "-" + $topology + "-" + $failurePoint) + New-Item -ItemType Directory -Path $caseRoot -Force | Out-Null + $parentMarker = Join-Path $caseRoot "pre-existing-parent.txt" + Set-Content -LiteralPath $parentMarker -Value "keep-parent" -Encoding ASCII -NoNewline + $preExistingJunctionTarget = Join-Path $caseRoot "pre-existing-junction-target" + $preExistingJunction = Join-Path $caseRoot "pre-existing-dangling-junction" + New-Item -ItemType Directory -Path $preExistingJunctionTarget -Force | Out-Null + New-Item -ItemType Junction -Path $preExistingJunction -Target $preExistingJunctionTarget | Out-Null + Remove-Item -LiteralPath $preExistingJunctionTarget -Recurse -Force + if ($topology -eq "bin-parent") { + $binDir = Join-Path $caseRoot "bin-parent" + $installRoot = Join-Path $binDir "install-root" + } + elseif ($topology -eq "install-parent") { + $installRoot = Join-Path $caseRoot "install-parent" + $binDir = Join-Path $installRoot "bin-root" + } + else { + $installRoot = Join-Path $caseRoot "install-root" + $binDir = Join-Path $caseRoot "bin-root" + } + $env:ATOMIC_INSTALL_DIR = $installRoot + $env:ATOMIC_BIN_DIR = $binDir + $global:AtomicFixturePayloadCopyCount = 0 + $global:AtomicFixtureFailurePoint = $null + $env:ATOMIC_FIXTURE_FAIL_INSTALLED_VERSION = $null + + $oldShimBytes = $null + if ($state -eq "existing") { + & $InstallerPath -Ref "1.0.0" | Out-Null + Set-Content -LiteralPath (Join-Path $installRoot "preserve-root.txt") -Value "old-root" -Encoding ASCII -NoNewline + Set-Content -LiteralPath (Join-Path $binDir "preserve-bin.txt") -Value "old-bin" -Encoding ASCII -NoNewline + Set-Content -LiteralPath (Join-Path $installRoot "versions\1.0.0\preserve-version.txt") -Value "old-version" -Encoding ASCII -NoNewline + $oldShim = Join-Path $binDir "atomic.cmd" + $oldShimContent = '@rem old-pair-' + $failurePoint + [Environment]::NewLine + '@echo off' + [Environment]::NewLine + '"%~dp0atomic-current\atomic.exe" %*' + [Environment]::NewLine + 'exit /b %ERRORLEVEL%' + [Environment]::NewLine + Set-Content -LiteralPath $oldShim -Value $oldShimContent -Encoding ASCII -NoNewline + $oldShimBytes = [IO.File]::ReadAllBytes($oldShim) + $oldProbe = Invoke-FixtureShim $oldShim "--version" + Assert-Fixture ($oldProbe.ExitCode -eq 0 -and $oldProbe.Output -eq "1.0.0") "old pair was not executable before $failurePoint failure" + } + + $global:AtomicFixtureFailurePoint = $failurePoint + $failure = $null + try { & $InstallerPath -Ref "2.0.0" | Out-Null } + catch { $failure = $_ } + $global:AtomicFixtureFailurePoint = $null + Assert-Fixture ($null -ne $failure -and $failure.Exception.Message -match ("Injected transaction failure: " + $failurePoint)) "$state $topology $failurePoint failure was not injected" + + Assert-Fixture ((Get-Content -LiteralPath $parentMarker -Raw) -eq "keep-parent") "$state $topology $failurePoint removed a pre-existing parent marker" + $preservedJunction = @(Get-ChildItem -LiteralPath $caseRoot -Force | Where-Object { $_.Name -eq "pre-existing-dangling-junction" }) + Assert-Fixture ($preservedJunction.Count -eq 1 -and ($preservedJunction[0].Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) "$state $topology $failurePoint removed a pre-existing dangling junction" + if ($state -eq "fresh") { + Assert-Fixture (-not (Test-Path -LiteralPath $installRoot)) "fresh $topology $failurePoint failure left the install root" + Assert-Fixture (-not (Test-Path -LiteralPath $binDir)) "fresh $topology $failurePoint failure left the bin directory" + Assert-Fixture (@(Get-ChildItem -LiteralPath $caseRoot -Force).Count -eq 2) "fresh $topology $failurePoint left transaction-created parent entries" + } + else { + Assert-Fixture ((Get-Content -LiteralPath (Join-Path $installRoot "preserve-root.txt") -Raw) -eq "old-root") "existing $failurePoint failure recursed into the install root" + Assert-Fixture ((Get-Content -LiteralPath (Join-Path $binDir "preserve-bin.txt") -Raw) -eq "old-bin") "existing $failurePoint failure recursed into the bin directory" + Assert-Fixture ((Get-Content -LiteralPath (Join-Path $installRoot "versions\1.0.0\preserve-version.txt") -Raw) -eq "old-version") "existing $failurePoint failure did not preserve the old version" + Assert-Fixture ((Get-Content -LiteralPath (Join-Path $installRoot "current\version.txt") -Raw) -eq "1.0.0") "existing $failurePoint failure did not preserve current" + Assert-Fixture ((Get-Content -LiteralPath (Join-Path $binDir "atomic-current\version.txt") -Raw) -eq "1.0.0") "existing $failurePoint failure did not preserve atomic-current" + Assert-Fixture ([Convert]::ToBase64String([IO.File]::ReadAllBytes((Join-Path $binDir "atomic.cmd"))) -ceq [Convert]::ToBase64String($oldShimBytes)) "existing $failurePoint failure did not preserve the old shim" + Assert-Fixture (-not (Test-Path -LiteralPath (Join-Path $installRoot "versions\2.0.0"))) "existing $failurePoint failure retained the failed version" + $rollbackProbe = Invoke-FixtureShim (Join-Path $binDir "atomic.cmd") "--version" + Assert-Fixture ($rollbackProbe.ExitCode -eq 0 -and $rollbackProbe.Output -eq "1.0.0") "existing $failurePoint failure did not leave the old pair executable" + } + + Assert-NoTransactionResidue $installRoot $binDir + Assert-Fixture (@(Get-ChildItem -LiteralPath $fixtureTemp -Filter "atomic-install-*" -Force).Count -eq 0) "$state $topology $failurePoint failure left a temporary download directory" + [IO.Directory]::Delete($preExistingJunction) + Remove-Item -LiteralPath $caseRoot -Recurse -Force -ErrorAction SilentlyContinue + } + } + } + } + elseif ($Scenario -eq "hash-fallback") { + $env:PROCESSOR_ARCHITEW6432 = "AMD64" + $env:PROCESSOR_ARCHITECTURE = "AMD64" + & $InstallerPath -Ref "1.0.0" | Out-Null + Assert-Fixture (@($global:AtomicFixtureRequests | Where-Object { $_.Uri -match '/releases/download/' }).Count -eq 2) "the .NET checksum fallback did not complete both release downloads" + $versionProbe = Invoke-FixtureShim (Join-Path $binDir "atomic.cmd") "--version" + Assert-Fixture ($versionProbe.ExitCode -eq 0 -and $versionProbe.Output -eq "1.0.0") ".NET checksum fallback did not leave a runnable install" + Assert-NoTransactionResidue $installRoot $binDir + } + elseif ($Scenario -eq "ref-identity") { + $env:PROCESSOR_ARCHITEW6432 = "AMD64" + $env:PROCESSOR_ARCHITECTURE = "AMD64" + $mismatch = $null + try { & $InstallerPath -Ref "1.0.1" | Out-Null } + catch { $mismatch = $_ } + Assert-Fixture ($null -ne $mismatch) "a mismatched exact-tag response was accepted" + Assert-Fixture ($mismatch.Exception.Message -match 'GitHub returned release 1\.0\.0 for requested tag 1\.0\.1') "mismatch failure did not name both release identities" + Assert-Fixture (@($global:AtomicFixtureRequests | Where-Object { $_.Uri -match '/releases/download/' }).Count -eq 0) "a mismatched exact-tag response still downloaded a release" + Assert-Fixture (-not (Test-Path -LiteralPath $installRoot)) "a mismatched exact-tag response created an installation" + Assert-Fixture (-not (Test-Path -LiteralPath $binDir)) "a mismatched exact-tag response created a bin directory" + + & $InstallerPath -Ref "1.0.0" | Out-Null + Assert-Fixture (Test-Path -LiteralPath (Join-Path $installRoot "versions\1.0.0\atomic.exe")) "a matching exact ref did not install" + Assert-NoTransactionResidue $installRoot $binDir + } + elseif ($Scenario -eq "semicolon-bin") { + $semicolonBinDir = Join-Path $workspace "semi;bin" + $env:ATOMIC_BIN_DIR = $semicolonBinDir + $env:PROCESSOR_ARCHITEW6432 = "AMD64" + $env:PROCESSOR_ARCHITECTURE = "AMD64" + $beforeUserPath = [Environment]::GetEnvironmentVariable("Path", "User") + $beforeProcessPath = $env:Path + + $installOutput = & $InstallerPath -Ref "1.0.0" | Out-String + $shim = Join-Path $semicolonBinDir "atomic.cmd" + Assert-Fixture (Test-Path -LiteralPath $shim) "semicolon bin directory did not receive the shim" + Assert-Fixture ([Environment]::GetEnvironmentVariable("Path", "User") -eq $beforeUserPath) "a semicolon bin directory was appended to the User PATH" + Assert-Fixture ($env:Path -eq $beforeProcessPath) "a semicolon bin directory was appended to the current PATH" + Assert-Fixture ($installOutput -match "cannot be represented as one Windows PATH entry") "semicolon bin directory did not report the PATH limitation" + Assert-Fixture ($installOutput -match 'Run Atomic directly') "semicolon bin directory did not print direct-run guidance" + Assert-Fixture ($installOutput -notmatch 'Restart your terminal') "semicolon bin directory claimed a PATH update" + $versionProbe = Invoke-FixtureShim $shim "--version" + Assert-Fixture ($versionProbe.ExitCode -eq 0 -and $versionProbe.Output -eq "1.0.0") "semicolon bin directory shim is not runnable" + + & $InstallerPath -Ref "1.0.0" | Out-Null + Assert-Fixture ([Environment]::GetEnvironmentVariable("Path", "User") -eq $beforeUserPath) "a semicolon bin directory rerun appended a duplicate PATH entry" + Assert-NoTransactionResidue $installRoot $semicolonBinDir + } + elseif ($Scenario -eq "shadowed-shim") { + $env:PROCESSOR_ARCHITEW6432 = "AMD64" + $env:PROCESSOR_ARCHITECTURE = "AMD64" + $env:PATHEXT = $null + New-Item -ItemType Directory -Path $binDir -Force | Out-Null + $stalePath = Join-Path $binDir "atomic.exe" + Copy-Item -LiteralPath $fixtureExecutable -Destination $stalePath + $staleBytes = [IO.File]::ReadAllBytes($stalePath) + + $shadowed = $null + try { & $InstallerPath -Ref "1.0.0" | Out-Null } + catch { $shadowed = $_ } + Assert-Fixture ($null -ne $shadowed) "a stale same-stem atomic.exe was accepted" + Assert-Fixture ($shadowed.Exception.Message -match 'atomic\.exe, which PATHEXT resolves before atomic\.cmd') "the stale launcher rejection did not name the shadowing entry" + Assert-Fixture (@($global:AtomicFixtureRequests).Count -eq 0) "the stale launcher rejection performed a network request" + Assert-Fixture (-not (Test-Path -LiteralPath $installRoot)) "the stale launcher rejection created an install root" + Assert-Fixture (-not (Test-Path -LiteralPath (Join-Path $binDir "atomic.cmd"))) "the stale launcher rejection created a shim" + Assert-Fixture ([Convert]::ToBase64String([IO.File]::ReadAllBytes($stalePath)) -ceq [Convert]::ToBase64String($staleBytes)) "the stale launcher was replaced instead of reported" + Remove-Item -LiteralPath $stalePath -Force + + $env:PATHEXT = ".WSF;.CMD;.EXE" + $wsfPath = Join-Path $binDir "atomic.wsf" + Set-Content -LiteralPath $wsfPath -Value "stale script" -Encoding ASCII -NoNewline + $customShadowed = $null + try { & $InstallerPath -Ref "1.0.0" | Out-Null } + catch { $customShadowed = $_ } + Assert-Fixture ($null -ne $customShadowed) "a custom PATHEXT entry ahead of .CMD was accepted" + Assert-Fixture ($customShadowed.Exception.Message -match 'atomic\.wsf, which PATHEXT resolves before atomic\.cmd') "the custom PATHEXT rejection did not name the shadowing entry" + Assert-Fixture (@($global:AtomicFixtureRequests).Count -eq 0) "the custom PATHEXT rejection performed a network request" + Remove-Item -LiteralPath $wsfPath -Force + $env:PATHEXT = $null + + $staleDirectory = Join-Path $binDir "atomic.bat" + New-Item -ItemType Directory -Path $staleDirectory -Force | Out-Null + Set-Content -LiteralPath (Join-Path $staleDirectory "keep.txt") -Value "caller-data" -Encoding ASCII -NoNewline + $shadowDirectory = $null + try { & $InstallerPath -Ref "1.0.0" | Out-Null } + catch { $shadowDirectory = $_ } + Assert-Fixture ($null -ne $shadowDirectory) "a stale same-stem atomic.bat directory was accepted" + Assert-Fixture ($shadowDirectory.Exception.Message -match 'atomic\.bat, which PATHEXT resolves before atomic\.cmd') "the stale directory rejection did not name the shadowing entry" + Assert-Fixture ((Get-Content -LiteralPath (Join-Path $staleDirectory "keep.txt") -Raw) -eq "caller-data") "the stale same-stem directory was deleted instead of reported" + Remove-Item -LiteralPath $staleDirectory -Recurse -Force + + Set-Content -LiteralPath (Join-Path $binDir "atomic.vbs") -Value "harmless" -Encoding ASCII -NoNewline + & $InstallerPath -Ref "1.0.0" | Out-Null + Assert-Fixture (Test-Path -LiteralPath (Join-Path $binDir "atomic.cmd")) "an extension after .CMD blocked a valid install" + Assert-Fixture (Test-Path -LiteralPath (Join-Path $binDir "atomic.vbs")) "a harmless same-stem entry was removed" + + $previousProcessPath = $env:Path + try { + $env:Path = $binDir + $cmdExe = Join-Path $env:SystemRoot "System32\cmd.exe" + $resolvedOutput = (& $cmdExe /d /c "atomic --version" | Out-String).Trim() + $resolvedExit = $LASTEXITCODE + } + finally { + $env:Path = $previousProcessPath + } + Assert-Fixture ($resolvedExit -eq 0 -and $resolvedOutput -eq "1.0.0") "PATHEXT resolution of atomic did not run the installed shim: $resolvedOutput" + Assert-NoTransactionResidue $installRoot $binDir + } + elseif ($Scenario -eq "custom-pathext") { + $env:PROCESSOR_ARCHITEW6432 = "AMD64" + $env:PROCESSOR_ARCHITECTURE = "AMD64" + $env:PATHEXT = ".CMD;.EXE" + New-Item -ItemType Directory -Path $binDir -Force | Out-Null + $allowedStalePath = Join-Path $binDir "atomic.exe" + Copy-Item -LiteralPath $fixtureExecutable -Destination $allowedStalePath + $requestsBeforeInstall = @($global:AtomicFixtureRequests).Count + + & $InstallerPath -Ref "1.0.0" | Out-Null + $shim = Join-Path $binDir "atomic.cmd" + Assert-Fixture (Test-Path -LiteralPath $shim) "custom PATHEXT prevented the shim from being installed" + Assert-Fixture (@($global:AtomicFixtureRequests).Count -gt $requestsBeforeInstall) "custom PATHEXT install did not reach the release request" + + $previousProcessPath = $env:Path + try { + $env:Path = $binDir + $cmdExe = Join-Path $env:SystemRoot "System32\cmd.exe" + $resolvedOutput = (& $cmdExe /d /c "atomic --version" | Out-String).Trim() + $resolvedExit = $LASTEXITCODE + } + finally { + $env:Path = $previousProcessPath + } + Assert-Fixture ($resolvedExit -eq 0 -and $resolvedOutput -eq "1.0.0") "custom PATHEXT did not resolve atomic.cmd ahead of atomic.exe: $resolvedOutput" + Assert-NoTransactionResidue $installRoot $binDir + } + elseif ($Scenario -eq "temp-cleanup") { + $env:PROCESSOR_ARCHITEW6432 = "AMD64" + $env:PROCESSOR_ARCHITECTURE = "AMD64" + + $caseRoot = Join-Path $workspace "temp-cleanup" + $installRoot = Join-Path $caseRoot "install-root" + $binDir = Join-Path $caseRoot "bin-root" + $caseTemp = Join-Path $caseRoot "temp" + New-Item -ItemType Directory -Path $caseTemp -Force | Out-Null + $env:ATOMIC_INSTALL_DIR = $installRoot + $env:ATOMIC_BIN_DIR = $binDir + $env:TEMP = $caseTemp + $env:TMP = $caseTemp + $resolvedTempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') + Assert-Fixture ($resolvedTempRoot -ieq [IO.Path]::GetFullPath($caseTemp).TrimEnd('\')) "GetTempPath() resolved to $resolvedTempRoot instead of the isolated case temp root" + + $global:AtomicFixtureTempLockPath = $null + $global:AtomicFixtureTempLockStream = $null + $global:AtomicFixtureTempRemovalAttempts = 0 + $global:AtomicFixtureTempLockMode = "one-shot" + & $InstallerPath -Ref "1.0.0" | Out-Null + $global:AtomicFixtureTempLockMode = $null + Assert-Fixture ($global:AtomicFixtureTempRemovalAttempts -ge 2) "a real one-shot open handle did not force a verified cleanup retry ($($global:AtomicFixtureTempRemovalAttempts) removal calls)" + Assert-Fixture ($null -eq $global:AtomicFixtureTempLockStream) "the one-shot fixture handle was never released" + Assert-Fixture (Test-Path -LiteralPath (Join-Path $binDir "atomic.cmd")) "the one-shot locked-handle install did not complete" + $oneShotResidue = Get-TempResidueReport $caseTemp + Assert-Fixture ($oneShotResidue.Count -eq 0) "a released one-shot handle still left temp residue: $($oneShotResidue.Paths)" + Assert-NoTransactionResidue $installRoot $binDir + + Remove-Item -LiteralPath $installRoot -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $binDir -Recurse -Force -ErrorAction SilentlyContinue + $global:AtomicFixtureTempRemovalAttempts = 0 + $global:AtomicFixtureTempLockMode = "sticky" + $stickyFailure = $null + try { & $InstallerPath -Ref "1.0.0" | Out-Null } + catch { $stickyFailure = $_ } + $global:AtomicFixtureTempLockMode = $null + Assert-Fixture ($null -ne $stickyFailure) "an exhausted temp cleanup reported success" + $stickyMessage = [string]$stickyFailure.Exception.Message + $stickyTempDir = [IO.Path]::GetDirectoryName($global:AtomicFixtureTempLockPath) + Assert-Fixture ($stickyMessage -match 'Failed to remove the temporary download directory') "the exhausted cleanup used the wrong message: $stickyMessage" + Assert-Fixture ($stickyMessage -match [regex]::Escape($stickyTempDir)) "the exhausted cleanup did not name the exact temporary path: $stickyMessage" + Assert-Fixture ($stickyMessage -match 'after (\d+) attempts') "the exhausted cleanup did not report its attempt count: $stickyMessage" + $reportedAttempts = [int]$Matches[1] + Assert-Fixture ($reportedAttempts -ge 2) "the exhausted cleanup did not retry before giving up: $stickyMessage" + Assert-Fixture ($global:AtomicFixtureTempRemovalAttempts -eq $reportedAttempts) "the exhausted cleanup reported $reportedAttempts attempts but performed $($global:AtomicFixtureTempRemovalAttempts)" + Assert-Fixture ($stickyMessage -match 'last error: \S') "the exhausted cleanup did not report the last error: $stickyMessage" + Assert-Fixture (Test-Path -LiteralPath (Join-Path $binDir "atomic.cmd")) "the exhausted cleanup discarded a completed install" + $global:AtomicFixtureTempLockStream.Dispose() + $global:AtomicFixtureTempLockStream = $null + Remove-Item -LiteralPath $stickyTempDir -Recurse -Force + Assert-NoTransactionResidue $installRoot $binDir + + Remove-Item -LiteralPath $installRoot -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $binDir -Recurse -Force -ErrorAction SilentlyContinue + $global:AtomicFixtureTempRemovalAttempts = 0 + $global:AtomicFixtureWarnings.Clear() + $global:AtomicFixtureTempLockMode = "sticky" + $env:ATOMIC_FIXTURE_FAIL_INSTALLED_VERSION = "1.0.0" + $previousWarningPreference = $WarningPreference + $WarningPreference = "Stop" + $primaryFailure = $null + try { & $InstallerPath -Ref "1.0.0" | Out-Null } + catch { $primaryFailure = $_ } + finally { + $WarningPreference = $previousWarningPreference + $env:ATOMIC_FIXTURE_FAIL_INSTALLED_VERSION = $null + } + $global:AtomicFixtureTempLockMode = $null + Assert-Fixture ($null -ne $primaryFailure) "a failing installed smoke reported success" + $primaryMessage = [string]$primaryFailure.Exception.Message + Assert-Fixture ($primaryMessage -match 'Installed atomic\.cmd --version failed') "a failing temp cleanup replaced the primary installer error: $primaryMessage" + Assert-Fixture ($primaryMessage -notmatch 'Failed to remove the temporary download directory') "the cleanup error was surfaced instead of the primary error: $primaryMessage" + $lockedTempDir = [IO.Path]::GetDirectoryName($global:AtomicFixtureTempLockPath) + $cleanupWarnings = @($global:AtomicFixtureWarnings | Where-Object { $_ -match 'Temporary download directory cleanup remains incomplete' }) + Assert-Fixture ($cleanupWarnings.Count -eq 1) "the deferred cleanup failure was not warned exactly once ($($cleanupWarnings.Count))" + Assert-Fixture ($cleanupWarnings[0] -match [regex]::Escape($lockedTempDir)) "the cleanup warning did not name the exact temporary path: $($cleanupWarnings[0])" + Assert-Fixture (-not (Test-Path -LiteralPath $installRoot)) "a failing temp cleanup blocked rollback of the fresh install root" + Assert-Fixture (-not (Test-Path -LiteralPath $binDir)) "a failing temp cleanup blocked removal of the transaction-created bin directory" + $global:AtomicFixtureTempLockStream.Dispose() + $global:AtomicFixtureTempLockStream = $null + Remove-Item -LiteralPath $lockedTempDir -Recurse -Force + + foreach ($stressTag in @("1.0.0", "2.0.0")) { + & $InstallerPath -Ref $stressTag | Out-Null + $stressResidue = Get-TempResidueReport $caseTemp + Assert-Fixture ($stressResidue.Count -eq 0) "installing $stressTag left temp residue: $($stressResidue.Paths)" + Assert-Fixture ((Get-Content -LiteralPath (Join-Path $binDir "atomic-current\version.txt") -Raw) -eq $stressTag) "installing $stressTag did not update the installed pointer" + Assert-NoTransactionResidue $installRoot $binDir + } + + $env:ATOMIC_FIXTURE_FAIL_INSTALLED_VERSION = "1.0.0" + $stressFailure = $null + try { & $InstallerPath -Ref "1.0.0" | Out-Null } + catch { $stressFailure = $_ } + $env:ATOMIC_FIXTURE_FAIL_INSTALLED_VERSION = $null + Assert-Fixture ($null -ne $stressFailure) "the rolled-back stress install reported success" + $rolledBackResidue = Get-TempResidueReport $caseTemp + Assert-Fixture ($rolledBackResidue.Count -eq 0) "a rolled-back install left temp residue: $($rolledBackResidue.Paths)" + Assert-NoTransactionResidue $installRoot $binDir + + $env:TEMP = $fixtureTemp + $env:TMP = $fixtureTemp + } + else { + throw "Unknown fixture scenario: $Scenario" + } + + $finalResidue = Get-TempResidueReport $fixtureTemp + Assert-Fixture ($finalResidue.Count -eq 0) "temporary installer directory was not cleaned: $($finalResidue.Paths)" + Write-Output "SCENARIO_OK:$Scenario" +} +finally { + [Environment]::SetEnvironmentVariable("Path", $originalUserPath, "User") + foreach ($name in $environmentNames) { + [Environment]::SetEnvironmentVariable($name, $originalEnvironment[$name], "Process") + } + Remove-Item -LiteralPath $installRoot -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $binDir -Recurse -Force -ErrorAction SilentlyContinue +} +`; + +const ctrlCChildHarness = String.raw` +param( + [Parameter(Mandatory=$true)][string]$InstallerPath, + [Parameter(Mandatory=$true)][string]$AssetRoot, + [Parameter(Mandatory=$true)][string]$InstallRoot, + [Parameter(Mandatory=$true)][string]$BinDir, + [Parameter(Mandatory=$true)][string]$TempRoot, + [Parameter(Mandatory=$true)][string]$PauseMove, + [Parameter(Mandatory=$true)][string]$RollbackFailure, + [Parameter(Mandatory=$true)][string]$ReadyPath, + [Parameter(Mandatory=$true)][string]$MoveLogPath, + [Parameter(Mandatory=$true)][string]$ObservedProcessPath, + [Parameter(Mandatory=$true)][string]$FinallyPath +) + +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" +Set-StrictMode -Version Latest +$env:ATOMIC_INSTALL_DIR = $InstallRoot +$env:ATOMIC_BIN_DIR = $BinDir +$env:TEMP = $TempRoot +$env:TMP = $TempRoot +$env:PROCESSOR_ARCHITEW6432 = "AMD64" +$env:PROCESSOR_ARCHITECTURE = "AMD64" + +function global:Invoke-WebRequest { + [CmdletBinding()] + param( + [Parameter(Mandatory=$true)][string]$Uri, + [string]$OutFile, + [hashtable]$Headers, + [switch]$UseBasicParsing, + [int]$MaximumRedirection + ) + + if ($Uri -match '/repos/bastani-inc/atomic/releases/tags/([^/]+)$') { + return [pscustomobject]@{ Content = '{"tag_name":"2.0.0"}'; Headers = @{} } + } + if (-not [string]::IsNullOrWhiteSpace($OutFile) -and $Uri -match '/releases/download/([^/]+)/([^/]+)$') { + $tag = [Uri]::UnescapeDataString($Matches[1]) + Copy-Item -LiteralPath (Join-Path (Join-Path $AssetRoot $tag) $Matches[2]) -Destination $OutFile + return [pscustomobject]@{ StatusCode = 200; Headers = @{} } + } + throw "Unexpected Ctrl+C fixture request: $Uri" +} + +$global:AtomicCtrlCRollbackArmed = $false +$global:AtomicCtrlCRollbackFailureDelivered = $false +function global:Get-ChildItem { + [CmdletBinding()] + param( + [Parameter(Mandatory=$true)][string]$LiteralPath, + [string]$Filter, + [switch]$Force + ) + + if ($global:AtomicCtrlCRollbackArmed -and + -not $global:AtomicCtrlCRollbackFailureDelivered -and + $RollbackFailure -like "*-remove") { + $requestedLeaf = $null + try { $requestedLeaf = [string](Get-Variable -Name leafName -Scope 1 -ValueOnly -ErrorAction Stop) } + catch { $requestedLeaf = $null } + $failureLeaf = switch ($RollbackFailure) { + "shim-remove" { "atomic.cmd" } + "atomic-current-remove" { "atomic-current" } + "current-remove" { "current" } + "version-remove" { "2.0.0" } + default { $null } + } + if (-not [string]::IsNullOrWhiteSpace($failureLeaf) -and $requestedLeaf -ieq $failureLeaf) { + $global:AtomicCtrlCRollbackFailureDelivered = $true + [IO.File]::AppendAllText($MoveLogPath, "ROLLBACK_FAILURE:$RollbackFailure" + [Environment]::NewLine) + throw "Injected one-shot rollback removal failure: $RollbackFailure" + } + } + + if ([string]::IsNullOrWhiteSpace($Filter)) { + return Microsoft.PowerShell.Management\Get-ChildItem -LiteralPath $LiteralPath -Force:$Force + } + return Microsoft.PowerShell.Management\Get-ChildItem -LiteralPath $LiteralPath -Filter $Filter -Force:$Force +} + +$global:AtomicCtrlCPauseDelivered = $false +function global:Move-Item { + [CmdletBinding()] + param( + [Parameter(Mandatory=$true)][string]$LiteralPath, + [Parameter(Mandatory=$true)][string]$Destination + ) + + $sourceLeaf = [IO.Path]::GetFileName($LiteralPath) + $destinationLeaf = [IO.Path]::GetFileName($Destination) + $moveName = $null + if ($sourceLeaf -eq "2.0.0" -and $destinationLeaf -match '^\.backup-[0-9a-f]{32}$') { $moveName = "version-backup" } + elseif ($sourceLeaf -match '^\.stage-[0-9a-f]{32}$' -and $destinationLeaf -eq "2.0.0") { $moveName = "version-install" } + elseif ($sourceLeaf -eq "current" -and $destinationLeaf -match '^\.current-backup-[0-9a-f]{32}$') { $moveName = "current-backup" } + elseif ($sourceLeaf -match '^\.current-[0-9a-f]{32}$' -and $destinationLeaf -eq "current") { $moveName = "current-install" } + elseif ($sourceLeaf -eq "atomic-current" -and $destinationLeaf -match '^\.atomic-current-backup-[0-9a-f]{32}$') { $moveName = "atomic-current-backup" } + elseif ($sourceLeaf -match '^\.atomic-current-[0-9a-f]{32}$' -and $destinationLeaf -eq "atomic-current") { $moveName = "atomic-current-install" } + elseif ($sourceLeaf -eq "atomic.cmd" -and $destinationLeaf -match '^\.atomic-backup-[0-9a-f]{32}\.cmd$') { $moveName = "shim-backup" } + elseif ($sourceLeaf -match '^\.atomic-[0-9a-f]{32}\.cmd$' -and $destinationLeaf -eq "atomic.cmd") { $moveName = "shim-install" } + + $restoreName = $null + if ($sourceLeaf -match '^\.atomic-backup-[0-9a-f]{32}\.cmd$' -and $destinationLeaf -eq "atomic.cmd") { $restoreName = "shim-restore" } + elseif ($sourceLeaf -match '^\.atomic-current-backup-[0-9a-f]{32}$' -and $destinationLeaf -eq "atomic-current") { $restoreName = "atomic-current-restore" } + elseif ($sourceLeaf -match '^\.current-backup-[0-9a-f]{32}$' -and $destinationLeaf -eq "current") { $restoreName = "current-restore" } + elseif ($sourceLeaf -match '^\.backup-[0-9a-f]{32}$' -and $destinationLeaf -eq "2.0.0") { $restoreName = "version-restore" } + if ($global:AtomicCtrlCRollbackArmed -and + -not $global:AtomicCtrlCRollbackFailureDelivered -and + $RollbackFailure -eq $restoreName) { + $global:AtomicCtrlCRollbackFailureDelivered = $true + [IO.File]::AppendAllText($MoveLogPath, "ROLLBACK_FAILURE:$RollbackFailure" + [Environment]::NewLine) + throw "Injected one-shot rollback restore failure: $RollbackFailure" + } + + Microsoft.PowerShell.Management\Move-Item -LiteralPath $LiteralPath -Destination $Destination + if ($null -ne $moveName) { + [IO.File]::AppendAllText($MoveLogPath, "MOVED:$moveName" + [Environment]::NewLine) + if (-not $global:AtomicCtrlCPauseDelivered -and $moveName -eq $PauseMove) { + $global:AtomicCtrlCPauseDelivered = $true + $global:AtomicCtrlCRollbackArmed = $true + [IO.File]::WriteAllText($ReadyPath, "READY:$moveName") + while ($true) { Start-Sleep -Milliseconds 200 } + } + } +} + +try { + & $InstallerPath -Ref "2.0.0" | Out-Null + throw "Installer unexpectedly completed before Ctrl+C" +} +finally { + [IO.File]::WriteAllText($ObservedProcessPath, [string]$env:Path, [Text.Encoding]::Unicode) + [IO.File]::WriteAllText($FinallyPath, "CHILD_FINALLY", [Text.Encoding]::ASCII) +} +`; + +// CREATE_NEW_CONSOLE isolates the child. The helper then attaches to that console, ignores +// Ctrl+C in only its own process, and broadcasts a kernel CTRL_C_EVENT to the attached +// console with GenerateConsoleCtrlEvent(..., 0). This is a real console event, not a +// PowerShell exception or a test-only production hook. +const ctrlCHelperSource = String.raw` +using System; +using System.ComponentModel; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; + +public static class CtrlCConsoleDriver +{ + private const uint CREATE_NEW_CONSOLE = 0x00000010; + private const uint CTRL_C_EVENT = 0; + private const uint WAIT_OBJECT_0 = 0; + private const uint WAIT_TIMEOUT = 258; + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct STARTUPINFO + { + public int cb; + public string lpReserved; + public string lpDesktop; + public string lpTitle; + public uint dwX; + public uint dwY; + public uint dwXSize; + public uint dwYSize; + public uint dwXCountChars; + public uint dwYCountChars; + public uint dwFillAttribute; + public uint dwFlags; + public short wShowWindow; + public short cbReserved2; + public IntPtr lpReserved2; + public IntPtr hStdInput; + public IntPtr hStdOutput; + public IntPtr hStdError; + } + + [StructLayout(LayoutKind.Sequential)] + private struct PROCESS_INFORMATION + { + public IntPtr hProcess; + public IntPtr hThread; + public uint dwProcessId; + public uint dwThreadId; + } + + private delegate bool ConsoleCtrlDelegate(uint ctrlType); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern bool CreateProcess( + string applicationName, StringBuilder commandLine, IntPtr processAttributes, + IntPtr threadAttributes, bool inheritHandles, uint creationFlags, + IntPtr environment, string currentDirectory, ref STARTUPINFO startupInfo, + out PROCESS_INFORMATION processInformation); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool FreeConsole(); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool AttachConsole(uint processId); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool SetConsoleCtrlHandler(ConsoleCtrlDelegate handler, bool add); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GenerateConsoleCtrlEvent(uint ctrlEvent, uint processGroupId); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern uint WaitForSingleObject(IntPtr handle, uint milliseconds); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool GetExitCodeProcess(IntPtr process, out uint exitCode); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool TerminateProcess(IntPtr process, uint exitCode); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool CloseHandle(IntPtr handle); + + private static string Quote(string value) + { + return "\"" + value.Replace("\"", "\\\"") + "\""; + } + + public static int Main(string[] args) + { + if (args.Length < 3) + { + Console.Error.WriteLine("usage: ctrl-helper [child args]"); + return 64; + } + + StringBuilder command = new StringBuilder(); + command.Append(Quote(args[0])); + command.Append(" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "); + command.Append(Quote(args[2])); + for (int i = 3; i < args.Length; i++) + { + command.Append(" "); + command.Append(Quote(args[i])); + } + + // Do not let an inherited ignore-CTRL_C attribute flow into the child. + SetConsoleCtrlHandler(null, false); + STARTUPINFO startup = new STARTUPINFO(); + startup.cb = Marshal.SizeOf(typeof(STARTUPINFO)); + PROCESS_INFORMATION process; + if (!CreateProcess(null, command, IntPtr.Zero, IntPtr.Zero, false, CREATE_NEW_CONSOLE, + IntPtr.Zero, null, ref startup, out process)) + { + Console.Error.WriteLine(new Win32Exception(Marshal.GetLastWin32Error()).Message); + return 65; + } + + try + { + DateTime markerDeadline = DateTime.UtcNow.AddSeconds(45); + while (!File.Exists(args[1])) + { + if (WaitForSingleObject(process.hProcess, 0) == WAIT_OBJECT_0) + { + Console.Error.WriteLine("child exited before the move marker"); + return 66; + } + if (DateTime.UtcNow >= markerDeadline) + { + TerminateProcess(process.hProcess, 67); + Console.Error.WriteLine("timed out waiting for the move marker"); + return 67; + } + Thread.Sleep(25); + } + + Thread.Sleep(100); + FreeConsole(); + if (!AttachConsole(process.dwProcessId)) + { + TerminateProcess(process.hProcess, 68); + Console.Error.WriteLine("AttachConsole failed: " + new Win32Exception(Marshal.GetLastWin32Error()).Message); + return 68; + } + if (!SetConsoleCtrlHandler(null, true) || !GenerateConsoleCtrlEvent(CTRL_C_EVENT, 0)) + { + int error = Marshal.GetLastWin32Error(); + FreeConsole(); + TerminateProcess(process.hProcess, 69); + Console.Error.WriteLine("GenerateConsoleCtrlEvent failed: " + new Win32Exception(error).Message); + return 69; + } + Thread.Sleep(100); + FreeConsole(); + + uint wait = WaitForSingleObject(process.hProcess, 45000); + if (wait == WAIT_TIMEOUT) + { + TerminateProcess(process.hProcess, 70); + Console.Error.WriteLine("child did not exit after Ctrl+C"); + return 70; + } + uint childExitCode; + GetExitCodeProcess(process.hProcess, out childExitCode); + Console.WriteLine("CTRL_C_CHILD_EXIT:" + childExitCode); + return 0; + } + finally + { + CloseHandle(process.hThread); + CloseHandle(process.hProcess); + } + } +} +`; + +test("Windows PowerShell 5.1 fixtures enforce shim bytes, cmd execution, rollback, and failure cleanup", () => { + assert.match(fixtureHarness, /安装-Δοκιμή/u); + assert.match(fixtureHarness, /自訂-bin-Δ/u); + assert.match(fixtureHarness, /\[Alias\("Type"\)\]/u); + assert.match(fixtureHarness, /SupportsShouldProcess=\$true/u); + assert.match(fixtureHarness, /\[IO\.File\]::ReadAllBytes\(\$shim\)/u); + assert.match(fixtureHarness, /0xFF,\s*0xFE/u); + assert.match(fixtureHarness, /0xEF,\s*0xBB,\s*0xBF/u); + assert.match(fixtureHarness, /Where-Object\s*\{\s*\$_\s+-eq\s+0\s*\}/u); + assert.match(fixtureHarness, /Where-Object\s*\{\s*\$_\s+-gt\s+0x7F\s*\}/u); + assert.doesNotMatch(fixtureHarness, /&\s+\$shim\b/u); + assert.match(fixtureHarness, /&\s+\$cmdExe\s+\/d\s+\/c\s+\$commandLine/u); + assert.match(fixtureHarness, /--probe "hello world"/u); + assert.match(fixtureHarness, /--exit 37/u); + assert.match(fixtureHarness, /rollback-marker[\s\S]+old atomic-current junction[\s\S]+old shim bytes/u); + assert.match( + fixtureHarness, + /@\("payload-copy", "current-create", "current-move", "atomic-current-create", "atomic-current-move", "shim-stage"\)/u, + ); + assert.match(fixtureHarness, /FailurePoint\s+-eq\s+"current-create"[\s\S]+\\\.current-/u); + assert.match(fixtureHarness, /FailurePoint\s+-eq\s+"current-move"[\s\S]+\\\.current-/u); + assert.match(fixtureHarness, /FailurePoint\s+-eq\s+"atomic-current-create"[\s\S]+\\\.atomic-current-/u); + assert.match(fixtureHarness, /FailurePoint\s+-eq\s+"atomic-current-move"[\s\S]+\\\.atomic-current-/u); + assert.doesNotMatch(fixtureHarness, /"junction-(?:create|move)"/u); + assert.match(fixtureHarness, /foreach \(\$state in @\("fresh", "existing"\)\)/u); + assert.match(fixtureHarness, /fresh \$topology \$failurePoint failure left the install root/u); + assert.match(fixtureHarness, /existing \$failurePoint failure recursed into the bin directory/u); + assert.match(fixtureHarness, /existing \$failurePoint failure did not preserve the old version/u); + assert.match(fixtureHarness, /existing \$failurePoint failure did not leave the old pair executable/u); + assert.match(fixtureHarness, /Assert-NoTransactionResidue \$installRoot \$binDir/u); + assert.match( + fixtureHarness, + /AtomicFixtureFailApi\s*=\s*\$true[\s\S]+successful stable redirect queried the GitHub API/u, + ); + assert.match(fixtureHarness, /@\("separate", "bin-parent", "install-parent"\)/u); + assert.match(fixtureHarness, /pre-existing-dangling-junction/u); + assert.match( + fixtureHarness, + /"shim-remove", "shim-restore",[\s\S]+"atomic-current-remove", "atomic-current-restore",[\s\S]+"current-remove", "current-restore",[\s\S]+"version-remove", "version-restore"/u, + ); + assert.match(fixtureHarness, /one-shot rollback failure was not delivered/u); + assert.match( + fixtureHarness, + /did not restore User PATH on a later rollback attempt[\s\S]+did not restore current PATH on a later rollback attempt/u, + ); + assert.match( + fixtureHarness, + /changed old payload bytes[\s\S]+restore current payload bytes[\s\S]+restore atomic-current payload bytes[\s\S]+restore old shim bytes/u, + ); +}); + +test("Windows PowerShell 5.1 tag-grammar fixture rejects unsupported refs before any request", () => { + for (const invalidTag of [ + "v1.0.0", + "1.0", + "1.0.0.0", + "1.0.0-alpha.0", + "1.0.0-beta.1", + "1.0.0-alpha", + "01.0.0", + "release/1.0", + "hash#tag", + "percent%tag", + ]) { + assert.ok(fixtureHarness.includes(`"${invalidTag}"`), `tag-grammar fixture is missing ${invalidTag}`); + } + assert.match(fixtureHarness, /explicit ref \$invalidTag performed a request/u); + assert.match(fixtureHarness, /explicit ref \$invalidTag created an install root/u); + assert.match(fixtureHarness, /ATOMIC_VERSION rejection performed a request/u); + assert.match(fixtureHarness, /foreach \(\$validTag in @\("1\.0\.0", "1\.0\.0-alpha\.1"\)\)/u); + assert.match(fixtureHarness, /an unsupported latest redirect tag still downloaded a release/u); + assert.match(fixtureHarness, /an unsupported latest API tag_name still downloaded a release/u); + assert.doesNotMatch(fixtureHarness, /Atomic \$tag installed successfully\./u); +}); + +test("Windows PowerShell 5.1 fixtures cover missing .CMD, pointer conflicts, and preserved preflight errors", () => { + assert.match(fixtureHarness, /\$env:PATHEXT = "\.EXE;\.BAT"/u); + assert.match(fixtureHarness, /the missing-\.CMD rejection performed a request/u); + assert.match(fixtureHarness, /the missing-\.CMD rejection did not name bare atomic/u); + for (const acceptedPathExt of ['".cmd;.EXE"', '".EXE;.CMD;.BAT"']) { + assert.ok(fixtureHarness.includes(acceptedPathExt), `missing-cmd-pathext lacks the control ${acceptedPathExt}`); + } + assert.ok( + fixtureHarness.includes(`' " .CMD " ; ".EXE" '`), + "missing-cmd-pathext lacks the quoted-whitespace control", + ); + + for (const label of ["current directory", "current file", "atomic-current directory", "atomic-current file"]) { + assert.ok(fixtureHarness.includes(`Label = "${label}"`), `pointer-conflicts lacks ${label}`); + } + assert.match(fixtureHarness, /marker data was not preserved/u); + assert.match(fixtureHarness, /an installer-owned current junction was not replaced/u); + + assert.match(fixtureHarness, /the preflight error was replaced/u); + assert.match(fixtureHarness, /the API error was replaced/u); + assert.equal((fixtureHarness.match(/mentions an uninitialized variable/gu) ?? []).length, 2); + assert.match(fixtureHarness, /\$env:PATHEXT = "\.COM;\.EXE;\.BAT;\.CMD;\.VBS;\.VBE;\.JS;\.JSE;\.WSF;\.WSH;\.MSC"/u); +}); + +test("Windows PowerShell 5.1 Ctrl+C fixture uses a real isolated console event after actual moves", () => { + assert.match(ctrlCHelperSource, /CREATE_NEW_CONSOLE/u); + assert.match(ctrlCHelperSource, /AttachConsole\(process\.dwProcessId\)/u); + assert.match(ctrlCHelperSource, /SetConsoleCtrlHandler\(null, true\)/u); + assert.match(ctrlCHelperSource, /GenerateConsoleCtrlEvent\(CTRL_C_EVENT, 0\)/u); + assert.match(ctrlCChildHarness, /Microsoft\.PowerShell\.Management\\Move-Item[\s\S]+WriteAllText\(\$ReadyPath/u); + assert.match(ctrlCChildHarness, /while \(\$true\) \{ Start-Sleep -Milliseconds 200 \}/u); + assert.match(ctrlCChildHarness, /finally[\s\S]+CHILD_FINALLY/u); + assert.doesNotMatch(ctrlCChildHarness, /Injected transaction failure|throw "Ctrl\+C"/u); + assert.match(ctrlCChildHarness, /Injected one-shot rollback removal failure/u); + assert.match(ctrlCChildHarness, /Injected one-shot rollback restore failure/u); + for (const [state, move, failure] of [ + ["fresh", "version-install", "version-remove"], + ["fresh", "current-install", "current-remove"], + ["fresh", "atomic-current-install", "atomic-current-remove"], + ["fresh", "shim-install", "shim-remove"], + ["existing", "version-backup", "version-restore"], + ["existing", "version-install", "version-remove"], + ["existing", "current-backup", "current-restore"], + ["existing", "current-install", "current-remove"], + ["existing", "atomic-current-backup", "atomic-current-restore"], + ["existing", "atomic-current-install", "atomic-current-remove"], + ["existing", "shim-backup", "shim-restore"], + ["existing", "shim-install", "shim-remove"], + ] as const) { + assert.ok( + fixtureHarness.includes(`State = "${state}"; Move = "${move}"; RollbackFailure = "${failure}"`), + `${state} ${move} does not pair real Ctrl+C with ${failure}`, + ); + } + assert.match(fixtureHarness, /"ROLLBACK_FAILURE:" \+ \$case\.RollbackFailure/u); + assert.match( + fixtureHarness, + /changed old version bytes[\s\S]+changed old current bytes[\s\S]+changed old atomic-current bytes[\s\S]+changed old shim bytes/u, + ); +}); + +test("Windows PowerShell 5.1 temp-cleanup fixture proves bounded removal against real open handles", () => { + assert.match(fixtureHarness, /function global:Remove-Item/u); + assert.match( + fixtureHarness, + /\[IO\.File\]::Open\(\$lockPath, \[IO\.FileMode\]::Open, \[IO\.FileAccess\]::Read, \[IO\.FileShare\]::None\)/u, + ); + assert.match(fixtureHarness, /Microsoft\.PowerShell\.Management\\Remove-Item -LiteralPath \$LiteralPath/u); + assert.match( + fixtureHarness, + /\$global:AtomicFixtureTempLockMode -eq "one-shot" -and\r?\n\s+\$global:AtomicFixtureTempRemovalAttempts -ge 2/u, + ); + assert.match(fixtureHarness, /function Get-TempResidueReport/u); + + const scenario = fixtureHarness.slice( + fixtureHarness.indexOf('elseif ($Scenario -eq "temp-cleanup")'), + fixtureHarness.indexOf('throw "Unknown fixture scenario'), + ); + assert.ok(scenario.length > 0, "the temp-cleanup fixture scenario is missing"); + assert.doesNotMatch(scenario, /Start-Sleep/u, "the deterministic probe must not wait on wall-clock time"); + assert.match(scenario, /GetTempPath\(\) resolved to \$resolvedTempRoot instead of the isolated case temp root/u); + assert.match(scenario, /a real one-shot open handle did not force a verified cleanup retry/u); + assert.match(scenario, /an exhausted temp cleanup reported success/u); + assert.match(scenario, /the exhausted cleanup did not name the exact temporary path/u); + assert.match(scenario, /the exhausted cleanup did not report its attempt count/u); + assert.match(scenario, /the exhausted cleanup did not report the last error/u); + assert.match(scenario, /the exhausted cleanup discarded a completed install/u); + assert.match(scenario, /a failing temp cleanup replaced the primary installer error/u); + assert.match(scenario, /the deferred cleanup failure was not warned exactly once/u); + assert.match(scenario, /a failing temp cleanup blocked rollback of the fresh install root/u); + assert.match(scenario, /a failing temp cleanup blocked removal of the transaction-created bin directory/u); + assert.match(scenario, /installing \$stressTag left temp residue/u); + assert.match(scenario, /a rolled-back install left temp residue/u); + assert.match(fixtureHarness, /temporary installer directory was not cleaned: \$\(\$finalResidue\.Paths\)/u); + assert.match( + fixtureHarness, + /the missing-\.CMD rejection created transaction temp state: \$\(\$rejectionResidue\.Paths\)/u, + ); +}); + +function runPowerShellFixture( + scenario: + | "install" + | "tag-grammar" + | "missing-cmd-pathext" + | "pointer-conflicts" + | "preflight-errors" + | "checksum" + | "final-smoke" + | "unicode" + | "dangling-junction" + | "rollback-retries" + | "transaction-failures" + | "ctrl-c" + | "hash-fallback" + | "ref-identity" + | "semicolon-bin" + | "shadowed-shim" + | "custom-pathext" + | "temp-cleanup", +): string { + assert.ok(powershellExecutable); + const workspace = mkdtempSync(join(tmpdir(), `atomic-ps-fixture-${scenario}-`)); + const harnessPath = join(workspace, "fixture.ps1"); + if (scenario === "ctrl-c") { + writeFileSync(join(workspace, "ctrl-c-child.ps1"), `\uFEFF${ctrlCChildHarness}`, "utf16le"); + writeFileSync(join(workspace, "ctrl-c-helper.cs"), ctrlCHelperSource, "utf8"); + } + writeFileSync(harnessPath, `\uFEFF${fixtureHarness}`, "utf16le"); + try { + const fixtureTimeout = + scenario === "ctrl-c" + ? CTRL_C_FIXTURE_STRUCTURAL_TIMEOUT_MS + : scenario === "rollback-retries" + ? ROLLBACK_RETRY_FIXTURE_STRUCTURAL_TIMEOUT_MS + : scenario === "transaction-failures" + ? TRANSACTION_FAILURE_FIXTURE_STRUCTURAL_TIMEOUT_MS + : scenario === "temp-cleanup" + ? TEMP_CLEANUP_FIXTURE_STRUCTURAL_TIMEOUT_MS + : POWERSHELL_FIXTURE_TIMEOUT_MS; + const result = spawnSyncCollect( + [ + powershellExecutable, + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + harnessPath, + "-InstallerPath", + installerPath, + "-Scenario", + scenario, + ], + { timeout: fixtureTimeout }, + ); + const stdout = result.stdout.toString(); + const stderr = result.stderr.toString(); + assert.equal( + result.exitCode, + 0, + `PowerShell fixture ${scenario} failed.\nstdout:\n${stdout}\nstderr:\n${stderr}`, + ); + assert.match(stdout, new RegExp(`SCENARIO_OK:${scenario}`, "u")); + return stdout; + } finally { + rmSync(workspace, { recursive: true, force: true }); + } +} + +powershellTest("PowerShell 5.1 fixture installs exact refs for both architectures and is idempotent", () => { + runPowerShellFixture("install"); +}); + +powershellTest("PowerShell 5.1 fixture rejects unsupported release tags before archive download", () => { + runPowerShellFixture("tag-grammar"); +}); + +powershellTest("PowerShell 5.1 fixture rejects PATHEXT without .CMD before any request or mutation", () => { + runPowerShellFixture("missing-cmd-pathext"); +}); + +powershellTest("PowerShell 5.1 fixture refuses unexpected regular transaction pointers before any request", () => { + runPowerShellFixture("pointer-conflicts"); +}); + +powershellTest("PowerShell 5.1 fixture preserves preflight and API errors before transaction setup", () => { + runPowerShellFixture("preflight-errors"); +}); + +powershellTest("PowerShell 5.1 fixture rejects a checksum mismatch without mutating the old install", () => { + runPowerShellFixture("checksum"); +}); + +powershellTest("PowerShell 5.1 fixture rolls back a failing final shim smoke and PATH changes", () => { + runPowerShellFixture("final-smoke"); +}); + +powershellTest("PowerShell 5.1 fixture preserves Unicode install paths and rolls back final smoke failure", () => { + runPowerShellFixture("unicode"); +}); + +powershellTest("PowerShell 5.1 fixture repairs and cleans up dangling junctions", () => { + runPowerShellFixture("dangling-junction"); +}); + +powershellTest( + "PowerShell 5.1 fixture retries one-shot rollback removal and restore failures for every installed resource", + () => { + runPowerShellFixture("rollback-retries"); + }, + ROLLBACK_RETRY_FIXTURE_STRUCTURAL_TIMEOUT_MS, +); + +powershellTest( + "PowerShell 5.1 fixture retries one-shot rollback failures after every applicable real console Ctrl+C move", + () => { + runPowerShellFixture("ctrl-c"); + }, + CTRL_C_FIXTURE_STRUCTURAL_TIMEOUT_MS, +); + +powershellTest( + "PowerShell 5.1 fixture rolls back fresh and existing installs at every staged transaction failure", + () => { + runPowerShellFixture("transaction-failures"); + }, + TRANSACTION_FAILURE_FIXTURE_STRUCTURAL_TIMEOUT_MS, +); + +powershellTest("PowerShell 5.1 fixture installs when Get-FileHash is unavailable", () => { + runPowerShellFixture("hash-fallback"); +}); +powershellTest("PowerShell 5.1 fixture fails closed when an exact-tag response names a different release", () => { + runPowerShellFixture("ref-identity"); +}); + +powershellTest("PowerShell 5.1 fixture never appends a semicolon-containing bin directory to PATH", () => { + runPowerShellFixture("semicolon-bin"); +}); + +powershellTest("PowerShell 5.1 fixture refuses same-stem launchers that PATHEXT resolves before the shim", () => { + runPowerShellFixture("shadowed-shim"); +}); + +powershellTest("PowerShell 5.1 fixture honors custom PATHEXT order when .CMD precedes .EXE", () => { + runPowerShellFixture("custom-pathext"); +}); + +powershellTest( + "PowerShell 5.1 fixture removes every temporary download directory against real open handles", + () => { + runPowerShellFixture("temp-cleanup"); + }, + TEMP_CLEANUP_FIXTURE_STRUCTURAL_TIMEOUT_MS, +); diff --git a/test/unit/install-shell.test.ts b/test/unit/install-shell.test.ts new file mode 100644 index 000000000..189236fdb --- /dev/null +++ b/test/unit/install-shell.test.ts @@ -0,0 +1,1357 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { + chmodSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + readlinkSync, + realpathSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, delimiter, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { test } from "vitest"; +import { spawnSyncCollect } from "../helpers/runtime.js"; + +const root = fileURLToPath(new URL("../..", import.meta.url)); +const installerPath = join(root, "install.sh"); +const unixTest = process.platform === "win32" ? test.skip : test; +const systemSysctl = "/usr/sbin/sysctl"; +const darwinRosettaFallbackTest = + process.platform === "darwin" && + existsSync(systemSysctl) && + spawnSyncCollect([systemSysctl, "-in", "hw.optional.arm64"]).stdout.toString().trim() === "1" + ? test + : test.skip; + +const unixAssets = [ + "atomic-darwin-arm64.tar.gz", + "atomic-darwin-x64.tar.gz", + "atomic-linux-x64.tar.gz", + "atomic-linux-arm64.tar.gz", + "atomic-linux-x64-musl.tar.gz", + "atomic-linux-arm64-musl.tar.gz", +] as const; + +test("POSIX installer has valid sh syntax and declares the archive install contract", () => { + if (process.platform !== "win32") { + const syntax = spawnSyncCollect(["sh", "-n", installerPath]); + assert.equal(syntax.exitCode, 0, syntax.stderr.toString()); + } + + const source = readFileSync(installerPath, "utf8"); + assert.ok(source.startsWith("#!/bin/sh\n")); + for (const option of ["--ref ", "--ref=", "-r ", "--help"]) assert.ok(source.includes(option)); + for (const variable of ["ATOMIC_INSTALL_DIR", "ATOMIC_BIN_DIR", "ATOMIC_VERSION", "GITHUB_TOKEN", "GH_TOKEN"]) + assert.ok(source.includes(variable)); + for (const asset of unixAssets) assert.equal(source.split(asset).length - 1, 1, asset); + for (const tool of ["curl", "wget", "sha256sum", "shasum", "openssl"]) + assert.match(source, new RegExp(`command -v ${tool}`, "u")); + assert.doesNotMatch(source, /\bawk\b/u); + assert.doesNotMatch(source, /\[\[|\]\]|\b(?:local|function)\s|pipefail|\$BASH|<\(|>\(/u); + assert.doesNotMatch(source, /\b(?:npm|pnpm|yarn|bun|node|git|jq)(?:\.exe)?\b/iu); + assert.match(source, /sysctl -in hw\.optional\.arm64/u); + assert.match(source, /\/usr\/sbin\/sysctl -in hw\.optional\.arm64/u); + assert.match(source, /\/etc\/alpine-release/u); + assert.match(source, /ldd --version/u); + assert.match(source, /CHECKSUM_MATCHES.*-eq 1/u); + assert.match(source, /staged atomic --version check failed/u); + assert.match(source, /installed atomic --version check failed/u); + assert.ok(source.indexOf("checksum verification failed") < source.indexOf('mkdir -p "$INSTALL_ROOT"')); + assert.equal(source.match(/pwd -P/gu)?.length, 1); + assert.match(source, /INSTALL_ROOT=\$\(normalize_absolute_path "\$INSTALL_ROOT" && printf '_'\)/u); + assert.match(source, /BIN_DIR=\$\(normalize_absolute_path "\$BIN_DIR" && printf '_'\)/u); + assert.match(source, /canonical_physical=\$\(CDPATH= cd -P "\$canonical_probe"[^\n]+&& pwd && printf '_'\)/u); + assert.match(source, /PHYSICAL_INSTALL_ROOT=\$\(canonicalize_existing_prefix "\$INSTALL_ROOT" && printf '_'\)/u); + assert.match(source, /PHYSICAL_BIN_PATH=\$\(canonicalize_existing_prefix "\$BIN_PATH" && printf '_'\)/u); + assert.match(source, /case \$PHYSICAL_INSTALL_ROOT\/ in[\s\S]+"\$PHYSICAL_BIN_PATH\/"\*/u); + assert.match(source, /\[ -d "\$BIN_PATH" \] && \[ ! -L "\$BIN_PATH" \]/u); + assert.match(source, /REQUESTED_REF_ENCODED=\$\(percent_encode "\$REQUESTED_REF"\)/u); + assert.match(source, /RELEASE_TAG_ENCODED=\$\(percent_encode "\$RELEASE_TAG"\)/u); + assert.match(source, /percent_decode "\$resolved_url_tag"/u); + assert.match(source, /VERSION_PATH=\$VERSIONS_DIR\/\$RELEASE_TAG_ENCODED/u); + assert.match(source, /ln -s "versions\/\$RELEASE_TAG_ENCODED"/u); + const containment = source.indexOf("ATOMIC_INSTALL_DIR cannot equal ATOMIC_BIN_DIR/atomic"); + const unexpectedLauncher = source.indexOf("ATOMIC_BIN_DIR/atomic is an unexpected directory"); + assert.ok(containment >= 0 && containment < source.indexOf("for required_command")); + assert.ok(unexpectedLauncher >= 0 && unexpectedLauncher < source.indexOf("for required_command")); +}); + +function resolveExecutable(name: string): string { + for (const directory of (process.env.PATH ?? "").split(delimiter)) { + if (!directory) continue; + const candidate = join(directory, name); + if (existsSync(candidate)) return realpathSync(candidate); + } + throw new Error(`Required fixture command not found: ${name}`); +} + +interface FixtureRelease { + tag: string; + encodedTag: string; + assets: Map; + checksums: string; +} + +interface InstallerFixture { + workspace: string; + home: string; + tempRoot: string; + installRoot: string; + binDir: string; + requestLog: string; + tools: string; + releases: Map; + cleanup(): void; + run(options?: RunOptions): ReturnType; +} + +interface RunOptions { + args?: readonly string[]; + downloader?: "curl" | "wget"; + wgetKind?: "gnu" | "busybox"; + os?: string; + arch?: string; + arm64Sysctl?: string; + libc?: string; + sysctl?: boolean; + ldd?: boolean; + environment?: Record; + pathEntries?: readonly string[]; + umask?: string; +} + +function writeExecutable(path: string, source: string): void { + writeFileSync(path, source); + chmodSync(path, 0o755); +} + +function createArchive(workspace: string, tag: string, asset: string): { path: string; checksum: string } { + const encodedTag = encodeURIComponent(tag); + const sourceRoot = join(workspace, `payload-${encodedTag}-${asset}`); + const payload = join(sourceRoot, "atomic"); + mkdirSync(join(payload, "builtin"), { recursive: true }); + mkdirSync(join(payload, "node_modules", "fixture"), { recursive: true }); + writeExecutable( + join(payload, "atomic"), + `#!/bin/sh\nversion='${tag}'\nif [ "\${ATOMIC_FIXTURE_FAIL_STAGED_VERSION:-}" = "$version" ]; then exit 17; fi\ncase "$0" in\n *atomic-install.*) ;;\n *) if [ "\${ATOMIC_FIXTURE_FAIL_FINAL_VERSION:-}" = "$version" ]; then exit 23; fi ;;\nesac\nif [ "\${1:-}" = --version ]; then printf '%s\\n' "$version"; exit 0; fi\nprintf '%s\\n' "$version:$*"\n`, + ); + writeFileSync(join(payload, "package.json"), JSON.stringify({ name: "@bastani/atomic", version: tag })); + writeFileSync(join(payload, "app.js"), `fixture-${tag}`); + writeFileSync(join(payload, "builtin", "payload.txt"), `builtin-${tag}`); + writeFileSync(join(payload, "node_modules", "fixture", "payload.txt"), `modules-${tag}`); + writeFileSync(join(payload, "asset.txt"), asset); + + const archive = join(workspace, `${encodedTag}-${asset}`); + const result = spawnSyncCollect([resolveExecutable("tar"), "-czf", archive, "-C", sourceRoot, "atomic"]); + assert.equal(result.exitCode, 0, result.stderr.toString()); + const checksum = createHash("sha256").update(readFileSync(archive)).digest("hex"); + rmSync(sourceRoot, { recursive: true, force: true }); + return { path: archive, checksum }; +} + +function addRelease(fixture: InstallerFixture, tag: string): FixtureRelease { + const assets = new Map(); + const rows: string[] = []; + for (const asset of unixAssets) { + const archive = createArchive(fixture.workspace, tag, asset); + assets.set(asset, archive.path); + rows.push(`${archive.checksum} ${asset}`); + } + const release = { tag, encodedTag: encodeURIComponent(tag), assets, checksums: `${rows.join("\n")}\n` }; + fixture.releases.set(tag, release); + return release; +} + +function shellExpansion(expression: string): string { + return ["$", `{${expression}}`].join(""); +} + +const curlWrapper = [ + "#!/bin/sh", + "output=", + "url=", + 'for argument in "$@"; do printf \'ARGV %s\\n\' "$argument" >> "$ATOMIC_FIXTURE_LOG"; done', + 'while [ "$#" -gt 0 ]; do', + " case $1 in", + " -o) shift; output=$1 ;;", + " -w) shift ;;", + ` -H) shift; header=$1; case $header in @*) header_file=${shellExpansion("header#@")}; mode=$($ATOMIC_FIXTURE_REAL_STAT -c '%a' "$header_file" 2>/dev/null || $ATOMIC_FIXTURE_REAL_STAT -f '%Lp' "$header_file"); printf 'AUTH_MODE %s\\n' "$mode" >> "$ATOMIC_FIXTURE_LOG"; while IFS= read -r header_line || [ -n "$header_line" ]; do printf 'HEADER %s\\n' "$header_line" >> "$ATOMIC_FIXTURE_LOG"; done < "$header_file" ;; *) printf 'HEADER %s\\n' "$header" >> "$ATOMIC_FIXTURE_LOG" ;; esac ;;`, + " -*) ;;", + " *) url=$1 ;;", + " esac", + " shift", + "done", + `printf 'GET %s\\n' "$url" >> "$ATOMIC_FIXTURE_LOG"`, + "case $url in", + " https://github.com/bastani-inc/atomic/releases/latest)", + ` [ "${shellExpansion("ATOMIC_FIXTURE_REDIRECT_FAIL:-0")}" = 1 ] && exit 22`, + ` printf 'https://github.com/bastani-inc/atomic/releases/tag/%s' "$ATOMIC_FIXTURE_LATEST_TAG"`, + " ;;", + " https://api.github.com/repos/bastani-inc/atomic/releases/latest)", + ` [ "${shellExpansion("ATOMIC_FIXTURE_FAIL_API:-0")}" = 1 ] && exit 22`, + ` printf '{"tag_name":"%s"}\\n' "$ATOMIC_FIXTURE_LATEST_TAG"`, + " ;;", + " https://api.github.com/repos/bastani-inc/atomic/releases/tags/*)", + ` [ "${shellExpansion("ATOMIC_FIXTURE_FAIL_API:-0")}" = 1 ] && exit 22`, + ` tag=${shellExpansion("url##*/")}`, + ` printf '{"tag_name":"%s"}\\n' "${shellExpansion("ATOMIC_FIXTURE_TAGS_TAG:-$tag")}"`, + " ;;", + " https://github.com/bastani-inc/atomic/releases/download/*/*)", + ` name=${shellExpansion("url##*/")}`, + ` rest=${shellExpansion("url%/*")}`, + ` tag=${shellExpansion("rest##*/")}`, + ` [ "${shellExpansion("ATOMIC_FIXTURE_FAIL_FILE:-")}" = "$name" ] && exit 22`, + ` /bin/cp "$ATOMIC_FIXTURE_RELEASES/$tag/$name" "$output"`, + " ;;", + " *) exit 22 ;;", + "esac", +].join("\n"); + +const wgetWrapper = [ + "#!/bin/sh", + "output=", + "spider=0", + "url=", + 'for argument in "$@"; do printf \'ARGV %s\\n\' "$argument" >> "$ATOMIC_FIXTURE_LOG"; done', + `if [ "${shellExpansion("1:-")}" = --version ]; then`, + ` case "${shellExpansion("ATOMIC_FIXTURE_WGET_KIND:-gnu")}" in`, + " gnu) printf '%s\\n' 'GNU Wget 1.21.3'; exit 0 ;;", + " *) printf '%s\\n' 'BusyBox wget' >&2; exit 1 ;;", + " esac", + "fi", + `if [ -n "${shellExpansion("WGETRC:-")}" ]; then`, + ` mode=$($ATOMIC_FIXTURE_REAL_STAT -c '%a' "$WGETRC" 2>/dev/null || $ATOMIC_FIXTURE_REAL_STAT -f '%Lp' "$WGETRC")`, + ` printf 'AUTH_MODE %s\\n' "$mode" >> "$ATOMIC_FIXTURE_LOG"`, + ` while IFS= read -r config_line || [ -n "$config_line" ]; do case $config_line in 'header = '*) printf 'HEADER %s\\n' "${shellExpansion("config_line#header = ")}" >> "$ATOMIC_FIXTURE_LOG" ;; esac; done < "$WGETRC"`, + "fi", + 'while [ "$#" -gt 0 ]; do', + " case $1 in", + " -O) shift; output=$1 ;;", + " --spider) spider=1 ;;", + ` --header=*) printf 'HEADER %s\\n' "${shellExpansion("1#--header=")}" >> "$ATOMIC_FIXTURE_LOG" ;;`, + " -*) ;;", + " *) url=$1 ;;", + " esac", + " shift", + "done", + `printf 'GET %s\\n' "$url" >> "$ATOMIC_FIXTURE_LOG"`, + "case $url in", + " https://github.com/bastani-inc/atomic/releases/latest)", + ' [ "$spider" = 1 ] || exit 1', + ` [ "${shellExpansion("ATOMIC_FIXTURE_REDIRECT_FAIL:-0")}" = 1 ] && exit 1`, + ` printf ' Location: https://github.com/bastani-inc/atomic/releases/tag/%s [following]\\n' "$ATOMIC_FIXTURE_LATEST_TAG" >&2`, + " ;;", + " https://api.github.com/repos/bastani-inc/atomic/releases/latest)", + ` [ "${shellExpansion("ATOMIC_FIXTURE_FAIL_API:-0")}" = 1 ] && exit 1`, + ` printf '{"tag_name":"%s"}\\n' "$ATOMIC_FIXTURE_LATEST_TAG"`, + " ;;", + " https://api.github.com/repos/bastani-inc/atomic/releases/tags/*)", + ` [ "${shellExpansion("ATOMIC_FIXTURE_FAIL_API:-0")}" = 1 ] && exit 1`, + ` tag=${shellExpansion("url##*/")}`, + ` printf '{"tag_name":"%s"}\\n' "${shellExpansion("ATOMIC_FIXTURE_TAGS_TAG:-$tag")}"`, + " ;;", + " https://github.com/bastani-inc/atomic/releases/download/*/*)", + ` name=${shellExpansion("url##*/")}`, + ` rest=${shellExpansion("url%/*")}`, + ` tag=${shellExpansion("rest##*/")}`, + ` [ "${shellExpansion("ATOMIC_FIXTURE_FAIL_FILE:-")}" = "$name" ] && exit 1`, + ` /bin/cp "$ATOMIC_FIXTURE_RELEASES/$tag/$name" "$output"`, + " ;;", + " *) exit 1 ;;", + "esac", +].join("\n"); + +function createFixture(): InstallerFixture { + const workspace = mkdtempSync(join(tmpdir(), "atomic-sh-installer-")); + const home = join(workspace, "home"); + const tempRoot = join(workspace, "tmp"); + const installRoot = join(workspace, "install root"); + const binDir = join(workspace, "bin root"); + const requestLog = join(workspace, "requests.log"); + const tools = join(workspace, "tools"); + const releasesRoot = join(workspace, "releases"); + mkdirSync(home); + mkdirSync(tempRoot); + mkdirSync(tools); + mkdirSync(releasesRoot); + writeFileSync(requestLog, ""); + + for (const command of ["tar", "mkdir", "chmod", "ln", "rm", "rmdir", "cat", "gzip"]) { + const source = resolveExecutable(command); + symlinkSync(source, join(tools, command)); + } + writeExecutable( + join(tools, "mv"), + [ + "#!/bin/sh", + `case "${shellExpansion("ATOMIC_FIXTURE_FAIL_RESTORE:-")}:$1:$2" in`, + " bin-always:*/.atomic-backup-*:*/atomic) printf '%s\\n' 'fixture restore failure' >&2; exit 71 ;;", + " bin-once:*/.atomic-backup-*:*/atomic)", + " if [ ! -e \"$ATOMIC_FIXTURE_RESTORE_MARKER\" ]; then : > \"$ATOMIC_FIXTURE_RESTORE_MARKER\"; printf '%s\\n' 'fixture one-shot restore failure' >&2; exit 71; fi", + " ;;", + "esac", + '"$ATOMIC_FIXTURE_REAL_MV" "$@" || exit $?', + `case "${shellExpansion("ATOMIC_FIXTURE_SIGNAL_AFTER_MOVE:-")}:$2" in`, + " version-backup:*/versions/.backup-*|version-install:*/versions/[!.]*|current-backup:*/.current-backup-*|current-install:*/current|bin-backup:*/.atomic-backup-*|bin-install:*/atomic)", + ' if [ ! -e "$ATOMIC_FIXTURE_SIGNAL_MARKER" ]; then', + ' : > "$ATOMIC_FIXTURE_SIGNAL_MARKER"', + ` kill -"${shellExpansion("ATOMIC_FIXTURE_SIGNAL:-TERM")}" "$PPID"`, + " fi", + " ;;", + "esac", + ].join("\n"), + ); + let checksumCommand: "sha256sum" | "shasum"; + try { + checksumCommand = "sha256sum"; + symlinkSync(resolveExecutable(checksumCommand), join(tools, checksumCommand)); + } catch { + checksumCommand = "shasum"; + symlinkSync(resolveExecutable(checksumCommand), join(tools, checksumCommand)); + } + assert.ok(checksumCommand); + + writeExecutable( + join(tools, "uname"), + '#!/bin/sh\ncase "$1" in -s) printf \'%s\\n\' "$ATOMIC_FIXTURE_OS" ;; -m) printf \'%s\\n\' "$ATOMIC_FIXTURE_ARCH" ;; *) exit 1 ;; esac\n', + ); + writeExecutable( + join(tools, "sysctl"), + `#!/bin/sh\nprintf '%s\\n' "${shellExpansion("ATOMIC_FIXTURE_ARM64_SYSCTL:-0")}"\n`, + ); + writeExecutable(join(tools, "ldd"), `#!/bin/sh\nprintf '%s\\n' "${shellExpansion("ATOMIC_FIXTURE_LIBC:-glibc")}"\n`); + writeExecutable(join(tools, "curl"), curlWrapper); + writeExecutable(join(tools, "wget"), wgetWrapper); + + const releases = new Map(); + const fixture: InstallerFixture = { + workspace, + home, + tempRoot, + installRoot, + binDir, + requestLog, + tools, + releases, + cleanup: () => rmSync(workspace, { recursive: true, force: true }), + run: (options = {}) => { + const downloader = options.downloader ?? "curl"; + const runTools = join(workspace, `tools-${downloader}-${Math.random().toString(16).slice(2)}`); + mkdirSync(runTools); + for (const entry of readdirSync(tools)) { + if ((entry === "curl" || entry === "wget") && entry !== downloader) continue; + if (entry === "ldd" && options.ldd === false) continue; + if (entry === "sysctl" && options.sysctl === false) continue; + symlinkSync(realpathSync(join(tools, entry)), join(runTools, entry)); + } + for (const release of releases.values()) { + const releaseDir = join(releasesRoot, release.encodedTag); + rmSync(releaseDir, { recursive: true, force: true }); + mkdirSync(releaseDir, { recursive: true }); + for (const [asset, archive] of release.assets) symlinkSync(archive, join(releaseDir, asset)); + writeFileSync(join(releaseDir, "SHA256SUMS"), release.checksums); + } + const env: Record = { + ...process.env, + PATH: [...(options.pathEntries ?? []), runTools].join(delimiter), + HOME: home, + TMPDIR: tempRoot, + ATOMIC_INSTALL_DIR: installRoot, + ATOMIC_BIN_DIR: binDir, + ATOMIC_VERSION: undefined, + GITHUB_TOKEN: undefined, + GH_TOKEN: undefined, + ATOMIC_FIXTURE_LOG: requestLog, + ATOMIC_FIXTURE_RELEASES: releasesRoot, + ATOMIC_FIXTURE_REAL_MV: resolveExecutable("mv"), + WGETRC: undefined, + ATOMIC_FIXTURE_REAL_STAT: resolveExecutable("stat"), + ATOMIC_FIXTURE_RESTORE_MARKER: join(runTools, "restore-failed"), + ATOMIC_FIXTURE_SIGNAL_MARKER: join(runTools, "signal-sent"), + ATOMIC_FIXTURE_LATEST_TAG: "2.0.0", + ATOMIC_FIXTURE_OS: options.os ?? "Linux", + ATOMIC_FIXTURE_ARCH: options.arch ?? "x86_64", + ATOMIC_FIXTURE_WGET_KIND: options.wgetKind ?? "gnu", + ATOMIC_FIXTURE_ARM64_SYSCTL: options.arm64Sysctl ?? "0", + ATOMIC_FIXTURE_LIBC: options.libc ?? "ldd (GNU libc) 2.36", + ...options.environment, + }; + const installerArguments = [installerPath, ...(options.args ?? [])]; + const installerCommand = + options.umask === undefined + ? ["/bin/sh", ...installerArguments] + : ["/bin/sh", "-c", `umask ${options.umask}; exec /bin/sh "$0" "$@"`, ...installerArguments]; + return spawnSyncCollect(installerCommand, { + cwd: workspace, + env, + timeout: 15_000, + }); + }, + }; + addRelease(fixture, "1.0.0"); + addRelease(fixture, "2.0.0"); + return fixture; +} + +function output(result: ReturnType): string { + return `${result.stdout.toString()}${result.stderr.toString()}`; +} + +function assertSuccess(result: ReturnType): void { + assert.equal(result.exitCode, 0, output(result)); +} + +function downloaderArgv(requestLog: string): string { + return requestLog + .split("\n") + .filter((line) => line.startsWith("ARGV ")) + .join("\n"); +} + +function pathExportCommand(result: ReturnType): string { + const marker = "Add Atomic to PATH for this shell:\n "; + const stdout = result.stdout.toString(); + const markerIndex = stdout.indexOf(marker); + assert.ok(markerIndex >= 0, stdout); + const commandWithLineFeed = stdout.slice(markerIndex + marker.length); + assert.ok(commandWithLineFeed.endsWith("\n"), stdout); + return commandWithLineFeed.slice(0, -1); +} + +function currentVersion(fixture: InstallerFixture): string { + return basename(realpathSync(join(fixture.installRoot, "current"))); +} + +function assertNoTemporaryState(fixture: InstallerFixture): void { + assert.deepEqual(readdirSync(fixture.tempRoot), []); + if (existsSync(join(fixture.installRoot, "versions"))) { + assert.equal( + readdirSync(join(fixture.installRoot, "versions")).filter( + (name) => name.startsWith(".stage-") || name.startsWith(".backup-"), + ).length, + 0, + ); + } + if (existsSync(fixture.installRoot)) { + assert.equal(readdirSync(fixture.installRoot).filter((name) => name.startsWith(".current-")).length, 0); + } + if (existsSync(fixture.binDir)) { + assert.equal(readdirSync(fixture.binDir).filter((name) => name.startsWith(".atomic-")).length, 0); + } +} + +unixTest("shell installer follows the stable redirect, installs the full tar payload, and prints a PATH hint", () => { + const fixture = createFixture(); + try { + const result = fixture.run({ environment: { ATOMIC_FIXTURE_FAIL_API: "1" } }); + assertSuccess(result); + assert.equal(currentVersion(fixture), "2.0.0"); + assert.ok(lstatSync(join(fixture.installRoot, "current")).isSymbolicLink()); + assert.ok(lstatSync(join(fixture.binDir, "atomic")).isSymbolicLink()); + for (const path of [ + "atomic", + "package.json", + "app.js", + "builtin/payload.txt", + "node_modules/fixture/payload.txt", + ]) { + assert.ok(existsSync(join(fixture.installRoot, "versions", "2.0.0", path)), path); + } + const installed = spawnSyncCollect([join(fixture.binDir, "atomic"), "--version"], { + env: { PATH: fixture.tools }, + }); + assert.equal(installed.exitCode, 0, installed.stderr.toString()); + assert.equal(installed.stdout.toString().trim(), "2.0.0"); + assert.equal(pathExportCommand(result), `export PATH='${fixture.binDir}':"$PATH"`); + const requests = readFileSync(fixture.requestLog, "utf8"); + assert.match(requests, /GET https:\/\/github\.com\/bastani-inc\/atomic\/releases\/latest/u); + assert.doesNotMatch(requests, /api\.github\.com/u); + assert.match(requests, /atomic-linux-x64\.tar\.gz/u); + assert.match(requests, /SHA256SUMS/u); + assertNoTemporaryState(fixture); + } finally { + fixture.cleanup(); + } +}); + +unixTest("shell installer supports curl and wget fallback, API fallback, every ref form, and token precedence", () => { + for (const downloader of ["curl", "wget"] as const) { + const fixture = createFixture(); + try { + const result = fixture.run({ + downloader, + environment: { ATOMIC_FIXTURE_REDIRECT_FAIL: "1", GITHUB_TOKEN: "github-token", GH_TOKEN: "gh-token" }, + }); + assertSuccess(result); + const requestLog = readFileSync(fixture.requestLog, "utf8"); + assert.match(requestLog, /GET https:\/\/api\.github\.com\/repos\/bastani-inc\/atomic\/releases\/latest/u); + assert.equal((requestLog.match(/HEADER Authorization: Bearer github-token/gu) ?? []).length, 1); + assert.doesNotMatch(downloaderArgv(requestLog), /github-token|gh-token/u); + assert.match(requestLog, /AUTH_MODE 600/u); + assert.doesNotMatch(output(result), /github-token|gh-token/u); + const authorizationIndex = requestLog.indexOf("HEADER Authorization: Bearer github-token"); + const apiIndex = requestLog.indexOf("GET https://api.github.com/"); + const assetIndex = requestLog.indexOf("/releases/download/"); + assert.ok(authorizationIndex >= 0 && authorizationIndex < apiIndex && apiIndex < assetIndex, requestLog); + } finally { + fixture.cleanup(); + } + } + + for (const args of [["--ref", "1.0.0"], ["--ref=1.0.0"], ["-r", "1.0.0"]]) { + const fixture = createFixture(); + try { + assertSuccess(fixture.run({ args, environment: { ATOMIC_VERSION: "2.0.0", GH_TOKEN: "gh-token" } })); + assert.equal(currentVersion(fixture), "1.0.0"); + const requests = readFileSync(fixture.requestLog, "utf8"); + assert.match(requests, /releases\/tags\/1\.0\.0/u); + assert.doesNotMatch(requests, /releases\/tags\/2\.0\.0/u); + assert.match(requests, /HEADER Authorization: Bearer gh-token/u); + } finally { + fixture.cleanup(); + } + } + + const fixture = createFixture(); + try { + assertSuccess(fixture.run({ environment: { ATOMIC_VERSION: "1.0.0" } })); + assert.equal(currentVersion(fixture), "1.0.0"); + } finally { + fixture.cleanup(); + } + + const prereleaseFixture = createFixture(); + try { + addRelease(prereleaseFixture, "1.0.0-alpha.1"); + assertSuccess(prereleaseFixture.run({ args: ["--ref", "1.0.0-alpha.1"] })); + assert.equal(currentVersion(prereleaseFixture), "1.0.0-alpha.1"); + } finally { + prereleaseFixture.cleanup(); + } +}); +unixTest("shell installer keeps BusyBox wget usable without exposing authenticated API tokens", () => { + const redirectFixture = createFixture(); + try { + const result = redirectFixture.run({ + downloader: "wget", + wgetKind: "busybox", + environment: { GITHUB_TOKEN: "redirect-token" }, + }); + assertSuccess(result); + const requests = readFileSync(redirectFixture.requestLog, "utf8"); + assert.doesNotMatch(requests, /api\.github\.com|Authorization|ARGV --version/u); + assert.doesNotMatch(downloaderArgv(requests), /redirect-token/u); + assert.doesNotMatch(output(result), /redirect-token/u); + } finally { + redirectFixture.cleanup(); + } + + const unauthenticatedFixture = createFixture(); + try { + const result = unauthenticatedFixture.run({ + downloader: "wget", + wgetKind: "busybox", + environment: { ATOMIC_FIXTURE_REDIRECT_FAIL: "1" }, + }); + assertSuccess(result); + assert.match(readFileSync(unauthenticatedFixture.requestLog, "utf8"), /api\.github\.com/u); + } finally { + unauthenticatedFixture.cleanup(); + } + + const protectedFixture = createFixture(); + try { + const result = protectedFixture.run({ + downloader: "wget", + wgetKind: "busybox", + environment: { ATOMIC_FIXTURE_REDIRECT_FAIL: "1", GITHUB_TOKEN: "protected-token" }, + }); + assert.notEqual(result.exitCode, 0); + assert.match(output(result), /authenticated GitHub API requests require curl or GNU Wget/u); + assert.doesNotMatch(output(result), /protected-token/u); + const requests = readFileSync(protectedFixture.requestLog, "utf8"); + assert.doesNotMatch(requests, /api\.github\.com|Authorization/u); + assert.doesNotMatch(downloaderArgv(requests), /protected-token/u); + assertNoTemporaryState(protectedFixture); + } finally { + protectedFixture.cleanup(); + } +}); + +unixTest("shell installer resolves relative install and bin roots against one physical working directory", () => { + const fixture = createFixture(); + try { + const relativeBin = "relative bin"; + const absoluteBin = join(realpathSync(fixture.workspace), relativeBin); + const result = fixture.run({ + args: ["--ref", "1.0.0"], + environment: { ATOMIC_INSTALL_DIR: "install root", ATOMIC_BIN_DIR: relativeBin }, + }); + assertSuccess(result); + assert.equal(currentVersion(fixture), "1.0.0"); + const binTarget = readlinkSync(join(absoluteBin, "atomic")); + assert.ok(binTarget.startsWith("/"), `bin target is not absolute: ${binTarget}`); + assert.equal( + realpathSync(join(absoluteBin, "atomic")), + realpathSync(join(fixture.installRoot, "current", "atomic")), + ); + assert.equal(pathExportCommand(result), `export PATH='${absoluteBin}':"$PATH"`); + + const otherDirectory = join(fixture.workspace, "other working directory"); + mkdirSync(otherDirectory); + const installed = spawnSyncCollect(["/bin/sh", "-c", "command -v atomic && atomic --version"], { + cwd: otherDirectory, + env: { PATH: `${absoluteBin}${delimiter}${fixture.tools}` }, + }); + assert.equal(installed.exitCode, 0, installed.stderr.toString()); + assert.equal(installed.stdout.toString().trim(), `${join(absoluteBin, "atomic")}\n1.0.0`); + assertNoTemporaryState(fixture); + } finally { + fixture.cleanup(); + } +}); + +unixTest("shell installer compares metacharacters in PATH entries literally", () => { + const fixture = createFixture(); + try { + const relativeBin = "literal[7]*? bin"; + const absoluteBin = join(realpathSync(fixture.workspace), relativeBin); + const nearMatch = join(realpathSync(fixture.workspace), "literal7-many-q bin"); + const first = fixture.run({ + args: ["--ref", "1.0.0"], + pathEntries: [nearMatch], + environment: { ATOMIC_BIN_DIR: relativeBin }, + }); + assertSuccess(first); + assert.equal(pathExportCommand(first), `export PATH='${absoluteBin}':"$PATH"`); + + const second = fixture.run({ + args: ["--ref", "1.0.0"], + pathEntries: [absoluteBin], + environment: { ATOMIC_BIN_DIR: relativeBin }, + }); + assertSuccess(second); + assert.doesNotMatch(second.stdout.toString(), /Add Atomic to PATH|export PATH=/u); + + const otherDirectory = join(fixture.workspace, "path literal other cwd"); + mkdirSync(otherDirectory); + const installed = spawnSyncCollect(["/bin/sh", "-c", "command -v atomic && atomic --version"], { + cwd: otherDirectory, + env: { PATH: `${absoluteBin}${delimiter}${fixture.tools}` }, + }); + assert.equal(installed.exitCode, 0, installed.stderr.toString()); + assert.equal(installed.stdout.toString().trim(), `${join(absoluteBin, "atomic")}\n1.0.0`); + assertNoTemporaryState(fixture); + } finally { + fixture.cleanup(); + } +}); + +unixTest("shell installer emits executable PATH guidance for every shell-significant path character", () => { + const fixture = createFixture(); + try { + const relativeBin = "quoted $HOME `printf unsafe` 'single' \"double\" \\backslash\nline\n"; + const result = fixture.run({ + args: ["--ref", "1.0.0"], + environment: { ATOMIC_BIN_DIR: relativeBin }, + }); + assertSuccess(result); + const exportCommand = pathExportCommand(result); + assert.match(exportCommand, /^export PATH='/u); + assert.match(exportCommand, /'\\''/u); + + const executed = spawnSyncCollect( + ["/bin/sh", "-c", `${exportCommand}\ncommand -v atomic >/dev/null && atomic --version`], + { cwd: fixture.workspace, env: { PATH: fixture.tools } }, + ); + assert.equal(executed.exitCode, 0, executed.stderr.toString()); + assert.equal(executed.stdout.toString().trim(), "1.0.0"); + assertNoTemporaryState(fixture); + } finally { + fixture.cleanup(); + } +}); + +unixTest("shell installer never treats adjacent PATH entries as one colon-containing bin directory", () => { + const fixture = createFixture(); + try { + const relativeBin = "colon:left:right"; + const absoluteBin = join(realpathSync(fixture.workspace), relativeBin); + const result = fixture.run({ + args: ["--ref", "1.0.0"], + pathEntries: absoluteBin.split(":"), + environment: { ATOMIC_BIN_DIR: relativeBin }, + }); + assertSuccess(result); + const stdout = result.stdout.toString(); + assert.match(stdout, /contains ':' and cannot be represented as one POSIX PATH entry/u); + assert.match(stdout, /Choose a colon-free ATOMIC_BIN_DIR/u); + assert.doesNotMatch(stdout, /Add Atomic to PATH|export PATH=/u); + + const marker = "Run Atomic directly: "; + const directStart = stdout.indexOf(marker); + assert.ok(directStart >= 0, stdout); + const directEnd = stdout.indexOf("\n", directStart); + const directCommand = stdout.slice(directStart + marker.length, directEnd); + const executed = spawnSyncCollect(["/bin/sh", "-c", `${directCommand} --version`], { + env: { PATH: fixture.tools }, + }); + assert.equal(executed.exitCode, 0, executed.stderr.toString()); + assert.equal(executed.stdout.toString().trim(), "1.0.0"); + assertNoTemporaryState(fixture); + } finally { + fixture.cleanup(); + } +}); + +unixTest("shell installer accepts only Atomic stable and alpha release tag grammar", () => { + for (const tag of ["v1.0.0", "1.0", "1.02.3", "1.0.0-alpha.0", "1.0.0-beta.1", "release/1.0.0"]) { + const fixture = createFixture(); + try { + const result = fixture.run({ args: ["--ref", tag] }); + assert.notEqual(result.exitCode, 0, `${tag} unexpectedly passed`); + assert.match(output(result), /expected MAJOR\.MINOR\.PATCH or MAJOR\.MINOR\.PATCH-alpha\.REVISION/u); + assert.equal(readFileSync(fixture.requestLog, "utf8"), ""); + assertNoTemporaryState(fixture); + } finally { + fixture.cleanup(); + } + } +}); + +unixTest("shell installer fails closed when the exact-tag API response names a different release", () => { + const fixture = createFixture(); + try { + const result = fixture.run({ + args: ["--ref", "1.0.0"], + environment: { ATOMIC_FIXTURE_TAGS_TAG: "2.0.0" }, + }); + assert.notEqual(result.exitCode, 0); + assert.match(output(result), /returned release 2\.0\.0 for requested tag 1\.0\.0/u); + + const requests = readFileSync(fixture.requestLog, "utf8"); + assert.match(requests, /GET https:\/\/api\.github\.com\/repos\/bastani-inc\/atomic\/releases\/tags\/1\.0\.0/u); + assert.doesNotMatch(requests, /releases\/download\//u); + assert.ok(!existsSync(fixture.installRoot)); + assert.ok(!existsSync(fixture.binDir)); + assertNoTemporaryState(fixture); + } finally { + fixture.cleanup(); + } +}); + +unixTest("shell installer preserves trailing newlines in custom install and bin directories", () => { + const fixture = createFixture(); + try { + const installRoot = join(fixture.workspace, "install-newline\n"); + const binDir = join(fixture.workspace, "bin-newline\n"); + assertSuccess( + fixture.run({ + args: ["--ref", "1.0.0"], + environment: { ATOMIC_INSTALL_DIR: installRoot, ATOMIC_BIN_DIR: binDir }, + }), + ); + + assert.ok(existsSync(join(binDir, "atomic")), "launcher was not created in the requested bin directory"); + assert.ok(existsSync(join(installRoot, "versions", "1.0.0", "atomic"))); + assert.ok(!existsSync(join(fixture.workspace, "bin-newline")), "installer used a newline-trimmed bin directory"); + assert.ok(!existsSync(join(fixture.workspace, "install-newline"))); + + const launcher = spawnSyncCollect(["/bin/sh", "-c", '"$1" --version', "sh", join(binDir, "atomic")], { + env: { PATH: fixture.tools }, + }); + assert.equal(launcher.exitCode, 0, launcher.stderr.toString()); + assert.equal(launcher.stdout.toString().trim(), "1.0.0"); + } finally { + fixture.cleanup(); + } +}); + +unixTest("shell installer rejects launcher equality and ancestor containment before requests", () => { + for (const paths of [ + { install: "collision/atomic", bin: "collision" }, + { install: "collision/./atomic", bin: "collision/nested/.." }, + { install: "collision/atomic/data", bin: "collision" }, + ]) { + const fixture = createFixture(); + try { + const result = fixture.run({ + args: ["--ref", "1.0.0"], + environment: { ATOMIC_INSTALL_DIR: paths.install, ATOMIC_BIN_DIR: paths.bin }, + }); + assert.notEqual(result.exitCode, 0); + assert.match(output(result), /cannot equal ATOMIC_BIN_DIR\/atomic or be inside that launcher path/u); + assert.equal(readFileSync(fixture.requestLog, "utf8"), ""); + assert.deepEqual(readdirSync(fixture.tempRoot), []); + assert.ok(!existsSync(join(fixture.workspace, "collision"))); + } finally { + fixture.cleanup(); + } + } + + const fixture = createFixture(); + try { + const installRoot = "collision/atomic"; + assertSuccess( + fixture.run({ + args: ["--ref", "1.0.0"], + environment: { ATOMIC_INSTALL_DIR: installRoot, ATOMIC_BIN_DIR: "working-bin" }, + }), + ); + const absoluteInstallRoot = join(fixture.workspace, installRoot); + writeFileSync(join(absoluteInstallRoot, "versions", "1.0.0", "preserve.txt"), "old-state"); + writeFileSync(fixture.requestLog, ""); + const rejected = fixture.run({ + args: ["--ref", "2.0.0"], + environment: { ATOMIC_INSTALL_DIR: "collision/./atomic", ATOMIC_BIN_DIR: "collision/nested/.." }, + }); + assert.notEqual(rejected.exitCode, 0); + assert.match(output(rejected), /cannot equal ATOMIC_BIN_DIR\/atomic or be inside that launcher path/u); + assert.equal(readFileSync(fixture.requestLog, "utf8"), ""); + assert.equal(readFileSync(join(absoluteInstallRoot, "versions", "1.0.0", "preserve.txt"), "utf8"), "old-state"); + assert.equal(basename(realpathSync(join(absoluteInstallRoot, "current"))), "1.0.0"); + const oldLauncher = spawnSyncCollect([join(fixture.workspace, "working-bin", "atomic"), "--version"], { + env: { PATH: fixture.tools }, + }); + assert.equal(oldLauncher.exitCode, 0, oldLauncher.stderr.toString()); + assert.equal(oldLauncher.stdout.toString().trim(), "1.0.0"); + assert.deepEqual(readdirSync(fixture.tempRoot), []); + } finally { + fixture.cleanup(); + } +}); + +unixTest("shell installer refuses to replace a pre-existing launcher directory", () => { + const fixture = createFixture(); + try { + const unexpectedLauncher = join(fixture.binDir, "atomic"); + mkdirSync(unexpectedLauncher, { recursive: true }); + writeFileSync(join(unexpectedLauncher, "keep.txt"), "caller-data"); + const result = fixture.run({ args: ["--ref", "1.0.0"] }); + assert.notEqual(result.exitCode, 0); + assert.match(output(result), /unexpected directory; refusing to replace it/u); + assert.equal(readFileSync(join(unexpectedLauncher, "keep.txt"), "utf8"), "caller-data"); + assert.equal(readFileSync(fixture.requestLog, "utf8"), ""); + assert.deepEqual(readdirSync(fixture.tempRoot), []); + } finally { + fixture.cleanup(); + } +}); + +unixTest("shell installer resolves symlink aliases before collision preflight without mutation", () => { + for (const existing of [false, true]) { + const fixture = createFixture(); + try { + const physicalParent = join( + realpathSync(fixture.workspace), + `physical collision ${existing ? "existing" : "fresh"}`, + ); + const installRoot = join(physicalParent, "atomic"); + const binAlias = join(realpathSync(fixture.workspace), `bin alias ${existing ? "existing" : "fresh"}`); + const workingBin = join(realpathSync(fixture.workspace), "working collision bin"); + mkdirSync(physicalParent); + writeFileSync(join(physicalParent, "parent-marker.txt"), "keep-parent"); + symlinkSync(physicalParent, binAlias); + + if (existing) { + assertSuccess( + fixture.run({ + args: ["--ref", "1.0.0"], + environment: { ATOMIC_INSTALL_DIR: installRoot, ATOMIC_BIN_DIR: workingBin }, + }), + ); + writeFileSync(join(installRoot, "versions", "1.0.0", "preserve.txt"), "old-state"); + writeFileSync(fixture.requestLog, ""); + } + + const beforeParentEntries = readdirSync(physicalParent).sort(); + const rejected = fixture.run({ + args: ["--ref", "2.0.0"], + environment: { + ATOMIC_INSTALL_DIR: join(physicalParent, ".", "missing", "..", "atomic"), + ATOMIC_BIN_DIR: join(binAlias, ".", "missing", ".."), + }, + }); + assert.notEqual(rejected.exitCode, 0); + assert.match(output(rejected), /cannot equal ATOMIC_BIN_DIR\/atomic or be inside that launcher path/u); + assert.equal(readFileSync(fixture.requestLog, "utf8"), ""); + assert.deepEqual(readdirSync(fixture.tempRoot), []); + assert.deepEqual(readdirSync(physicalParent).sort(), beforeParentEntries); + assert.equal(readFileSync(join(physicalParent, "parent-marker.txt"), "utf8"), "keep-parent"); + assert.ok(lstatSync(binAlias).isSymbolicLink()); + + if (existing) { + assert.equal(readFileSync(join(installRoot, "versions", "1.0.0", "preserve.txt"), "utf8"), "old-state"); + assert.equal(basename(realpathSync(join(installRoot, "current"))), "1.0.0"); + const oldLauncher = spawnSyncCollect([join(workingBin, "atomic"), "--version"], { + env: { PATH: fixture.tools }, + }); + assert.equal(oldLauncher.exitCode, 0, oldLauncher.stderr.toString()); + assert.equal(oldLauncher.stdout.toString().trim(), "1.0.0"); + } else { + assert.ok(!existsSync(installRoot)); + } + } finally { + fixture.cleanup(); + } + } +}); + +darwinRosettaFallbackTest("shell installer detects Rosetta with /usr/sbin/sysctl outside restricted PATH", () => { + const fixture = createFixture(); + try { + const result = fixture.run({ + args: ["--ref", "1.0.0"], + os: "Darwin", + arch: "x86_64", + sysctl: false, + }); + assertSuccess(result); + assert.equal( + readFileSync(join(fixture.installRoot, "current", "asset.txt"), "utf8"), + "atomic-darwin-arm64.tar.gz", + ); + assert.match(readFileSync(fixture.requestLog, "utf8"), /atomic-darwin-arm64\.tar\.gz$/mu); + assertNoTemporaryState(fixture); + } finally { + fixture.cleanup(); + } +}); +unixTest("shell installer selects every Darwin and Linux archive, including Rosetta and musl", () => { + const cases = [ + [{ os: "Darwin", arch: "x86_64", arm64Sysctl: "1" }, "atomic-darwin-arm64.tar.gz"], + [{ os: "Darwin", arch: "x86_64", arm64Sysctl: "0" }, "atomic-darwin-x64.tar.gz"], + [{ os: "Linux", arch: "x86_64", libc: "ldd (GNU libc) 2.36" }, "atomic-linux-x64.tar.gz"], + [ + { os: "Linux", arch: "aarch64", libc: "GNU C Library stable release version 2.39" }, + "atomic-linux-arm64.tar.gz", + ], + [{ os: "Linux", arch: "x86_64", libc: "musl libc" }, "atomic-linux-x64-musl.tar.gz"], + [{ os: "Linux", arch: "arm64", libc: "musl libc" }, "atomic-linux-arm64-musl.tar.gz"], + ] as const; + for (const [host, asset] of cases) { + const fixture = createFixture(); + try { + assertSuccess(fixture.run({ ...host, args: ["--ref", "1.0.0"] })); + assert.equal(readFileSync(join(fixture.installRoot, "current", "asset.txt"), "utf8"), asset); + assert.match(readFileSync(fixture.requestLog, "utf8"), new RegExp(`${asset.replaceAll(".", "\\.")}$`, "mu")); + } finally { + fixture.cleanup(); + } + } +}); + +unixTest("shell installer rejects unsupported hosts and malformed invocations without network or install state", () => { + for (const [options, message] of [ + [{ os: "FreeBSD" }, "unsupported operating system: FreeBSD"], + [{ arch: "riscv64" }, "unsupported architecture: riscv64"], + [{ libc: "uClibc 1.0.43" }, "unsupported Linux libc: uClibc"], + [{ libc: "Android bionic libc" }, "unsupported Linux libc: bionic"], + [{ libc: "mystery libc 9" }, "unsupported Linux libc: unknown"], + [{ ldd: false }, "unable to identify Linux libc: ldd not found"], + [{ environment: { ANDROID_ROOT: "/system" } }, "unsupported Linux libc: bionic"], + [{ args: ["--ref"] }, "--ref requires a release tag"], + [{ args: ["--ref="] }, "--ref requires a non-empty release tag"], + [{ args: ["--unknown"] }, "unknown option: --unknown"], + ] as const) { + const fixture = createFixture(); + try { + const result = fixture.run(options); + assert.notEqual(result.exitCode, 0); + assert.match(output(result), new RegExp(message.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"), "u")); + assert.equal(readFileSync(fixture.requestLog, "utf8"), ""); + assert.ok(!existsSync(fixture.installRoot)); + assertNoTemporaryState(fixture); + } finally { + fixture.cleanup(); + } + } + + const fixture = createFixture(); + try { + const help = fixture.run({ args: ["--help"] }); + assertSuccess(help); + assert.match(help.stdout.toString(), /Usage:/u); + assert.equal(readFileSync(fixture.requestLog, "utf8"), ""); + } finally { + fixture.cleanup(); + } +}); + +unixTest("checksum and archive failures preserve an existing install and clean temporary state", () => { + const cases = [ + "missing", + "malformed", + "duplicate", + "mismatch", + "download", + "checksum-download", + "extract", + "staged-smoke", + ] as const; + for (const failure of cases) { + const fixture = createFixture(); + try { + assertSuccess(fixture.run({ args: ["--ref", "1.0.0"] })); + writeFileSync(join(fixture.installRoot, "versions", "1.0.0", "preserve.txt"), "old-state"); + const next = fixture.releases.get("2.0.0") as FixtureRelease; + const target = "atomic-linux-x64.tar.gz"; + const targetArchive = next.assets.get(target) as string; + const validHash = createHash("sha256").update(readFileSync(targetArchive)).digest("hex"); + let environment: Record = {}; + switch (failure) { + case "missing": + next.checksums = next.checksums + .split("\n") + .filter((line) => !line.endsWith(target)) + .join("\n"); + break; + case "malformed": + next.checksums = `not-a-hash ${target}\n`; + break; + case "duplicate": + next.checksums = `${validHash} ${target}\n${validHash} ${target}\n`; + break; + case "mismatch": + next.checksums = `${"0".repeat(64)} ${target}\n`; + break; + case "download": + environment = { ATOMIC_FIXTURE_FAIL_FILE: target }; + break; + case "checksum-download": + environment = { ATOMIC_FIXTURE_FAIL_FILE: "SHA256SUMS" }; + break; + case "extract": + writeFileSync(targetArchive, "not a tar archive"); + next.checksums = `${createHash("sha256").update("not a tar archive").digest("hex")} ${target}\n`; + break; + case "staged-smoke": + environment = { ATOMIC_FIXTURE_FAIL_STAGED_VERSION: "2.0.0" }; + break; + } + const result = fixture.run({ args: ["--ref", "2.0.0"], environment }); + assert.notEqual(result.exitCode, 0, `${failure} unexpectedly passed`); + assert.equal(currentVersion(fixture), "1.0.0", failure); + assert.equal( + readFileSync(join(fixture.installRoot, "versions", "1.0.0", "preserve.txt"), "utf8"), + "old-state", + ); + assert.ok(!existsSync(join(fixture.installRoot, "versions", "2.0.0"))); + assertNoTemporaryState(fixture); + } finally { + fixture.cleanup(); + } + } +}); + +unixTest("same-version reinstall and upgrade are clean, idempotent, and roll back a final launcher failure", () => { + const fixture = createFixture(); + try { + assertSuccess(fixture.run({ args: ["--ref", "1.0.0"] })); + const versionOne = join(fixture.installRoot, "versions", "1.0.0"); + writeFileSync(join(versionOne, "stale.txt"), "stale"); + assertSuccess(fixture.run({ args: ["--ref", "1.0.0"] })); + assert.ok(!existsSync(join(versionOne, "stale.txt"))); + assert.equal(currentVersion(fixture), "1.0.0"); + + const failed = fixture.run({ + args: ["--ref", "2.0.0"], + environment: { ATOMIC_FIXTURE_FAIL_FINAL_VERSION: "2.0.0" }, + }); + assert.notEqual(failed.exitCode, 0); + assert.match(output(failed), /installed atomic --version check failed/u); + assert.equal(currentVersion(fixture), "1.0.0"); + assert.ok(existsSync(join(versionOne, "atomic"))); + assert.ok(!existsSync(join(fixture.installRoot, "versions", "2.0.0"))); + + assertSuccess(fixture.run({ args: ["--ref", "2.0.0"] })); + assert.equal(currentVersion(fixture), "2.0.0"); + assert.ok(existsSync(join(versionOne, "atomic")), "upgrade should retain older versions"); + assertNoTemporaryState(fixture); + } finally { + fixture.cleanup(); + } +}); + +unixTest("POSIX rollback retries launcher restores and reports retained recovery backups", () => { + const retryFixture = createFixture(); + try { + assertSuccess(retryFixture.run({ args: ["--ref", "1.0.0"] })); + const failed = retryFixture.run({ + args: ["--ref", "2.0.0"], + environment: { + ATOMIC_FIXTURE_FAIL_FINAL_VERSION: "2.0.0", + ATOMIC_FIXTURE_FAIL_RESTORE: "bin-once", + }, + }); + assert.notEqual(failed.exitCode, 0); + assert.match(output(failed), /failed to restore the previous atomic launcher/u); + assert.equal(currentVersion(retryFixture), "1.0.0"); + const restored = spawnSyncCollect([join(retryFixture.binDir, "atomic"), "--version"], { + env: { PATH: retryFixture.tools }, + }); + assert.equal(restored.exitCode, 0, restored.stderr.toString()); + assert.equal(restored.stdout.toString().trim(), "1.0.0"); + assertNoTemporaryState(retryFixture); + } finally { + retryFixture.cleanup(); + } + + const retainedFixture = createFixture(); + try { + assertSuccess(retainedFixture.run({ args: ["--ref", "1.0.0"] })); + const failed = retainedFixture.run({ + args: ["--ref", "2.0.0"], + environment: { + ATOMIC_FIXTURE_FAIL_FINAL_VERSION: "2.0.0", + ATOMIC_FIXTURE_FAIL_RESTORE: "bin-always", + }, + }); + assert.notEqual(failed.exitCode, 0); + assert.match(output(failed), /rollback remains incomplete after 3 attempts; backups were retained for recovery/u); + assert.equal(currentVersion(retainedFixture), "1.0.0"); + assert.ok(!existsSync(join(retainedFixture.binDir, "atomic"))); + const backupNames = readdirSync(retainedFixture.binDir).filter((name) => name.startsWith(".atomic-backup-")); + assert.equal(backupNames.length, 1); + const retained = spawnSyncCollect([join(retainedFixture.binDir, backupNames[0] as string), "--version"], { + env: { PATH: retainedFixture.tools }, + }); + assert.equal(retained.exitCode, 0, retained.stderr.toString()); + assert.equal(retained.stdout.toString().trim(), "1.0.0"); + assert.deepEqual(readdirSync(retainedFixture.tempRoot), []); + } finally { + retainedFixture.cleanup(); + } +}); + +unixTest("failed POSIX installs remove every empty parent directory they created", () => { + const fixture = createFixture(); + try { + const createdRoot = join(fixture.workspace, "created parents"); + const result = fixture.run({ + args: ["--ref", "1.0.0"], + environment: { + ATOMIC_INSTALL_DIR: join(createdRoot, "install", "a", "root"), + ATOMIC_BIN_DIR: join(createdRoot, "bin", "b", "root"), + ATOMIC_FIXTURE_FAIL_FINAL_VERSION: "1.0.0", + }, + }); + assert.notEqual(result.exitCode, 0); + assert.match(output(result), /installed atomic --version check failed/u); + assert.ok(!existsSync(createdRoot)); + assert.deepEqual(readdirSync(fixture.tempRoot), []); + } finally { + fixture.cleanup(); + } +}); + +unixTest("catchable signals after transaction moves restore the complete previous install", () => { + const cases = [ + ["version-backup", "TERM"], + ["version-install", "INT"], + ["current-backup", "TERM"], + ["current-install", "INT"], + ["bin-backup", "TERM"], + ["bin-install", "INT"], + ] as const; + for (const [move, signal] of cases) { + const fixture = createFixture(); + try { + assertSuccess(fixture.run({ args: ["--ref", "1.0.0"] })); + const versionOne = join(fixture.installRoot, "versions", "1.0.0"); + writeFileSync(join(versionOne, "preserve.txt"), `${move}-${signal}`); + const interrupted = fixture.run({ + args: ["--ref", "1.0.0"], + environment: { + ATOMIC_FIXTURE_SIGNAL: signal, + ATOMIC_FIXTURE_SIGNAL_AFTER_MOVE: move, + }, + }); + assert.notEqual(interrupted.exitCode, 0, `${move} ${signal} unexpectedly passed`); + assert.equal(readFileSync(join(versionOne, "preserve.txt"), "utf8"), `${move}-${signal}`); + assert.equal(currentVersion(fixture), "1.0.0"); + const installed = spawnSyncCollect([join(fixture.binDir, "atomic"), "--version"], { + env: { PATH: fixture.tools }, + }); + assert.equal(installed.exitCode, 0, `${move} ${signal}: ${installed.stderr.toString()}`); + assert.equal(installed.stdout.toString().trim(), "1.0.0"); + assertNoTemporaryState(fixture); + } finally { + fixture.cleanup(); + } + } +}); + +unixTest("shell installer rejects bin directories inside transaction-owned install paths before any request", () => { + for (const binSuffix of ["current", "current/nested", "versions", "versions/1.0.0", "versions/1.2.3/bin"]) { + const fixture = createFixture(); + try { + const result = fixture.run({ + args: ["--ref", "1.0.0"], + environment: { ATOMIC_BIN_DIR: join(fixture.installRoot, ...binSuffix.split("/")) }, + }); + assert.notEqual(result.exitCode, 0, binSuffix); + assert.match( + output(result), + /ATOMIC_BIN_DIR cannot be inside ATOMIC_INSTALL_DIR\/(?:current|versions); the installer replaces that path/u, + binSuffix, + ); + assert.equal(readFileSync(fixture.requestLog, "utf8"), "", binSuffix); + assert.deepEqual(readdirSync(fixture.tempRoot), [], binSuffix); + assert.ok(!existsSync(fixture.installRoot), binSuffix); + } finally { + fixture.cleanup(); + } + } + + const fixture = createFixture(); + try { + assertSuccess(fixture.run({ args: ["--ref", "1.0.0"] })); + writeFileSync(join(fixture.installRoot, "versions", "1.0.0", "preserve.txt"), "old-state"); + const currentAlias = join(fixture.workspace, "current alias"); + symlinkSync(join(fixture.installRoot, "current"), currentAlias); + writeFileSync(fixture.requestLog, ""); + + const rejected = fixture.run({ + args: ["--ref", "2.0.0"], + environment: { ATOMIC_BIN_DIR: join(currentAlias, "bin") }, + }); + assert.notEqual(rejected.exitCode, 0); + assert.match(output(rejected), /ATOMIC_BIN_DIR cannot be inside ATOMIC_INSTALL_DIR\/versions/u); + assert.equal(readFileSync(fixture.requestLog, "utf8"), ""); + assert.deepEqual(readdirSync(fixture.tempRoot), []); + assert.ok(!existsSync(join(fixture.installRoot, "versions", "2.0.0"))); + assert.ok(!existsSync(join(fixture.installRoot, "versions", "1.0.0", "bin"))); + assert.equal(readFileSync(join(fixture.installRoot, "versions", "1.0.0", "preserve.txt"), "utf8"), "old-state"); + assert.equal(currentVersion(fixture), "1.0.0"); + } finally { + fixture.cleanup(); + } + + for (const ownedChild of ["current", "versions"]) { + const fixture = createFixture(); + try { + const aliasParent = join(fixture.workspace, `${ownedChild} alias parent`); + const alias = join(aliasParent, `${ownedChild} alias`); + mkdirSync(aliasParent); + symlinkSync(join(fixture.installRoot, ownedChild), alias); + for (const binSuffix of ["bin", "bin/"]) { + const rejected = fixture.run({ + args: ["--ref", "1.0.0"], + environment: { ATOMIC_BIN_DIR: `${alias}/${binSuffix}` }, + }); + assert.notEqual(rejected.exitCode, 0, `${ownedChild}/${binSuffix}`); + assert.match( + output(rejected), + /ATOMIC_BIN_DIR contains an unresolved symbolic link; refusing an unresolvable path/u, + `${ownedChild}/${binSuffix}`, + ); + assert.equal(readFileSync(fixture.requestLog, "utf8"), "", `${ownedChild}/${binSuffix}`); + assert.deepEqual(readdirSync(fixture.tempRoot), [], `${ownedChild}/${binSuffix}`); + assert.ok(!existsSync(fixture.installRoot), `${ownedChild}/${binSuffix}`); + } + assert.ok(lstatSync(alias).isSymbolicLink(), ownedChild); + } finally { + fixture.cleanup(); + } + } + for (const ownedChild of ["current", "versions"]) { + const fixture = createFixture(); + try { + const aliasParent = join(fixture.workspace, `${ownedChild} install alias parent`); + const alias = join(aliasParent, `${ownedChild} install alias`); + mkdirSync(aliasParent); + symlinkSync(join(fixture.installRoot, ownedChild), alias); + const rejected = fixture.run({ + args: ["--ref", "1.0.0"], + environment: { ATOMIC_INSTALL_DIR: join(alias, "nested") }, + }); + assert.notEqual(rejected.exitCode, 0, ownedChild); + assert.match( + output(rejected), + /ATOMIC_INSTALL_DIR contains an unresolved symbolic link; refusing an unresolvable path/u, + ownedChild, + ); + assert.equal(readFileSync(fixture.requestLog, "utf8"), "", ownedChild); + assert.deepEqual(readdirSync(fixture.tempRoot), [], ownedChild); + assert.ok(!existsSync(fixture.installRoot), ownedChild); + assert.ok(lstatSync(alias).isSymbolicLink(), ownedChild); + } finally { + fixture.cleanup(); + } + } + const acceptedFixture = createFixture(); + try { + const nestedBin = join(acceptedFixture.installRoot, "bin"); + assertSuccess(acceptedFixture.run({ args: ["--ref", "1.0.0"], environment: { ATOMIC_BIN_DIR: nestedBin } })); + const installed = spawnSyncCollect([join(nestedBin, "atomic"), "--version"], { + env: { PATH: acceptedFixture.tools }, + }); + assert.equal(installed.exitCode, 0, installed.stderr.toString()); + assert.equal(installed.stdout.toString().trim(), "1.0.0"); + } finally { + acceptedFixture.cleanup(); + } +}); + +unixTest("shell installer accepts GNU sha256sum binary-mode rows", () => { + const fixture = createFixture(); + try { + for (const release of fixture.releases.values()) { + release.checksums = release.checksums.replace(/^([0-9a-f]{64}) {2}/gmu, "$1 *"); + } + assert.match(fixture.releases.get("1.0.0")?.checksums ?? "", /^[0-9a-f]{64} \*atomic-linux-x64\.tar\.gz$/mu); + assertSuccess(fixture.run({ args: ["--ref", "1.0.0"] })); + assert.equal(currentVersion(fixture), "1.0.0"); + assert.ok(existsSync(join(fixture.binDir, "atomic"))); + } finally { + fixture.cleanup(); + } +}); + +unixTest("shell installer restricts owner-only modes to temporary state and installs with the caller's umask", () => { + const fixture = createFixture(); + try { + assertSuccess(fixture.run({ args: ["--ref", "1.0.0"], umask: "022" })); + const versionPath = join(fixture.installRoot, "versions", "1.0.0"); + for (const directory of [ + fixture.installRoot, + join(fixture.installRoot, "versions"), + versionPath, + fixture.binDir, + ]) { + assert.equal((statSync(directory).mode & 0o777).toString(8), "755", directory); + } + assert.equal((statSync(join(versionPath, "package.json")).mode & 0o777).toString(8), "644"); + assert.equal((statSync(join(versionPath, "atomic")).mode & 0o777).toString(8), "755"); + assert.equal((statSync(join(versionPath, "builtin")).mode & 0o777).toString(8), "755"); + const requests = readFileSync(fixture.requestLog, "utf8"); + assert.doesNotMatch(requests, /AUTH_MODE/u); + } finally { + fixture.cleanup(); + } + + const tokenFixture = createFixture(); + try { + assertSuccess( + tokenFixture.run({ + args: ["--ref", "1.0.0"], + umask: "022", + environment: { ATOMIC_FIXTURE_REDIRECT_FAIL: "1", GITHUB_TOKEN: "github-token" }, + }), + ); + assert.match(readFileSync(tokenFixture.requestLog, "utf8"), /AUTH_MODE 600/u); + assert.equal((statSync(tokenFixture.binDir).mode & 0o777).toString(8), "755"); + } finally { + tokenFixture.cleanup(); + } +});