From c9fc06d6a5dd327afa1338110f688110a6b5e388 Mon Sep 17 00:00:00 2001 From: David Henning Date: Mon, 3 Aug 2026 22:54:05 +0200 Subject: [PATCH] feat: add a windows install script install.ps1 is the Windows counterpart to install.sh, served at https://jwtd.sh/install.ps1 and published by the same Pages step. It keeps the Unix installer's contract - verify before writing anything, checksum always, a present cosign never advisory, the same certificate identity and issuer, no elevation - and differs only where Windows forces it to: - It consumes the windows .zip rather than the .tar.gz, because Expand-Archive ships with PowerShell 5.1 and tar does not. The zips already exist for WinGet and are covered by the signed checksums.txt. - Errors throw instead of exiting: under `irm | iex` an exit would close the user's shell. Preference variables are set inside Install-Jwtd for the same reason, so they do not leak into the caller's session. - It edits the user PATH itself, which install.sh can only hint at. The HKCU value is read unexpanded and written back as ExpandString, since [Environment]::SetEnvironmentVariable would rewrite %USERPROFILE%-style entries the installer never touched. - An upgrade renames the installed binary aside before moving the new one into place, because Windows cannot overwrite a running .exe. - Options come from environment variables, as Invoke-Expression cannot forward arguments. - Architecture detection corrects for x64-on-ARM64 emulation. install_test.go asserts the script's shape; the behaviour needs a real Windows host, so a windows-installer job installs the latest release for real, reinstalls over it, checks that an unavailable release writes nothing, and verifies the PATH edit leaves a seeded %USERPROFILE% entry verbatim. WebCodr.jwtd is not in winget-pkgs yet, so the site's Windows hero pointed at a command that fails today. It now offers the install script, and the Windows panel lists script, Scoop, then WinGet. Co-Authored-By: Claude Opus 5 --- .github/workflows/pages.yml | 16 +- .github/workflows/test.yml | 89 +++++++++ .gitignore | 1 + AGENTS.md | 15 +- README.md | 25 ++- install.ps1 | 361 ++++++++++++++++++++++++++++++++++++ install.sh | 4 +- install_test.go | 241 ++++++++++++++++++++++++ site/index.html | 28 ++- site/script.js | 4 +- site/script.test.js | 7 +- 11 files changed, 770 insertions(+), 21 deletions(-) create mode 100644 install.ps1 diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 5c0ca62..e81d7b5 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -6,6 +6,7 @@ on: paths: - "site/**" - "install.sh" + - "install.ps1" - ".github/workflows/pages.yml" workflow_dispatch: @@ -29,12 +30,15 @@ jobs: run: | version="$(gh release view --json tagName --jq .tagName)" sed -i "s/VERSION/${version}/g" site/index.html - # The installer lives at the repository root as the single source of - # truth and is copied into the Pages artifact so that - # https://jwtd.sh/install.sh serves it. It is never edited here, so the - # hosted script is byte-identical to the reviewed one. - - name: Publish the install script at jwtd.sh/install.sh - run: install -m 0755 install.sh site/install.sh + # The installers live at the repository root as the single source of + # truth and are copied into the Pages artifact so that + # https://jwtd.sh/install.sh and /install.ps1 serve them. They are never + # edited here, so the hosted scripts are byte-identical to the reviewed + # ones. + - name: Publish the install scripts at jwtd.sh + run: | + install -m 0755 install.sh site/install.sh + install -m 0644 install.ps1 site/install.ps1 - uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4.0.0 with: path: site diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e9bcc31..1f850b7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -150,6 +150,95 @@ jobs: (cd dist && sha256sum --check checksums.txt) + # install_test.go can only assert the shape of install.ps1; the behaviour it + # guards - archive naming, checksum verification, replacing a running binary, + # and the user PATH edit - needs a real Windows host. This job installs the + # latest published release the way a user would. + windows-installer: + runs-on: windows-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Check install.ps1 syntax + shell: pwsh + run: | + $errors = $null + [void][System.Management.Automation.Language.Parser]::ParseFile( + (Resolve-Path ./install.ps1), [ref]$null, [ref]$errors) + if ($errors) { + $errors | ForEach-Object { $_.ToString() } + exit 1 + } + + - name: Install the latest release + shell: pwsh + run: | + $dir = Join-Path $env:RUNNER_TEMP 'jwtd-install' + ./install.ps1 -Dir $dir -NoModifyPath + & (Join-Path $dir 'jwtd.exe') --version + + # The second run exercises the upgrade path: Windows cannot overwrite an + # existing .exe in place, so the installer renames it aside first and must + # clean up after itself. + - name: Reinstall over the existing binary + shell: pwsh + run: | + $dir = Join-Path $env:RUNNER_TEMP 'jwtd-install' + ./install.ps1 -Dir $dir -NoModifyPath + $token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6Imp3dGQifQ.JbLj7QQhCqlNvT-EOwJSeB4ArXQPoXlhBGhWSGD7-V4' + & (Join-Path $dir 'jwtd.exe') --json $token + $leftovers = @(Get-ChildItem -Path $dir -Force | Where-Object { $_.Name -ne 'jwtd.exe' }) + if ($leftovers.Count -ne 0) { + "the installer left files behind: $($leftovers.Name -join ', ')" + exit 1 + } + + - name: Write nothing when the release cannot be fetched + shell: pwsh + run: | + $dir = Join-Path $env:RUNNER_TEMP 'jwtd-unavailable' + $failed = $false + try { ./install.ps1 -Dir $dir -NoModifyPath -Version v0.0.0-does-not-exist } + catch { $failed = $true } + if (-not $failed) { 'the installer must fail on an unavailable release'; exit 1 } + if (Test-Path (Join-Path $dir 'jwtd.exe')) { + 'the installer must not write a binary when verification cannot pass' + exit 1 + } + + - name: Add to the user PATH without rewriting existing entries + shell: pwsh + run: | + $key = 'HKCU:\Environment' + $original = (Get-Item $key).GetValue( + 'Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) + try { + Set-ItemProperty -Path $key -Name 'Path' ` + -Value '%USERPROFILE%\seeded;C:\already\there' -Type ExpandString + $dir = Join-Path $env:RUNNER_TEMP 'jwtd-path' + ./install.ps1 -Dir $dir + + $raw = (Get-Item $key).GetValue( + 'Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) + $entries = @($raw -split ';' | Where-Object { $_ -ne '' }) + # The seeded entry must survive verbatim: an installer that expands + # it would bake this runner's profile path into the user's PATH. + if ($entries[0] -ne '%USERPROFILE%\seeded') { + "unexpanded PATH entry was rewritten: $raw" + exit 1 + } + if ($entries -notcontains $dir) { + "install directory missing from PATH: $raw" + exit 1 + } + if ((Get-Item $key).GetValueKind('Path') -ne 'ExpandString') { + 'PATH must stay a REG_EXPAND_SZ value' + exit 1 + } + } finally { + Set-ItemProperty -Path $key -Name 'Path' -Value $original -Type ExpandString + } + nix: runs-on: ubuntu-latest steps: diff --git a/.gitignore b/.gitignore index be6a88c..f22ce6e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ dist/ completions/ # Copied from the repository root by the Pages workflow at build time. site/install.sh +site/install.ps1 .worktrees/ result result-* diff --git a/AGENTS.md b/AGENTS.md index 4e30400..3c4b4b6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,9 +87,9 @@ Artifacts cross the build/release job boundary as two separate uploads: `jwtd-re Release notes are auto-generated (`--generate-notes`), which lists only merged PR titles. `RELEASE_NOTES.md` holds hand-written prose for the next release: when present and non-empty it is prepended to the generated notes at release creation. Clear it after a release so its contents do not repeat on the following one. -### Install script +### Install scripts -`install.sh` is a package-manager-free installer for macOS and Linux, served at `https://jwtd.sh/install.sh` (`curl -fsSL https://jwtd.sh/install.sh | sh`). It maps `uname -s`/`uname -m` onto the GoReleaser archive names (`jwtd--.tar.gz`, the four darwin/linux × amd64/arm64 targets), downloads that archive plus `checksums.txt` from the latest release — or from `--version ` — extracts the binary, and installs it into `~/.local/bin` (overridable with `--dir`/`JWTD_INSTALL_DIR`). Windows is deliberately unreachable: it is served by WinGet and Scoop. +`install.sh` is a package-manager-free installer for macOS and Linux, served at `https://jwtd.sh/install.sh` (`curl -fsSL https://jwtd.sh/install.sh | sh`). It maps `uname -s`/`uname -m` onto the GoReleaser archive names (`jwtd--.tar.gz`, the four darwin/linux × amd64/arm64 targets), downloads that archive plus `checksums.txt` from the latest release — or from `--version ` — extracts the binary, and installs it into `~/.local/bin` (overridable with `--dir`/`JWTD_INSTALL_DIR`). Windows is deliberately unreachable from this script: it is served by `install.ps1`, WinGet, and Scoop. **Verification is not optional, and nothing is written before it passes.** The archive is always checked against its `checksums.txt` entry (`sha256sum`, falling back to `shasum -a 256`), and when `cosign` is on `PATH` the keyless bundle over `checksums.txt` is verified against the same certificate identity and issuer the README documents; a cosign failure aborts. cosign itself stays optional because most machines do not have it and the checksum already pins the bytes — but a present cosign is never advisory. The script runs under POSIX `sh` (it is piped into whatever `/bin/sh` the user has, not necessarily bash) and never calls `sudo`, so piping it into a shell is not a privilege decision. @@ -97,6 +97,17 @@ The binary is copied into the install directory under a temporary name and then There is one copy of the script. `.github/workflows/pages.yml` copies the repository root file into the Pages artifact (`install -m 0755 install.sh site/install.sh`, which is git-ignored) so the hosted script is byte-identical to the reviewed one, and the workflow redeploys when `install.sh` changes. `install_test.go` holds down the contract: the archive naming against `.goreleaser.yaml`, verification ordering, the Cosign identity matching the README, `sh`/no-`sudo`, rejection of unsupported platforms (driven by a stubbed `uname`, so the test never touches the network), and the README/site one-liner. +`install.ps1` is the Windows counterpart, served at `https://jwtd.sh/install.ps1` (`irm https://jwtd.sh/install.ps1 | iex`), published by the same Pages step (`install -m 0644 install.ps1 site/install.ps1`) and git-ignored the same way. It keeps install.sh's contract — verify before writing anything, checksum always, a present `cosign` never advisory, the same certificate identity and issuer, no elevation — and differs only where Windows does: + +- **It consumes the windows `.zip`, not the `.tar.gz`.** `Expand-Archive` ships with PowerShell 5.1; tar does not. The zips already exist for WinGet and are covered by the signed `checksums.txt`, so this adds no release artifact. +- **Errors `throw`, never `exit`.** The script is normally piped into `Invoke-Expression` in an interactive session, where `exit` would close the user's shell instead of aborting the installation. For the same reason `$ErrorActionPreference`/`$ProgressPreference` are set inside `Install-Jwtd` rather than at script scope: preference variables are dynamically scoped, and setting them at top level would leave them applied to the caller's session afterwards. +- **It edits the user PATH itself.** `install.sh` can only print a hint because it cannot know which shell profile to edit; Windows keeps the user PATH in one `HKCU:\Environment` value. It must be read with `DoNotExpandEnvironmentNames` and written back as `ExpandString` — `[Environment]::SetEnvironmentVariable` expands entries like `%USERPROFILE%` and writes the expanded text back as a plain string, silently rewriting parts of the PATH the installer never touched. `Publish-EnvironmentChange` broadcasts `WM_SETTINGCHANGE` (best-effort) so a newly opened terminal sees the change without a sign-out. `-NoModifyPath`/`JWTD_NO_MODIFY_PATH` opts out. +- **An upgrade renames the old binary aside.** Windows refuses to overwrite a running `.exe` but does allow renaming one, so the installer moves the installed binary to a temporary name, moves the new one into place, and then deletes the old file best-effort (the delete fails while an older `jwtd` is still running). This is the analogue of install.sh's stage-then-`rename(2)` handling of `ETXTBSY`. +- **Options come from environment variables.** `Invoke-Expression` cannot forward arguments, so `JWTD_VERSION`, `JWTD_INSTALL_DIR`, and `JWTD_NO_MODIFY_PATH` are the documented path; the `param()` block serves `& ([scriptblock]::Create((irm …))) -Version …`. +- **Architecture detection corrects for emulation.** An x64 PowerShell under emulation on an ARM64 machine reports X64, which would install the Intel binary; the machine-level `PROCESSOR_ARCHITECTURE` (and `PROCESSOR_ARCHITEW6432`) give the native architecture. This is the Rosetta check's counterpart. + +`install_test.go` asserts the shape (archive naming against `.goreleaser.yaml`, verify-before-write ordering, the shared Cosign trust root, no `exit`, no elevation, the registry handling, the publication path); comment lines are stripped before the "must not call" assertions so a comment explaining why the script avoids an API cannot satisfy the check for it. The behaviour needs a real Windows host, so the `windows-installer` job in `.github/workflows/test.yml` installs the latest published release for real, reinstalls over it to exercise the upgrade path, checks that an unavailable release writes nothing, and verifies that the PATH edit adds the directory while leaving a seeded `%USERPROFILE%`-style entry verbatim and the value still `REG_EXPAND_SZ`. + ### Open Graph card `site/og.png` is the 1200×630 social card, rendered from `og/og.html` with headless Chromium at 2x and downsampled (the 2x pass is what keeps the small monospace text crisp). The source deliberately lives **outside** `site/`: the Pages artifact is that directory verbatim, so a generator kept there would be published as a page of its own. `og/og.html` carries the exact regeneration commands in a comment, and its palette is copied from `site/styles.css` — the two must be updated together, since nothing detects a card whose colors have drifted from the site. diff --git a/README.md b/README.md index 793a375..a2162a9 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,30 @@ curl -fsSL https://jwtd.sh/install.sh | sh -s -- --dir /usr/local/bin `JWTD_VERSION` and `JWTD_INSTALL_DIR` set the same two values. Run the script with `--help` for the full list. The script is [`install.sh`](install.sh) in this repository; review it before piping it into a shell. -Windows is served by [WinGet](#winget-windows) and [Scoop](#scoop-windows) instead. +### Install script (Windows) + +```powershell +irm https://jwtd.sh/install.ps1 | iex +``` + +The same contract as the Unix installer: the release zip for the detected architecture is verified against the release's `checksums.txt` — and against the keyless Cosign signature when `cosign` is on `PATH` — before anything is written. The binary is installed into `%LOCALAPPDATA%\Programs\jwtd`, which is added to your user `PATH`; no administrator privileges are required. + +`Invoke-Expression` cannot forward arguments, so the options are environment variables: + +```powershell +$env:JWTD_VERSION = 'v5.3.0' # pin a release +$env:JWTD_INSTALL_DIR = 'C:\tools' # install somewhere else +$env:JWTD_NO_MODIFY_PATH = '1' # leave PATH alone +irm https://jwtd.sh/install.ps1 | iex +``` + +To pass parameters directly instead, create the script block explicitly: + +```powershell +& ([scriptblock]::Create((irm https://jwtd.sh/install.ps1))) -Version v5.3.0 -NoModifyPath +``` + +The script is [`install.ps1`](install.ps1) in this repository; review it before piping it into a shell. ### Homebrew (macOS and Linux) diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..1256cc4 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,361 @@ +#!/usr/bin/env pwsh +# +# jwtd installer for Windows. +# +# Downloads the release archive matching the detected architecture, verifies it +# against the release's checksums.txt (and, when cosign is installed, verifies +# the keyless signature over that checksum file), then installs the binary into +# %LOCALAPPDATA%\Programs\jwtd and adds that directory to the user PATH. No +# administrator privileges are required. +# +# irm https://jwtd.sh/install.ps1 | iex +# +# Invoke-Expression cannot forward arguments, so the environment variables +# JWTD_VERSION, JWTD_INSTALL_DIR, and JWTD_NO_MODIFY_PATH set the same values: +# +# $env:JWTD_VERSION = 'v5.3.0'; irm https://jwtd.sh/install.ps1 | iex +# +# To pass parameters directly, create the script block explicitly: +# +# & ([scriptblock]::Create((irm https://jwtd.sh/install.ps1))) -Version v5.3.0 +# +# macOS and Linux are served by install.sh; see the README. + +param( + [string]$Version, + [string]$Dir, + [switch]$NoModifyPath, + [switch]$Help +) + +$Repo = 'webcodr/jwtd' +$CertificateIdentityRegexp = '^https://github.com/webcodr/jwtd/\.github/workflows/release\.yml@' +$CertificateOidcIssuer = 'https://token.actions.githubusercontent.com' + +# The user PATH lives here. It is deliberately read and written through the +# registry rather than [Environment]::SetEnvironmentVariable: that API expands +# entries such as %USERPROFILE% and writes the expanded text back as a plain +# string, silently rewriting parts of the PATH the installer never touched. +$UserEnvironmentKey = 'HKCU:\Environment' + +function Write-Info { + param([string]$Message) + [Console]::Error.WriteLine($Message) +} + +function Write-Warn { + param([string]$Message) + [Console]::Error.WriteLine("warning: $Message") +} + +# Errors are thrown, never `exit`ed. This script is normally piped into +# Invoke-Expression in an interactive session, where `exit` would close the +# user's shell instead of aborting the installation. +function Write-Die { + param([string]$Message) + throw $Message +} + +function Show-Usage { + Write-Info @' +Install jwtd, a CLI that decodes and pretty-prints JWT, JWS, and JWE tokens. + +Usage: + install.ps1 [-Version ] [-Dir ] [-NoModifyPath] + +Parameters: + -Version Release to install (default: the latest release). + Accepts "5.3.0" or "v5.3.0". + -Dir Installation directory + (default: %LOCALAPPDATA%\Programs\jwtd). + -NoModifyPath Do not add the installation directory to the user PATH. + -Help Show this help. + +Environment: + JWTD_VERSION Same as -Version. + JWTD_INSTALL_DIR Same as -Dir. + JWTD_NO_MODIFY_PATH Same as -NoModifyPath when set to any value. + +The archive is always verified against the release's checksums.txt. When +cosign is installed, the keyless Cosign bundle over checksums.txt is verified +as well. +'@ +} + +function Test-WindowsHost { + # PowerShell 7 runs on Linux and macOS too, where install.sh is the right + # script. $IsWindows does not exist in Windows PowerShell 5.1, which only + # ever runs on Windows. + if ($PSVersionTable.PSEdition -eq 'Desktop') { + return $true + } + $flag = Get-Variable -Name 'IsWindows' -ValueOnly -ErrorAction SilentlyContinue + return [bool]$flag +} + +# Resolve-Architecture maps the OS architecture onto the GOARCH used in the +# release archive names (jwtd-windows-.zip). +function Resolve-Architecture { + $architecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() + switch ($architecture) { + 'X64' { return 'amd64' } + 'Arm64' { return 'arm64' } + default { + Write-Die "unsupported architecture: $architecture (release binaries are built for amd64 and arm64)" + } + } +} + +# An x64 PowerShell running under emulation on an ARM64 machine reports X64, +# which would install the Intel binary on ARM hardware. The machine-level +# environment key records the native architecture and is not rewritten for the +# emulated process, so it distinguishes emulation from a genuine x64 machine. +function Resolve-EmulatedArchitecture { + param([string]$Architecture) + + if ($Architecture -ne 'amd64') { + return $Architecture + } + if ($env:PROCESSOR_ARCHITEW6432 -eq 'ARM64') { + return 'arm64' + } + + $machineKey = 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment' + $native = (Get-ItemProperty -Path $machineKey -Name 'PROCESSOR_ARCHITECTURE' -ErrorAction SilentlyContinue).PROCESSOR_ARCHITECTURE + if ($native -eq 'ARM64') { + return 'arm64' + } + return $Architecture +} + +function Get-RemoteFile { + param([string]$Url, [string]$Destination) + + try { + Invoke-WebRequest -Uri $Url -OutFile $Destination -UseBasicParsing + } catch { + Write-Die "could not download $Url (check the release tag and your network connection)" + } +} + +# Test-Checksum matches the archive against its checksums.txt entry. The entry +# is selected by exact file name, so a substring match against another asset +# cannot stand in for it. +function Test-Checksum { + param([string]$Archive) + + $expected = $null + foreach ($line in Get-Content -Path 'checksums.txt') { + # " ", with the optional binary-mode asterisk sha256sum + # writes in front of the name. + if ($line -match '^\s*([0-9a-fA-F]+)\s+\*?(\S+)\s*$' -and $Matches[2] -eq $Archive) { + $expected = $Matches[1] + break + } + } + if (-not $expected) { + Write-Die "checksums.txt has no entry for $Archive" + } + + $actual = (Get-FileHash -Path $Archive -Algorithm SHA256).Hash + # Get-FileHash returns uppercase hex, checksums.txt lowercase; -ne compares + # strings case-insensitively. + if ($actual -ne $expected) { + Write-Die "checksum mismatch for $Archive; refusing to install" + } + Write-Info "Checksum verified: $Archive" +} + +# Test-Signature is best-effort by design: cosign is not a dependency most +# machines have, and the checksum above already pins the archive bytes. When +# cosign is present the bundle is verified and a failure is fatal. +function Test-Signature { + param([string]$BaseUrl) + + $cosign = Get-Command -Name 'cosign' -CommandType Application -ErrorAction SilentlyContinue | + Select-Object -First 1 + if (-not $cosign) { + Write-Info 'cosign not found - skipping signature verification (install cosign to verify the release signature)' + return + } + + Get-RemoteFile -Url "$BaseUrl/checksums.txt.sigstore.json" -Destination 'checksums.txt.sigstore.json' + & $cosign.Path verify-blob ` + --bundle 'checksums.txt.sigstore.json' ` + --certificate-identity-regexp $CertificateIdentityRegexp ` + --certificate-oidc-issuer $CertificateOidcIssuer ` + 'checksums.txt' 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { + Write-Die 'cosign could not verify checksums.txt against the jwtd release workflow; refusing to install' + } + Write-Info 'Signature verified: checksums.txt (cosign, keyless)' +} + +# Add-UserPathEntry appends the installation directory to the user PATH. Unlike +# the Unix installer, which can only print a hint because it cannot know which +# shell profile to edit, Windows keeps the user PATH in one registry value the +# installer can update itself. +function Add-UserPathEntry { + param([string]$Directory) + + $key = Get-Item -Path $UserEnvironmentKey + $current = [string]$key.GetValue( + 'Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) + $entries = @($current -split ';' | Where-Object { $_ -ne '' }) + if ($entries -contains $Directory) { + return $false + } + + Set-ItemProperty -Path $UserEnvironmentKey -Name 'Path' ` + -Value (($entries + $Directory) -join ';') -Type ExpandString + $env:PATH = "$env:PATH;$Directory" + return $true +} + +# Newly launched processes inherit their environment from Explorer, which +# rereads the registry only when this broadcast arrives. Without it a fresh +# terminal would not see the new PATH until the next sign-in. Best-effort: the +# installation is complete either way. +function Publish-EnvironmentChange { + try { + if (-not ('JwtdInstaller.NativeMethods' -as [type])) { + Add-Type -Namespace 'JwtdInstaller' -Name 'NativeMethods' -MemberDefinition @' +[System.Runtime.InteropServices.DllImport("user32.dll", SetLastError = true, CharSet = System.Runtime.InteropServices.CharSet.Auto)] +public static extern System.IntPtr SendMessageTimeout( + System.IntPtr hWnd, uint Msg, System.IntPtr wParam, string lParam, + uint fuFlags, uint uTimeout, out System.UIntPtr lpdwResult); +'@ + } + $HWND_BROADCAST = [System.IntPtr]0xffff + $WM_SETTINGCHANGE = 0x1a + $SMTO_ABORTIFHUNG = 0x2 + $result = [System.UIntPtr]::Zero + [void][JwtdInstaller.NativeMethods]::SendMessageTimeout( + $HWND_BROADCAST, $WM_SETTINGCHANGE, [System.IntPtr]::Zero, 'Environment', + $SMTO_ABORTIFHUNG, 5000, [ref]$result) + } catch { + # Nothing to do: the PATH entry is written, only its propagation to + # already-running processes is delayed. + } +} + +function Install-Jwtd { + $ErrorActionPreference = 'Stop' + # Invoke-WebRequest spends most of its time drawing the progress bar in + # Windows PowerShell 5.1. + $ProgressPreference = 'SilentlyContinue' + # PowerShell 7.4 turns a nonzero native exit code into a terminating error + # under the preference above, which would pre-empt the cosign check below + # with a less useful message. + if (Get-Variable -Name 'PSNativeCommandUseErrorActionPreference' -ErrorAction SilentlyContinue) { + $PSNativeCommandUseErrorActionPreference = $false + } + + if ($Help) { + Show-Usage + return + } + + if (-not (Test-WindowsHost)) { + Write-Die 'unsupported operating system: this script installs on Windows; on macOS and Linux use "curl -fsSL https://jwtd.sh/install.sh | sh"' + } + + $releaseVersion = if ($Version) { $Version } else { $env:JWTD_VERSION } + $installDir = if ($Dir) { $Dir } else { $env:JWTD_INSTALL_DIR } + $skipPath = $NoModifyPath.IsPresent -or [bool]$env:JWTD_NO_MODIFY_PATH + + if (-not $installDir) { + $installDir = Join-Path $env:LOCALAPPDATA 'Programs\jwtd' + } + # The download happens from a temporary working directory, so a relative + # -Dir has to be anchored to the caller's directory before that move. + if (-not [System.IO.Path]::IsPathRooted($installDir)) { + $installDir = Join-Path (Get-Location).Path $installDir + } + + $architecture = Resolve-EmulatedArchitecture -Architecture (Resolve-Architecture) + $archive = "jwtd-windows-$architecture.zip" + + if ($releaseVersion) { + if (-not $releaseVersion.StartsWith('v')) { + $releaseVersion = "v$releaseVersion" + } + $baseUrl = "https://github.com/$Repo/releases/download/$releaseVersion" + Write-Info "Installing jwtd $releaseVersion (windows/$architecture)" + } else { + $baseUrl = "https://github.com/$Repo/releases/latest/download" + Write-Info "Installing the latest jwtd release (windows/$architecture)" + } + + $workDir = Join-Path ([System.IO.Path]::GetTempPath()) "jwtd-install-$([System.IO.Path]::GetRandomFileName())" + New-Item -ItemType Directory -Path $workDir | Out-Null + $previousLocation = Get-Location + $staged = $null + try { + Set-Location -Path $workDir + + Get-RemoteFile -Url "$baseUrl/$archive" -Destination $archive + Get-RemoteFile -Url "$baseUrl/checksums.txt" -Destination 'checksums.txt' + Test-Checksum -Archive $archive + Test-Signature -BaseUrl $baseUrl + + Expand-Archive -Path $archive -DestinationPath 'extracted' -Force + $binary = Join-Path 'extracted' 'jwtd.exe' + if (-not (Test-Path -Path $binary)) { + Write-Die 'the release archive did not contain a jwtd.exe binary' + } + + if (-not (Test-Path -Path $installDir)) { + try { + New-Item -ItemType Directory -Path $installDir -Force | Out-Null + } catch { + Write-Die "could not create $installDir" + } + } + + # Windows refuses to overwrite a running .exe but does allow renaming + # one, so an upgrade moves the installed binary aside, moves the new one + # into place, and then deletes the old file. The delete fails while an + # older jwtd is still running, which is why it is best-effort: the + # upgrade itself has already taken effect. + $installed = Join-Path $installDir 'jwtd.exe' + $staged = Join-Path $installDir ".jwtd.install.$PID.exe" + $retired = Join-Path $installDir ".jwtd.old.$PID.exe" + try { + Copy-Item -Path $binary -Destination $staged -Force + } catch { + Write-Die "could not write to $installDir (choose another directory with -Dir)" + } + try { + if (Test-Path -Path $installed) { + Move-Item -Path $installed -Destination $retired -Force + } + Move-Item -Path $staged -Destination $installed -Force + $staged = $null + } catch { + Write-Die "could not install into $installDir (choose another directory with -Dir)" + } + Remove-Item -Path $retired -Force -ErrorAction SilentlyContinue + + $reported = & $installed --version 2>$null + if ($LASTEXITCODE -ne 0 -or -not $reported) { + $reported = 'jwtd' + } + Write-Info "Installed $reported to $installed" + + if ($skipPath) { + Write-Info "PATH was left unchanged. Add $installDir to it to run jwtd by name." + } elseif (Add-UserPathEntry -Directory $installDir) { + Publish-EnvironmentChange + Write-Info "Added $installDir to your user PATH. Open a new terminal to pick it up." + } + } finally { + Set-Location -Path $previousLocation + if ($staged) { + Remove-Item -Path $staged -Force -ErrorAction SilentlyContinue + } + Remove-Item -Path $workDir -Recurse -Force -ErrorAction SilentlyContinue + } +} + +Install-Jwtd diff --git a/install.sh b/install.sh index 66c453c..6c11a12 100755 --- a/install.sh +++ b/install.sh @@ -11,7 +11,7 @@ # curl -fsSL https://jwtd.sh/install.sh | sh -s -- --version v5.3.0 # curl -fsSL https://jwtd.sh/install.sh | sh -s -- --dir /usr/local/bin # -# Windows is served by Scoop and WinGet instead; see the README. +# Windows is served by install.ps1 instead; see the README. set -eu @@ -66,7 +66,7 @@ detect_os() { case "$kernel" in Linux) printf 'linux\n' ;; Darwin) printf 'darwin\n' ;; - *) die "unsupported operating system: $kernel (this script installs on Linux and macOS; on Windows use 'winget install WebCodr.jwtd' or Scoop)" ;; + *) die "unsupported operating system: $kernel (this script installs on Linux and macOS; on Windows run 'irm https://jwtd.sh/install.ps1 | iex' in PowerShell)" ;; esac } diff --git a/install_test.go b/install_test.go index 75fde98..9aa02e0 100644 --- a/install_test.go +++ b/install_test.go @@ -4,6 +4,7 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "strings" "testing" ) @@ -17,6 +18,29 @@ func readInstallScript(t *testing.T) string { return string(data) } +func readPowerShellInstallScript(t *testing.T) string { + t.Helper() + data, err := os.ReadFile("install.ps1") + if err != nil { + t.Fatalf("reading install.ps1: %v", err) + } + return string(data) +} + +// powerShellCodeLines drops comment lines so that assertions about what the +// installer must not call are not satisfied by a comment explaining why it does +// not call it. +func powerShellCodeLines(script string) []string { + var code []string + for line := range strings.SplitSeq(script, "\n") { + if strings.HasPrefix(strings.TrimSpace(line), "#") { + continue + } + code = append(code, line) + } + return code +} + // runInstallScript executes install.sh with the given arguments and an // optional directory prepended to PATH, so stubbed uname binaries can drive // the platform detection without touching the network. @@ -234,3 +258,220 @@ func TestInstallScriptPublication(t *testing.T) { } } } + +func TestPowerShellInstallScriptContract(t *testing.T) { + script := readPowerShellInstallScript(t) + + for label, required := range map[string]string{ + "parameter block": "param(", + "repository": `$Repo = 'webcodr/jwtd'`, + "default install directory": `Join-Path $env:LOCALAPPDATA 'Programs\jwtd'`, + "latest release URL": "releases/latest/download", + "pinned release URL": "releases/download/$releaseVersion", + "checksum file": "checksums.txt", + "cosign bundle": "checksums.txt.sigstore.json", + } { + if !strings.Contains(script, required) { + t.Errorf("install.ps1 is missing %s marker %q", label, required) + } + } + + // The script is piped into Invoke-Expression in an interactive session, + // where `exit` terminates the user's shell rather than the installation. + // Errors are raised with `throw` instead. + if regexp.MustCompile(`(?m)^\s*exit\b`).MatchString(script) { + t.Error("install.ps1 must not call exit: under `irm | iex` that closes the user's PowerShell session") + } + if !strings.Contains(script, "throw $Message") { + t.Error("install.ps1 must abort by throwing so the failure does not close the caller's session") + } + + // Preference variables are dynamically scoped. Setting them at the top + // level of a script that is invoked through Invoke-Expression would leave + // the user's own session with them applied afterwards. + body := script[strings.Index(script, "function Install-Jwtd"):] + if !strings.Contains(body, "$ErrorActionPreference = 'Stop'") { + t.Error("install.ps1 must set $ErrorActionPreference = 'Stop' inside Install-Jwtd, not at script scope") + } + + // The installer writes into a user-scoped directory and HKCU only. Any + // elevation would make piping it into a shell a privilege decision, and a + // machine-wide registry write would need that elevation. + for _, line := range powerShellCodeLines(script) { + for _, forbidden := range []string{"RunAs", "runas"} { + if strings.Contains(line, forbidden) { + t.Errorf("install.ps1 must never elevate, found %q in %q", forbidden, strings.TrimSpace(line)) + } + } + if strings.Contains(line, "Set-ItemProperty") && !strings.Contains(line, "$UserEnvironmentKey") { + t.Errorf("install.ps1 may only write to the user environment key, found %q", strings.TrimSpace(line)) + } + } +} + +// TestPowerShellInstallScriptTargetsReleaseArchives pins the asset naming to +// .goreleaser.yaml. The windows zips exist for WinGet; the installer consumes +// them because Expand-Archive is built in while tar.gz is not. +func TestPowerShellInstallScriptTargetsReleaseArchives(t *testing.T) { + config, err := os.ReadFile(".goreleaser.yaml") + if err != nil { + t.Fatalf("reading .goreleaser.yaml: %v", err) + } + if !strings.Contains(string(config), "id: jwtd-zip") { + t.Fatal(".goreleaser.yaml must publish the windows zip archives install.ps1 downloads") + } + if !strings.Contains(string(config), `name_template: "jwtd-{{ .Os }}-{{ .Arch }}"`) { + t.Fatal(".goreleaser.yaml archive name template changed; install.ps1 builds asset names from it") + } + + script := readPowerShellInstallScript(t) + if !strings.Contains(script, `$archive = "jwtd-windows-$architecture.zip"`) { + t.Error("install.ps1 must request jwtd-windows-.zip, matching the GoReleaser archive names") + } + for _, mapping := range []string{"'X64' { return 'amd64' }", "'Arm64' { return 'arm64' }"} { + if !strings.Contains(script, mapping) { + t.Errorf("install.ps1 is missing the architecture mapping %q", mapping) + } + } + if !strings.Contains(script, "unsupported architecture:") { + t.Error("install.ps1 must reject architectures with no release binary") + } + // PowerShell 7 also runs on macOS and Linux, which install.sh serves. + if !strings.Contains(script, "unsupported operating system:") { + t.Error("install.ps1 must reject non-Windows hosts and point at install.sh") + } +} + +// TestPowerShellInstallScriptVerifiesBeforeInstalling is the Windows half of +// TestInstallScriptVerifiesBeforeInstalling: nothing reaches the installation +// directory before the archive matches checksums.txt, and both installers trust +// exactly the same signing identity. +func TestPowerShellInstallScriptVerifiesBeforeInstalling(t *testing.T) { + script := readPowerShellInstallScript(t) + + verify := strings.Index(script, "Test-Checksum -Archive $archive") + if verify < 0 { + t.Fatal("install.ps1 must verify the downloaded archive against checksums.txt") + } + extract := strings.Index(script, "Expand-Archive -Path $archive") + if extract < 0 { + t.Fatal("install.ps1 must extract the binary from the release archive") + } + stage := strings.Index(script, "Copy-Item -Path $binary -Destination $staged") + if stage < 0 { + t.Fatal("install.ps1 must stage the binary inside the install directory before renaming it into place") + } + if verify > extract || verify > stage { + t.Error("install.ps1 must verify the checksum before extracting or installing the binary") + } + + shell := readInstallScript(t) + readme, err := os.ReadFile("README.md") + if err != nil { + t.Fatalf("reading README.md: %v", err) + } + for label, identity := range map[string]string{ + "certificate identity": `^https://github.com/webcodr/jwtd/\.github/workflows/release\.yml@`, + "OIDC issuer": "https://token.actions.githubusercontent.com", + } { + if !strings.Contains(script, identity) { + t.Errorf("install.ps1 Cosign %s must be %q", label, identity) + } + if !strings.Contains(shell, identity) || !strings.Contains(string(readme), identity) { + t.Errorf("install.sh and README.md Cosign %s must stay %q so every installer documents one trust root", label, identity) + } + } + + if !strings.Contains(script, "Write-Die 'cosign could not verify checksums.txt") { + t.Error("install.ps1 must abort when cosign is installed and verification fails") + } +} + +// TestPowerShellInstallScriptUpgradesRunningBinary covers the one thing the +// Unix installer does not have to handle: Windows refuses to overwrite a +// running .exe, so an upgrade renames the old binary aside first. +func TestPowerShellInstallScriptUpgradesRunningBinary(t *testing.T) { + script := readPowerShellInstallScript(t) + + retire := strings.Index(script, "Move-Item -Path $installed -Destination $retired") + if retire < 0 { + t.Fatal("install.ps1 must move an existing jwtd.exe aside; Windows cannot overwrite a running binary") + } + install := strings.Index(script, "Move-Item -Path $staged -Destination $installed") + if install < 0 { + t.Fatal("install.ps1 must move the staged binary into place") + } + if retire > install { + t.Error("install.ps1 must retire the old binary before moving the new one into place") + } + if !strings.Contains(script, "Remove-Item -Path $retired -Force -ErrorAction SilentlyContinue") { + t.Error("deleting the retired binary must be best-effort: it fails while an older jwtd is still running") + } +} + +// TestPowerShellInstallScriptEditsUserPathSafely holds down the registry +// handling. [Environment]::SetEnvironmentVariable expands %USERPROFILE%-style +// entries and writes the expanded text back as a plain string, corrupting parts +// of the PATH the installer never touched. +func TestPowerShellInstallScriptEditsUserPathSafely(t *testing.T) { + script := readPowerShellInstallScript(t) + + for _, line := range powerShellCodeLines(script) { + if strings.Contains(line, "SetEnvironmentVariable") { + t.Errorf("install.ps1 must not use [Environment]::SetEnvironmentVariable for PATH - it expands and rewrites unrelated entries - found %q", strings.TrimSpace(line)) + } + } + for label, required := range map[string]string{ + "user environment key": `$UserEnvironmentKey = 'HKCU:\Environment'`, + "unexpanded read": "[Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames", + "expandable write": "-Type ExpandString", + "opt-out": "$skipPath", + } { + if !strings.Contains(script, required) { + t.Errorf("install.ps1 PATH handling is missing the %s marker %q", label, required) + } + } +} + +// TestPowerShellInstallScriptPublication covers the delivery path, mirroring +// TestInstallScriptPublication. +func TestPowerShellInstallScriptPublication(t *testing.T) { + pages, err := os.ReadFile(filepath.Join(".github", "workflows", "pages.yml")) + if err != nil { + t.Fatalf("reading Pages workflow: %v", err) + } + if !strings.Contains(string(pages), "install -m 0644 install.ps1 site/install.ps1") { + t.Error("Pages workflow must copy install.ps1 into the site artifact so jwtd.sh/install.ps1 serves it") + } + if !strings.Contains(string(pages), `- "install.ps1"`) { + t.Error("Pages workflow must redeploy when install.ps1 changes") + } + + const oneLiner = "irm https://jwtd.sh/install.ps1 | iex" + readme, err := os.ReadFile("README.md") + if err != nil { + t.Fatalf("reading README.md: %v", err) + } + if !strings.Contains(string(readme), oneLiner) { + t.Errorf("README.md must document %q", oneLiner) + } + + index, err := os.ReadFile(filepath.Join("site", "index.html")) + if err != nil { + t.Fatalf("reading site/index.html: %v", err) + } + block := `` + oneLiner + `` + if !strings.Contains(string(index), block) { + t.Errorf("site/index.html must offer the install script in the Windows panel: %q", block) + } + + // The copy lives in the Pages artifact only; a committed one would be a + // second source of truth that could drift from the reviewed script. + ignore, err := os.ReadFile(".gitignore") + if err != nil { + t.Fatalf("reading .gitignore: %v", err) + } + if !strings.Contains(string(ignore), "site/install.ps1") { + t.Error(".gitignore must exclude site/install.ps1; it is generated by the Pages workflow") + } +} diff --git a/site/index.html b/site/index.html index 63600a0..8bd89a9 100644 --- a/site/index.html +++ b/site/index.html @@ -514,18 +514,21 @@

Packages for every distribution

>

Windows

-

WinGet or Scoop

+

Install script, Scoop, or WinGet

- Install with the built-in Windows Package Manager, or add the - webcodr Scoop bucket. + Install the current release without a package manager, or add the + webcodr Scoop bucket. x64 and ARM64 are both covered.