#!/bin/sh # Prism user-local installer. No shell profiles or endpoint credentials are read. # Keep execution behind the final call so incomplete piped downloads cannot # begin installing while the shell is still receiving this function body. main() { set -eu LC_ALL=C export LC_ALL umask 077 fail() { printf 'prism install: %s\n' "$*" >&2; exit 1; } usage() { cat <<'HELP' Install Prism for macOS 13+ or Linux (arm64 / x86_64), without sudo. sh install.sh [--base-url HTTPS_URL] [--bin-dir DIRECTORY] [--signing-key ED25519_PUBLIC_KEY_FILE] sh install.sh --help Defaults: PRISM_RELEASE_BASE or https://www.prismux.dev/releases/stable PRISM_BIN_DIR or $HOME/.local/bin Release metadata requires an Ed25519 signature from the pinned release key, verified with ssh-keygen (OpenSSH 8.2+) before the binary is downloaded. Signed issue and expiry times are checked against this machine's clock. An older version cannot replace an installed Prism that reports its version. The binary's size and SHA-256 are then checked before its --version probe and atomic install. --signing-key explicitly trusts a different OpenSSH Ed25519 public key for this install, including a custom --base-url. There is no unsigned fallback. The previous executable is saved as prism.previous. Shell profiles are never changed automatically. Windows users: run install.ps1 to install inside an existing WSL distro. HELP } base=${PRISM_RELEASE_BASE:-https://www.prismux.dev/releases/stable} bin_dir=${PRISM_BIN_DIR:-${HOME:?HOME is not set}/.local/bin} # Trust never comes from a key downloaded beside the release. official_signing_key='ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEh9ojDbtU6OkfvPHFjTrDIHzHS/T8/yeWYqW+QOz7e1' signing_key_file='' while [ "$#" -gt 0 ]; do case $1 in --help|-h) usage; exit 0 ;; --base-url|--bin-dir|--signing-key) [ "$#" -ge 2 ] || fail "$1 needs a value" case $1 in --base-url) base=$2 ;; --bin-dir) bin_dir=$2 ;; --signing-key) signing_key_file=$2 ;; esac shift 2 ;; *) fail "unknown option: $1 (see --help)" ;; esac done # Deliberately narrow URL grammar: no credentials, query, fragment, escapes, # shell syntax, or ambiguous dot-segments. Redirects must also remain HTTPS. case $base in https://*) ;; *) fail 'release base must use https://' ;; esac case $base in *[!A-Za-z0-9.:/_-]*) fail 'release base contains unsupported characters' ;; esac base=${base%/} authority=${base#https://} authority=${authority%%/*} case $authority in ''|.*|*..*|*[!A-Za-z0-9.:-]*) fail 'invalid release host' ;; esac case "$base/" in */../*|*/./*) fail 'release base cannot contain dot-segments' ;; esac [ -n "$bin_dir" ] || fail 'bin directory cannot be empty' carriage_return=$(printf '\r') case $bin_dir in *' '*|*"$carriage_return"*) fail 'bin directory cannot contain line breaks' ;; esac case $signing_key_file in *' '*|*"$carriage_return"*) fail 'signing key path cannot contain line breaks' ;; esac case $(uname -s) in Darwin) os=macos if command -v sw_vers >/dev/null 2>&1; then macos_version=$(sw_vers -productVersion) || fail 'could not determine macOS version (Prism requires macOS 13 or newer)' macos_major=${macos_version%%.*} case $macos_major in ''|*[!0-9]*) fail 'could not determine macOS version (Prism requires macOS 13 or newer)' ;; esac [ "${#macos_major}" -le 4 ] && [ "$macos_major" -ge 13 ] || fail 'Prism requires macOS 13 or newer; this Mac is older' fi ;; Linux) os=linux ;; *) fail 'supported systems are macOS and Linux; on Windows use install.ps1 for WSL' ;; esac case $(uname -m) in arm64|aarch64) arch=aarch64 ;; x86_64|amd64) arch=x86_64 ;; *) fail 'supported architectures are arm64 and x86_64' ;; esac target=$arch-$os 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 hasher=sha256sum elif command -v shasum >/dev/null 2>&1; then hasher=shasum else fail 'sha256sum or shasum is required'; fi for tool in sh mktemp cmp wc tr sed awk chmod cp mv mkdir rmdir rm sleep date; do command -v "$tool" >/dev/null 2>&1 || fail "required command is unavailable: $tool" done command -v ssh-keygen >/dev/null 2>&1 || fail 'ssh-keygen with -Y verify is required (OpenSSH 8.2+); install your operating system OpenSSH client package and retry' verifier_help=$(ssh-keygen -? 2>&1 || :) case $verifier_help in *'-Y verify'*) ;; *) fail 'this ssh-keygen does not support -Y verify; OpenSSH 8.2+ is required, and no unsigned fallback is permitted' ;; esac if [ -n "$signing_key_file" ]; then [ -f "$signing_key_file" ] || fail 'the explicit signing key must be a regular OpenSSH Ed25519 public key file' [ "$(wc -c < "$signing_key_file" | tr -d ' ')" -le 4096 ] || fail 'signing key file is too large' else [ "$official_signing_key" != UNCONFIGURED ] || fail 'this installer has no official release key configured; obtain the published installer or explicitly provide --signing-key' fi mkdir -p "$bin_dir" || fail "cannot create $bin_dir" bin_dir=$(CDPATH='' cd -P "$bin_dir" && pwd) || fail 'cannot access bin directory' lock=$bin_dir/.prism-install.lock mkdir "$lock" 2>/dev/null || fail "another install may be running; if it stopped, remove $lock" stage='' worker_pid='' timer_pid='' cleanup() { if [ -n "$worker_pid" ]; then kill "$worker_pid" 2>/dev/null || :; wait "$worker_pid" 2>/dev/null || :; fi if [ -n "$timer_pid" ]; then kill "$timer_pid" 2>/dev/null || :; wait "$timer_pid" 2>/dev/null || :; fi if [ -n "$stage" ]; then rm -rf "$stage"; fi rmdir "$lock" 2>/dev/null || : } trap cleanup EXIT trap 'exit 130' INT trap 'exit 143' TERM HUP stage=$(mktemp -d "$bin_dir/.prism-install.XXXXXXXX") || fail 'cannot stage download beside the installed executable' if [ -n "$signing_key_file" ]; then IFS= read -r signing_line < "$signing_key_file" || fail 'signing key must contain one LF-terminated OpenSSH public key' printf '%s\n' "$signing_line" > "$stage/key.input" cmp -s "$signing_key_file" "$stage/key.input" || fail 'signing key must contain exactly one LF-terminated OpenSSH public key' else signing_line=$official_signing_key fi IFS=' ' read -r signing_type signing_blob signing_comment < "$stage/signing.pub" ssh-keygen -l -f "$stage/signing.pub" >/dev/null 2>&1 || fail 'invalid Ed25519 public key' printf 'prismux-release namespaces="prism-install-v2" ssh-ed25519 %s\n' "$signing_blob" > "$stage/allowed_signers" # Decimal comparisons never coerce an unsigned 64-bit sequence into the shell's # signed integer arithmetic. Only validated, near-current clock values are # subsequently used in arithmetic. decimal_le() { compare_left=$1 compare_right=$2 [ "${#compare_left}" -lt "${#compare_right}" ] && return 0 [ "${#compare_left}" -gt "${#compare_right}" ] && return 1 while [ -n "$compare_left" ]; do compare_left_tail=${compare_left#?} compare_right_tail=${compare_right#?} compare_left_digit=${compare_left%"$compare_left_tail"} compare_right_digit=${compare_right%"$compare_right_tail"} [ "$compare_left_digit" -lt "$compare_right_digit" ] && return 0 [ "$compare_left_digit" -gt "$compare_right_digit" ] && return 1 compare_left=$compare_left_tail compare_right=$compare_right_tail done return 0 } uint64() { case $1 in ''|*[!0-9]*|0?*) return 1 ;; esac [ "${#1}" -le 20 ] && decimal_le "$1" 18446744073709551615 } # Compare SemVer precedence without floating-point or machine-integer loss. # Return 0 when the candidate is at least the installed version, 1 for a # downgrade, and 2 when either version cannot be compared safely. version_not_older() { awk -v candidate="$1" -v installed="$2" ' function decimal_compare(a, b) { if (length(a) != length(b)) return length(a) < length(b) ? -1 : 1 return ("x" a == "x" b) ? 0 : (("x" a < "x" b) ? -1 : 1) } function parse(v, result, build, dash, count, pieces, i, pre) { if (length(v) > 64) return 0 build = index(v, "+") if (build) { if (substr(v, build + 1) !~ /^[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*$/) return 0 v = substr(v, 1, build - 1) } dash = index(v, "-") if (dash) { pre = substr(v, dash + 1) if (pre !~ /^[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*$/) return 0 count = split(pre, pieces, ".") for (i = 1; i <= count; i++) if (pieces[i] ~ /^0[0-9]+$/) return 0 v = substr(v, 1, dash - 1) } if (split(v, pieces, ".") != 3) return 0 for (i = 1; i <= 3; i++) { if (pieces[i] !~ /^(0|[1-9][0-9]*)$/) return 0 result[i] = pieces[i] } result[4] = pre return 1 } BEGIN { if (!parse(candidate, a) || !parse(installed, b)) exit 2 for (i = 1; i <= 3; i++) { order = decimal_compare(a[i], b[i]) if (order) exit order < 0 ? 1 : 0 } if ("x" a[4] == "x" b[4]) exit 0 if (a[4] == "") exit 0 if (b[4] == "") exit 1 na = split(a[4], pa, ".") nb = split(b[4], pb, ".") for (i = 1; i <= na && i <= nb; i++) { an = pa[i] ~ /^[0-9]+$/ bn = pb[i] ~ /^[0-9]+$/ if (an && bn) order = decimal_compare(pa[i], pb[i]) else if (an != bn) order = an ? -1 : 1 else order = ("x" pa[i] == "x" pb[i]) ? 0 : (("x" pa[i] < "x" pb[i]) ? -1 : 1) if (order) exit order < 0 ? 1 : 0 } exit na < nb ? 1 : 0 }' /dev/null || :; wait "$sleeper" 2>/dev/null || :; fi; exit 0' TERM INT HUP sleep "$seconds" & sleeper=$! wait "$sleeper" || exit 0 kill "$worker_pid" 2>/dev/null || exit 0 sleep 1 & sleeper=$! wait "$sleeper" || exit 0 kill -KILL "$worker_pid" 2>/dev/null || : ) & timer_pid=$! result=0 wait "$worker_pid" || result=$? worker_pid='' kill "$timer_pid" 2>/dev/null || : wait "$timer_pid" 2>/dev/null || : timer_pid='' return "$result" } fetch() { url=$1 output=$2 limit=$3 if [ "$downloader" = curl ]; then run_bounded 125 curl -q --fail --silent --show-error --location \ --proto '=https' --proto-redir '=https' --connect-timeout 10 \ --max-time 120 --retry 2 --retry-delay 1 --max-filesize "$limit" \ --output "$output" --url "$url" else # wget has no portable total-transfer size cap. A child-only file-size # limit bounds disk use; the exact release size is checked below. blocks=$(( (limit + 511) / 512 )) # shellcheck disable=SC2016 # Expansion is intentionally inside the child. run_bounded 125 sh -c 'ulimit -f "$1"; shift; exec wget --no-config --no-hsts --https-only --max-redirect=0 --timeout=20 --tries=2 --quiet --output-document "$1" "$2"' sh "$blocks" "$output" "$url" fi } manifest=$stage/install-v2.manifest printf 'Checking Prism release for %s…\n' "$target" fetch "$base/$target/install-v2.manifest" "$manifest" 4096 || fail 'could not download signed release metadata (existing Prism is unchanged)' [ "$(wc -c < "$manifest" | tr -d ' ')" -le 4096 ] || fail 'release metadata is too large' exec 3< "$manifest" IFS= read -r schema_line <&3 || fail 'incomplete release metadata' IFS= read -r version_line <&3 || fail 'incomplete release metadata' IFS= read -r target_line <&3 || fail 'incomplete release metadata' IFS= read -r file_line <&3 || fail 'incomplete release metadata' IFS= read -r size_line <&3 || fail 'incomplete release metadata' IFS= read -r hash_line <&3 || fail 'incomplete release metadata' IFS= read -r issued_line <&3 || fail 'incomplete release metadata' IFS= read -r expires_line <&3 || fail 'incomplete release metadata' IFS= read -r sequence_line <&3 || fail 'incomplete release metadata' exec 3<&- [ "$schema_line" = schema=prism-install-v2 ] || fail 'unsupported release schema; signed v2 metadata is required' case $version_line in version=*) version=${version_line#version=} ;; *) fail 'missing release version' ;; esac case $version in ''|[!A-Za-z0-9]*|*[!A-Za-z0-9._+-]*) fail 'invalid release version' ;; esac [ "${#version}" -le 64 ] || fail 'release version is too long' version_not_older "$version" "$version" || fail 'release version must be valid SemVer for downgrade protection' [ "$target_line" = "target=$target" ] || fail 'release target does not match this machine' case $file_line in file=*) artifact=${file_line#file=} ;; *) fail 'missing release filename' ;; esac case $artifact in ''|[!A-Za-z0-9]*|*[!A-Za-z0-9._+-]*) fail 'invalid release filename' ;; esac [ "${#artifact}" -le 128 ] || fail 'release filename is too long' case $size_line in size=*) size=${size_line#size=} ;; *) fail 'missing release size' ;; esac case $size in ''|0*|*[!0-9]*) fail 'invalid release size' ;; esac [ "${#size}" -le 9 ] && [ "$size" -le 536870912 ] || fail 'release exceeds the 512 MiB installer bound' case $hash_line in sha256=*) digest=${hash_line#sha256=} ;; *) fail 'missing release digest' ;; esac case $digest in *[!0-9a-f]*) fail 'invalid SHA-256 digest' ;; esac [ "${#digest}" -eq 64 ] || fail 'invalid SHA-256 digest' case $issued_line in issued_at=*) issued_at=${issued_line#issued_at=} ;; *) fail 'missing release issue time' ;; esac case $expires_line in expires_at=*) expires_at=${expires_line#expires_at=} ;; *) fail 'missing release expiry time' ;; esac case $sequence_line in sequence=*) sequence=${sequence_line#sequence=} ;; *) fail 'missing release sequence' ;; esac uint64 "$issued_at" || fail 'invalid release issue time' uint64 "$expires_at" || fail 'invalid release expiry time' uint64 "$sequence" && [ "$sequence" != 0 ] || fail 'release sequence must be a positive unsigned 64-bit integer' printf 'schema=prism-install-v2\nversion=%s\ntarget=%s\nfile=%s\nsize=%s\nsha256=%s\nissued_at=%s\nexpires_at=%s\nsequence=%s\n' "$version" "$target" "$artifact" "$size" "$digest" "$issued_at" "$expires_at" "$sequence" > "$stage/canonical.manifest" cmp -s "$manifest" "$stage/canonical.manifest" || fail 'release metadata must contain exactly nine canonical LF-terminated fields' signature=$stage/install-v2.manifest.sig fetch "$base/$target/install-v2.manifest.sig" "$signature" 4096 || fail 'could not download the release signature; no unsigned fallback is permitted' [ "$(wc -c < "$signature" | tr -d ' ')" -le 4096 ] || fail 'release signature is too large' run_bounded 10 sh -c 'exec ssh-keygen -Y verify -f "$1" -I prismux-release -n prism-install-v2 -s "$2" < "$3"' sh "$stage/allowed_signers" "$signature" "$manifest" > "$stage/signature.out" 2> "$stage/signature.err" || fail 'release signature verification failed; existing Prism is unchanged (ssh-keygen with -Y verify is required)' # These fields become trusted only after verification. Reject replayed expired # metadata, clocks implausibly before issuance, and excessive signed lifetimes. now=$(date +%s) || fail 'could not read the local clock for release freshness' uint64 "$now" && [ "${#now}" -le 10 ] || fail 'local clock is outside the supported release freshness range' decimal_le "$issued_at" "$((now + 300))" || fail 'release issue time is in the future; check the local clock' decimal_le "$expires_at" "$now" && fail 'release metadata has expired; existing Prism is unchanged' decimal_le "$expires_at" "$issued_at" && fail 'release expiry must be after its issue time' decimal_le "$expires_at" "$((issued_at + 7776000))" || fail 'release validity exceeds the 90-day maximum' # Probe only the existing, regular executable here. The downloaded candidate # is not fetched or run until its signed metadata and freshness pass. A broken # existing executable may be repaired, but cannot supply a downgrade floor. check_destination_paths if [ -f "$bin_dir/prism" ]; then installed_version='' if [ -x "$bin_dir/prism" ] && run_bounded 3 "$bin_dir/prism" --version > "$stage/installed-version.out" 2> "$stage/installed-version.err"; then IFS= read -r installed_line < "$stage/installed-version.out" || installed_line='' case $installed_line in 'prism '*) installed_version=${installed_line#prism } ;; esac printf 'prism %s\n' "$installed_version" > "$stage/installed-version.expected" cmp -s "$stage/installed-version.out" "$stage/installed-version.expected" || installed_version='' fi version_order=0 version_not_older "$version" "$installed_version" || version_order=$? case $version_order in 0) ;; 1) fail "refusing to downgrade installed Prism $installed_version to $version" ;; *) printf 'prism install: existing Prism did not report a comparable version; continuing with the verified release\n' >&2 ;; esac fi printf 'Downloading Prism %s…\n' "$version" fetch "$base/$target/$artifact" "$stage/prism" "$size" || fail 'download failed (existing Prism is unchanged)' actual_size=$(wc -c < "$stage/prism" | tr -d ' ') [ "$actual_size" = "$size" ] || fail 'download size does not match the release; existing Prism is unchanged' if [ "$hasher" = sha256sum ]; then hash_output=$(sha256sum < "$stage/prism") else hash_output=$(shasum -a 256 < "$stage/prism"); fi actual_hash=${hash_output%% *} [ "$actual_hash" = "$digest" ] || fail 'SHA-256 mismatch; existing Prism is unchanged' chmod 755 "$stage/prism" run_bounded 10 "$stage/prism" --version > "$stage/version.out" 2> "$stage/version.err" || fail 'downloaded executable failed its --version check; existing Prism is unchanged' printf 'prism %s\n' "$version" > "$stage/expected-version.out" cmp -s "$stage/version.out" "$stage/expected-version.out" || fail 'downloaded executable reports a different version; existing Prism is unchanged' check_destination_paths if [ -f "$bin_dir/prism" ]; then cp -p "$bin_dir/prism" "$stage/prism.previous" || fail 'could not preserve the previous executable' mv -f "$stage/prism.previous" "$bin_dir/prism.previous" || fail 'could not publish the previous executable backup' fi mv -f "$stage/prism" "$bin_dir/prism" || fail 'could not atomically install Prism; the previous executable is unchanged' printf '\nInstalled Prism %s at %s/prism\n' "$version" "$bin_dir" [ ! -f "$bin_dir/prism.previous" ] || printf 'Previous executable: %s/prism.previous\n' "$bin_dir" case :${PATH:-}: in *:"$bin_dir":*) printf 'Run: prism\n' ;; *) # Quote arbitrary user-selected directories without evaluating them. quoted=$(printf '%s' "$bin_dir" | sed "s/'/'\\\\''/g") printf '\nAdd it to PATH in your current shell:\n' printf " fish: fish_add_path '%s'\n" "$quoted" printf " zsh/bash: export PATH='%s':\"\$PATH\"\n" "$quoted" printf 'For future zsh/bash sessions, add that export to ~/.zshrc or ~/.bashrc.\n' ;; esac } main "$@"