lightjunction 0.2.2

An interactive introduction to LIghtJUNction with public keys, age/OpenPGP encryption, and command timings
#Requires -Version 5.1
<#
.SYNOPSIS
Install a checksum-verified official lightjunction binary on Windows.
.DESCRIPTION
Defaults to the latest stable cli-v release and LOCALAPPDATA/Programs/lightjunction/bin.
LIGHTJUNCTION_VERSION and LIGHTJUNCTION_INSTALL_DIR supply optional defaults.
The script does not request administrator access or modify persistent PATH settings.
#>
[CmdletBinding()]
param(
    [string] $Version = $env:LIGHTJUNCTION_VERSION,
    [string] $InstallDir = $env:LIGHTJUNCTION_INSTALL_DIR
)

$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
$TemporaryDirectory = $null
$StagingDirectory = $null
$OriginalTls = [Net.ServicePointManager]::SecurityProtocol

function Get-ReleaseFile {
    param([Uri] $Uri, [string] $Destination)
    for ($Redirect = 0; $Redirect -le 5; $Redirect++) {
        if ($Uri.Scheme -cne 'https') { throw 'Refusing a non-HTTPS download or redirect.' }
        $Response = $null
        try {
            # PS 5.1 returns a response alongside a non-terminating redirect error;
            # PS 7 throws. Handle both, and validate every redirect before following it.
            $Response = Invoke-WebRequest -Uri $Uri -UseBasicParsing -MaximumRedirection 0 -TimeoutSec 180 `
                -Headers @{ 'User-Agent' = 'lightjunction-installer' } -ErrorAction SilentlyContinue
        } catch {
            $Response = $_.Exception.Response
            if ($null -eq $Response) { throw }
        }
        if ($null -eq $Response) { throw "Download failed: $Uri" }
        $Status = [int] $Response.StatusCode
        if ($Status -eq 200) {
            # Windows PowerShell 5.1's -OutFile/-PassThru combination can write an
            # empty file. Copy the raw response bytes instead, never decoded text.
            $Stream = $Response.RawContentStream
            if ($null -eq $Stream) { throw "Download response has no byte stream: $Uri" }
            try {
                if ($Stream.CanSeek) { $Stream.Position = 0 }
                $File = [IO.File]::Open($Destination, [IO.FileMode]::Create, [IO.FileAccess]::Write)
                try { $Stream.CopyTo($File) }
                finally { $File.Dispose() }
            } finally { $Stream.Dispose() }
            return
        }
        if ($Status -notin @(301, 302, 303, 307, 308)) {
            throw "Download failed with HTTP ${Status}: $Uri"
        }
        $Location = $Response.Headers.Location
        if ($null -eq $Location) { throw "Redirect has no Location header: $Uri" }
        $Uri = [Uri]::new($Uri, [string] $Location)
    }
    throw 'Too many HTTPS redirects.'
}

function Assert-RegularDestination {
    param([string] $Path)
    $Item = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue
    if ($null -ne $Item -and (
        $Item.PSIsContainer -or ($Item.Attributes -band [IO.FileAttributes]::ReparsePoint)
    )) {
        throw "Refusing a directory, symbolic link, or other reparse point: $Path"
    }
}

try {
    if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) {
        throw 'install.ps1 supports Windows only; use install.sh on Linux or macOS.'
    }
    if ($Version -and $Version -cnotmatch '\A(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\z') {
        throw 'Version must be MAJOR.MINOR.PATCH.'
    }
    if (-not $InstallDir) {
        if (-not $env:LOCALAPPDATA) { throw 'LOCALAPPDATA is not set; specify -InstallDir.' }
        $InstallDir = Join-Path $env:LOCALAPPDATA 'Programs/lightjunction/bin'
    }
    $InstallDir = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($InstallDir)
    $Destination = Join-Path $InstallDir 'lightjunction.exe'
    Assert-RegularDestination $Destination

    # Environment variables and process architecture can report x64 under ARM64 emulation.
    # IsWow64Process2 returns the actual host architecture on modern Windows.
    if (-not ('LightjunctionInstallerNative' -as [type])) {
        Add-Type -TypeDefinition @'
using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
public static class LightjunctionInstallerNative {
    [DllImport("kernel32.dll", SetLastError = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    private static extern bool IsWow64Process2(IntPtr process, out ushort guest, out ushort host);
    [DllImport("kernel32.dll")]
    private static extern IntPtr GetCurrentProcess();
    public static string Architecture() {
        try {
            ushort guest, host;
            if (!IsWow64Process2(GetCurrentProcess(), out guest, out host))
                throw new Win32Exception(Marshal.GetLastWin32Error());
            if (host == 0x8664) return "AMD64";
            if (host == 0xaa64) return "ARM64";
            return "unsupported (machine " + host.ToString("x4") + ")";
        } catch (EntryPointNotFoundException) {
            // Pre-Windows 10 systems have no ARM64/x64 emulation ambiguity.
            return Environment.GetEnvironmentVariable("PROCESSOR_ARCHITEW6432")
                ?? Environment.GetEnvironmentVariable("PROCESSOR_ARCHITECTURE");
        }
    }
}
'@
    }
    $Architecture = [LightjunctionInstallerNative]::Architecture()
    switch ($Architecture) {
        'AMD64' { $Target = 'x86_64-pc-windows-msvc' }
        'ARM64' { $Target = 'aarch64-pc-windows-msvc' }
        default { throw "Unsupported CPU architecture: $Architecture" }
    }

    [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
    $TemporaryDirectory = Join-Path ([IO.Path]::GetTempPath()) ('lightjunction-install-' + [Guid]::NewGuid())
    $null = [IO.Directory]::CreateDirectory($TemporaryDirectory)
    if (-not $Version) {
        $LatestFile = Join-Path $TemporaryDirectory 'latest.json'
        Get-ReleaseFile 'https://api.github.com/repos/LIghtJUNction/lightjunction/releases/latest' $LatestFile
        $Latest = Get-Content -LiteralPath $LatestFile -Raw | ConvertFrom-Json
        if ($Latest.tag_name -cnotmatch '\Acli-v((0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*))\z' `
            -or $Latest.prerelease -ne $false -or $Latest.draft -ne $false) {
            throw 'Latest release is not a stable cli-v release; specify -Version.'
        }
        $Version = $Latest.tag_name.Substring(5)
    }
    $Asset = "lightjunction-v$Version-$Target.zip"
    $BaseUrl = "https://github.com/LIghtJUNction/lightjunction/releases/download/cli-v$Version"
    $Archive = Join-Path $TemporaryDirectory $Asset
    $Checksums = Join-Path $TemporaryDirectory 'SHA256SUMS'
    Get-ReleaseFile "$BaseUrl/$Asset" $Archive
    Get-ReleaseFile "$BaseUrl/SHA256SUMS" $Checksums

    $Expected = @()
    foreach ($Line in [IO.File]::ReadAllLines($Checksums)) {
        if ($Line -cnotmatch '\A[0-9a-fA-F]{64}  [A-Za-z0-9][A-Za-z0-9._-]*\z') {
            throw 'SHA256SUMS contains a malformed checksum line.'
        }
        if ($Line.Substring(66) -ceq $Asset) { $Expected += $Line.Substring(0, 64) }
    }
    if ($Expected.Count -ne 1) { throw 'SHA256SUMS must contain exactly one checksum for the archive.' }
    $Actual = (Get-FileHash -LiteralPath $Archive -Algorithm SHA256).Hash
    if ($Actual -ine $Expected[0]) { throw 'SHA-256 mismatch; existing installation was not changed.' }

    Add-Type -AssemblyName System.IO.Compression.FileSystem
    $Zip = [IO.Compression.ZipFile]::OpenRead($Archive)
    try {
        $Names = @($Zip.Entries | ForEach-Object { $_.FullName })
        if ($Names.Count -ne 3 -or @($Names | Sort-Object -Unique).Count -ne 3 `
            -or @($Names | Where-Object { $_ -cnotin @('lightjunction.exe', 'LICENSE', 'README.md') }).Count) {
            throw 'Archive contains unexpected or duplicate paths.'
        }
        $Entry = $Zip.GetEntry('lightjunction.exe')
        $UnixType = ($Entry.ExternalAttributes -shr 16) -band 0xf000
        if ($Entry.Length -le 0 -or $UnixType -notin @(0, 0x8000)) {
            throw 'Archive binary is not a nonempty regular file.'
        }
        $null = [IO.Directory]::CreateDirectory($InstallDir)
        $StagingDirectory = Join-Path $InstallDir ('.lightjunction-install-' + [Guid]::NewGuid())
        $null = [IO.Directory]::CreateDirectory($StagingDirectory)
        $StagedBinary = Join-Path $StagingDirectory 'lightjunction.exe'
        $InputStream = $Entry.Open()
        try {
            $OutputStream = [IO.File]::Open($StagedBinary, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write)
            try { $InputStream.CopyTo($OutputStream); $OutputStream.Flush($true) }
            finally { $OutputStream.Dispose() }
        } finally { $InputStream.Dispose() }
    } finally { $Zip.Dispose() }

    Assert-RegularDestination $Destination
    if ([IO.File]::Exists($Destination)) {
        # Same-volume replacement preserves the previous binary if replacement fails.
        # A plain $null is coerced to an empty backup filename by PowerShell.
        [IO.File]::Replace($StagedBinary, $Destination, [NullString]::Value)
    } else {
        [IO.File]::Move($StagedBinary, $Destination)
    }
    Write-Output "Installed lightjunction $Version to $Destination"
    if (($env:PATH -split ';') -notcontains $InstallDir) {
        Write-Output "Add $InstallDir to PATH to use lightjunction from any directory."
    }
} catch {
    Write-Error "lightjunction installer: $($_.Exception.Message)" -ErrorAction Continue
    exit 1
} finally {
    [Net.ServicePointManager]::SecurityProtocol = $OriginalTls
    if ($StagingDirectory -and [IO.Directory]::Exists($StagingDirectory)) {
        [IO.Directory]::Delete($StagingDirectory, $true)
    }
    if ($TemporaryDirectory -and [IO.Directory]::Exists($TemporaryDirectory)) {
        [IO.Directory]::Delete($TemporaryDirectory, $true)
    }
}