#!/usr/bin/env bash set -euo pipefail base_url="${WAX_INSTALL_BASE_URL:-https://downloads.waxlang.dev}" index_base_url="${WAX_INSTALL_INDEX_BASE_URL:-https://packages.waxlang.dev/v1/releases}" install_root="${WAX_INSTALL_ROOT:-${HOME:-}/.wax}" requested_version="" target_override="${WAX_INSTALL_TARGET:-}" allow_fixture="${WAX_INSTALL_ALLOW_INSECURE_FIXTURE:-0}" no_path_update=0 no_file_association=0 uninstall=0 assume_yes=0 # Every diagnostic names the command the user actually ran. label="install" maximum_index_size=8192 maximum_archive_size=536870912 maximum_archive_entries=20000 valid_release_version() { local candidate="$1" local prerelease="" local identifiers=() local identifier="" if [[ ! "$candidate" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-([0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*))?$ ]]; then return 1 fi prerelease="${BASH_REMATCH[5]:-}" [[ -z "$prerelease" ]] && return 0 IFS=. read -r -a identifiers <<< "$prerelease" for identifier in "${identifiers[@]}"; do if [[ "$identifier" =~ ^[0-9]+$ && ${#identifier} -gt 1 && "$identifier" == 0* ]]; then return 1; fi done return 0 } usage() { echo "usage: install.sh [--version VERSION] [--install-root PATH] [--no-path-update] [--no-file-association]" echo " install.sh --uninstall [--install-root PATH] [--yes]" } fail() { echo "wax $label: $1" >&2 exit 1 } note() { printf 'wax %s: %s\n' "$label" "$1" >&2 } while [[ $# -gt 0 ]]; do case "$1" in --version) [[ $# -ge 2 ]] || { usage >&2; exit 2; } requested_version="$2" shift 2 ;; --install-root) [[ $# -ge 2 ]] || { usage >&2; exit 2; } install_root="$2" shift 2 ;; --no-path-update) no_path_update=1 shift ;; --no-file-association) no_file_association=1 shift ;; --uninstall) uninstall=1 label="uninstall" shift ;; --yes) assume_yes=1 shift ;; --help|-h) usage exit 0 ;; *) usage >&2 exit 2 ;; esac done # --- PATH configuration ------------------------------------------------------ # Quote a path for insertion into a POSIX shell startup file without allowing # spaces, quotes, or shell metacharacters in the path to change its meaning. shell_quote() { printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")" } validate_path_configuration() { # A piped Bash process can receive only exported environment variables, so # custom zsh and fish config roots are intentionally read from the # inherited environment rather than guessed from an unrelated shell. case "${SHELL:-}" in */fish|fish) [[ "${XDG_CONFIG_HOME:-$HOME/.config}" == /* ]] || fail "XDG_CONFIG_HOME must be an absolute path" [[ ! "${XDG_CONFIG_HOME:-$HOME/.config}" =~ [[:cntrl:]] ]] || fail "XDG_CONFIG_HOME contains control characters" ;; */zsh|zsh) [[ "${ZDOTDIR:-$HOME}" == /* ]] || fail "ZDOTDIR must be an absolute path" [[ ! "${ZDOTDIR:-$HOME}" =~ [[:cntrl:]] ]] || fail "ZDOTDIR contains control characters" ;; esac } path_startup_files() { case "${SHELL:-}" in */bash|bash) # Bash reads .bashrc for interactive shells and the first existing # login profile for login shells. Keep both paths covered, using # the same guarded block so sourcing both does not duplicate PATH. printf '%s/.bashrc\n' "$HOME" if [[ -f "$HOME/.bash_profile" ]]; then printf '%s/.bash_profile\n' "$HOME" elif [[ -f "$HOME/.bash_login" ]]; then printf '%s/.bash_login\n' "$HOME" else printf '%s/.profile\n' "$HOME" fi ;; */fish|fish) printf '%s/fish/config.fish\n' "${XDG_CONFIG_HOME:-$HOME/.config}" ;; */zsh|zsh) printf '%s/.zshrc\n' "${ZDOTDIR:-$HOME}" ;; *) printf '%s/.profile\n' "$HOME" ;; esac } # One definition of the installed block text, written by the install and matched # verbatim by the uninstall. The flavor is a parameter rather than a second read # of $SHELL because the uninstall recognizes a block by the file that holds it. path_block_text() { local flavor="$1" local path_entry="$2" local path_marker="$3" case "$flavor" in fish) # fish_add_path is itself persistent and duplicate-safe. printf '%s\nfish_add_path --global --path %s' "$path_marker" "$(shell_quote "$path_entry")" ;; *) # The guard is needed because Bash may source both .bash_profile # and .bashrc during one login, and because this block is installed # into both files when no single profile covers every Bash mode. printf '%s\ncase ":${PATH:-}:" in\n *:%s:*) ;;\n *) PATH=%s${PATH:+:$PATH} ;;\nesac\nexport PATH' "$path_marker" "$(shell_quote "$path_entry")" "$(shell_quote "$path_entry")" ;; esac } path_block_flavor_for_shell() { case "${SHELL:-}" in */fish|fish) printf 'fish\n' ;; *) printf 'posix\n' ;; esac } print_path_instructions() { local message="$1" local path_entry="$install_root/bin" local path_block="$(path_block_text "$(path_block_flavor_for_shell)" "$path_entry" "# Wax toolchain PATH: $path_entry")" echo "$message" echo "add $path_entry to PATH so wax, waxc, waxdbg, and waxlsp are available with:" printf '%s\n' "$path_block" } configure_path() { local path_entry="$install_root/bin" if [[ "$no_path_update" == 1 ]]; then echo "add $path_entry to PATH" return fi local startup_files=() local startup_file="" local primary_startup_file="" local path_marker="# Wax toolchain PATH: $path_entry" local path_block="$(path_block_text "$(path_block_flavor_for_shell)" "$path_entry" "$path_marker")" while IFS= read -r startup_file; do startup_files+=("$startup_file") done < <(path_startup_files) primary_startup_file="${startup_files[0]}" local reload_command=". $(shell_quote "$primary_startup_file")" local answer="" case ":${PATH:-}:" in *":$path_entry:"*) echo "PATH already includes $path_entry" return ;; esac # curl | bash consumes stdin for the script, so an interactive answer must # come from the controlling terminal instead of the script's stdin. A # readable /dev/tty is the whole condition: stdout is often redirected into # a log by someone sitting right there, and treating that as unattended # would edit their startup files without asking. Where there really is # nobody to ask, leaving a freshly installed toolchain off PATH is not a # useful default -- --no-path-update is the opt-out for installs that # manage PATH themselves. The device node can exist and be readable in a # process that has no controlling terminal, where opening it fails with # ENXIO, so the probe opens it rather than testing the path. if { : < /dev/tty; } 2>/dev/null && { : > /dev/tty; } 2>/dev/null; then printf 'Add %s to your shell startup files so wax, waxc, waxdbg, and waxlsp are available? [Y/n] ' "$path_entry" > /dev/tty if ! IFS= read -r answer < /dev/tty; then print_path_instructions "PATH was not changed (no answer received)" return fi case "$answer" in ""|[yY]|[yY][eE][sS]) ;; [nN]|[nN][oO]) print_path_instructions "PATH was not changed" return ;; *) print_path_instructions "PATH was not changed (answer must be yes or no)" return ;; esac fi for startup_file in "${startup_files[@]}"; do if [[ -e "$startup_file" && ! -f "$startup_file" ]]; then echo "could not update PATH: $startup_file is not a regular file" >&2 print_path_instructions "add $path_entry to PATH with this block:" return fi done if [[ "${SHELL:-}" == */fish || "${SHELL:-}" == fish ]]; then mkdir -p "${XDG_CONFIG_HOME:-$HOME/.config}/fish"; fi if [[ "${SHELL:-}" == */zsh || "${SHELL:-}" == zsh ]]; then mkdir -p "${ZDOTDIR:-$HOME}"; fi for startup_file in "${startup_files[@]}"; do if [[ -f "$startup_file" ]] && grep -Fqx "$path_marker" "$startup_file"; then continue fi # An unwritable startup file (a read-only home, a root-owned profile in a # container) must not abort an otherwise complete install under set -e. if ! { [[ ! -s "$startup_file" ]] || printf '\n' >> "$startup_file"; } || ! printf '%s\n' "$path_block" >> "$startup_file"; then print_path_instructions "could not update PATH: $startup_file is not writable" return fi echo "added $path_entry to PATH in $startup_file" done echo "open a new shell or run: $reload_command" } # Quote one argument for the Desktop Entry Exec grammar. Percent is handled # separately because it introduces field codes even inside a quoted argument. desktop_exec_quote() { local value="$1" value="${value//\\/\\\\\\\\}" value="${value//\"/\\\"}" value="${value//\$/\\$}" value="${value//\`/\\\`}" value="${value//%/%%}" printf '"%s"' "$value" } install_recording_file_type() { local data_home="$1" local mime_directory="$data_home/mime" local icon_directory="$data_home/icons/hicolor/256x256/mimetypes" local application_directory="$data_home/applications" local mime_source="$install_root/current/share/mime/wax-recording.xml" local icon_source="$install_root/current/share/icons/wax-recording.png" local mime_staging="" local icon_staging="" local desktop_staging="" local mime_destination="$mime_directory/packages/wax-recording.xml" local icon_destination="$icon_directory/application-x-wax-recording.png" local desktop_destination="$application_directory/dev.waxlang.WaxRecording.desktop" local waxdbg_exec="" [[ -f "$mime_source" && -f "$icon_source" ]] || return 1 mkdir -p "$mime_directory/packages" "$icon_directory" "$application_directory" || return 1 [[ ! -d "$mime_destination" && ! -d "$icon_destination" && ! -d "$desktop_destination" ]] || return 1 mime_staging="$(mktemp "$mime_directory/packages/.wax-recording.xml.XXXXXX")" || return 1 if ! icon_staging="$(mktemp "$icon_directory/.application-x-wax-recording.png.XXXXXX")"; then rm -f "$mime_staging" return 1 fi if ! desktop_staging="$(mktemp "$application_directory/.dev.waxlang.WaxRecording.desktop.XXXXXX")"; then rm -f "$mime_staging" "$icon_staging" return 1 fi waxdbg_exec="$(desktop_exec_quote "$install_root/bin/waxdbg")" if ! { cp "$mime_source" "$mime_staging" && cp "$icon_source" "$icon_staging" && printf '%s\n' \ '[Desktop Entry]' \ 'Type=Application' \ 'Name=Wax Inspector' \ 'Comment=Open a Wax recording' \ "Exec=$waxdbg_exec gui %f" \ 'Icon=application-x-wax-recording' \ 'Terminal=false' \ 'NoDisplay=true' \ 'Categories=Development;' \ 'MimeType=application/x-wax-recording;' > "$desktop_staging" && chmod 0644 "$mime_staging" "$icon_staging" "$desktop_staging" && mv -f "$mime_staging" "$mime_destination" && mv -f "$icon_staging" "$icon_destination" && mv -f "$desktop_staging" "$desktop_destination" }; then rm -f "$mime_staging" "$icon_staging" "$desktop_staging" return 1 fi if command -v update-mime-database >/dev/null 2>&1; then update-mime-database "$mime_directory" >/dev/null 2>&1 || note "could not refresh the desktop MIME cache; it will refresh at the next desktop login" fi if command -v update-desktop-database >/dev/null 2>&1; then update-desktop-database "$application_directory" >/dev/null 2>&1 || note "could not refresh the desktop application cache; it will refresh at the next desktop login" fi return 0 } configure_recording_file_type() { [[ "$target" == linux-* && "$no_file_association" == 0 ]] || return 0 local data_home="${XDG_DATA_HOME:-$HOME/.local/share}" if [[ "$data_home" != /* || "$data_home" =~ [[:cntrl:]] ]]; then note "did not install the .wxs file association because XDG_DATA_HOME is not a safe absolute path" return fi if install_recording_file_type "$data_home"; then echo "registered .wxs files with Wax Inspector" else note "could not install the optional .wxs desktop integration" fi } # --- Uninstall --------------------------------------------------------------- # Candidate startup files across every shell the installer writes to, not just # the shell running now: a toolchain installed from one shell must be removable # from another. uninstall_startup_files() { printf '%s/.bashrc\n%s/.bash_profile\n%s/.bash_login\n%s/.profile\n' "$HOME" "$HOME" "$HOME" "$HOME" local zsh_home="${ZDOTDIR:-$HOME}" if [[ "$zsh_home" == /* && ! "$zsh_home" =~ [[:cntrl:]] ]]; then printf '%s/.zshrc\n' "$zsh_home"; fi local config_home="${XDG_CONFIG_HOME:-$HOME/.config}" if [[ "$config_home" == /* && ! "$config_home" =~ [[:cntrl:]] ]]; then printf '%s/fish/config.fish\n' "$config_home"; fi } # Which flavor a startup file holds, read from the file rather than from $SHELL, # so a fish block is recognized when uninstalling from another shell. path_block_flavor_for_file() { case "$1" in */fish/config.fish) printf 'fish\n' ;; *) printf 'posix\n' ;; esac } # Remove the installed block only when the file still holds it verbatim. An # edited block is left alone and reported: guessing at what a user changed is # how an uninstaller corrupts a startup file. remove_path_block() { local startup_file="$1" local path_entry="$2" local path_marker="$3" local path_block="$4" local staging="" [[ -f "$startup_file" ]] || return 0 grep -Fqx "$path_marker" "$startup_file" || return 0 if ! staging="$(mktemp "$(dirname "$startup_file")/.wax-uninstall.XXXXXX")"; then note "could not rewrite $startup_file; remove its PATH block by hand" return 0 fi if ! WAX_PATH_BLOCK="$path_block" awk ' BEGIN { expected_count = split(ENVIRON["WAX_PATH_BLOCK"], expected, "\n") } { line[NR] = $0 } END { for (i = 1; i <= NR && !removed; i++) { if (line[i] != expected[1]) continue matched = 1 for (j = 2; j <= expected_count; j++) if (line[i + j - 1] != expected[j]) { matched = 0; break } if (!matched) continue start = (i > 1 && line[i - 1] == "") ? i - 1 : i for (j = start; j < i + expected_count; j++) skip[j] = 1 removed = 1 } for (i = 1; i <= NR; i++) if (!(i in skip)) print line[i] exit removed ? 0 : 1 } ' "$startup_file" > "$staging"; then rm -f "$staging" note "left the PATH block in $startup_file in place because it no longer matches what the installer wrote; remove it by hand" return 0 fi # Write through the path rather than replacing it: a startup file is often a # symbolic link into a dotfiles repository, and moving over the link would # leave a plain copy behind while the real file kept the block. if ! cat "$staging" > "$startup_file"; then rm -f "$staging" note "could not rewrite $startup_file; remove its PATH block by hand" return 0 fi rm -f "$staging" echo "removed $path_entry from PATH in $startup_file" } remove_recording_file_type() { local data_home="${XDG_DATA_HOME:-$HOME/.local/share}" [[ "$data_home" == /* && ! "$data_home" =~ [[:cntrl:]] ]] || return 0 local mime_directory="$data_home/mime" local application_directory="$data_home/applications" local desktop_file="$application_directory/dev.waxlang.WaxRecording.desktop" # The whole registration -- handler, MIME declaration, and icon -- belongs to # whichever install root the desktop entry launches, and the entry is the # only thing that names one. Without it nothing here is ours to delete. [[ -f "$desktop_file" ]] || return 0 grep -Fqx "Exec=$(desktop_exec_quote "$install_root/bin/waxdbg") gui %f" "$desktop_file" || { note "left the .wxs file association in place because it points at another Wax installation" return 0 } rm -f "$desktop_file" "$mime_directory/packages/wax-recording.xml" "$data_home/icons/hicolor/256x256/mimetypes/application-x-wax-recording.png" if command -v update-mime-database >/dev/null 2>&1; then update-mime-database "$mime_directory" >/dev/null 2>&1 || note "could not refresh the desktop MIME cache; it will refresh at the next desktop login" fi if command -v update-desktop-database >/dev/null 2>&1; then update-desktop-database "$application_directory" >/dev/null 2>&1 || note "could not refresh the desktop application cache; it will refresh at the next desktop login" fi echo "removed the .wxs file association" } run_uninstall() { local path_entry="$install_root/bin" local path_marker="# Wax toolchain PATH: $path_entry" local startup_file="" local answer="" local lock_pid="" [[ "$install_root" != "$HOME" && "$install_root" != "/" ]] || fail "refusing to remove $install_root" [[ -d "$install_root/releases" ]] || fail "$install_root is not an installer-managed Wax install root" if [[ -f "$install_root/.install-lock/pid" ]]; then IFS= read -r lock_pid < "$install_root/.install-lock/pid" || true if [[ "$lock_pid" =~ ^[1-9][0-9]*$ ]] && kill -0 "$lock_pid" 2>/dev/null; then fail "a Wax installation is running (process $lock_pid); wait for it to finish" fi fi # A controlling terminal is enough to ask, where the install also requires a # tty on stdout: `wax uninstall > log` must still be answered by a person. # Without one there is nobody to answer, so this refuses rather than # destroying an installation unasked; --yes is the way to say it up front. # The subshell is the test, because a readable /dev/tty can still fail to # open in a session that has no controlling terminal. if [[ "$assume_yes" != 1 ]]; then (exec 3>/dev/tty) 2>/dev/null || fail "no terminal to confirm at; rerun with --yes to remove $install_root" printf 'Remove the Wax toolchain in %s, its PATH entry, and its file associations? [y/N] ' "$install_root" > /dev/tty IFS= read -r answer < /dev/tty || answer="" case "$answer" in [yY]|[yY][eE][sS]) ;; *) fail "cancelled" ;; esac fi while IFS= read -r startup_file; do remove_path_block "$startup_file" "$path_entry" "$path_marker" "$(path_block_text "$(path_block_flavor_for_file "$startup_file")" "$path_entry" "$path_marker")" done < <(uninstall_startup_files) remove_recording_file_type # Last, because this deletes the script that is running. Bash keeps reading # from its open descriptor, and the remaining work is output. rm -rf "$install_root" || fail "could not remove $install_root; its PATH entry and file associations are already gone, so delete the directory by hand" echo "removed $install_root" echo "open a new shell so PATH no longer includes $path_entry" echo "the editor extension installed by 'wax vscode' was left in place; remove it from your editor" } if [[ -n "$requested_version" ]] && ! valid_release_version "$requested_version"; then fail "invalid release version: $requested_version" fi [[ -n "$install_root" && "$install_root" == /* ]] || fail "install root must be an absolute path" [[ ! "$install_root" =~ [[:cntrl:]:] ]] || fail "install root must not contain control characters or ':'" [[ -n "${HOME:-}" && "${HOME:-}" == /* ]] || fail "HOME must be an absolute path" [[ ! "${HOME:-}" =~ [[:cntrl:]] ]] || fail "HOME contains control characters" if [[ "$uninstall" == 1 ]]; then run_uninstall exit 0 fi base_url="${base_url%/}" index_base_url="${index_base_url%/}" if [[ "$allow_fixture" == 1 ]]; then [[ "$base_url" =~ ^http://(127\.0\.0\.1|localhost):[1-9][0-9]{0,4}$ ]] || fail "fixture downloads require an HTTP loopback URL" [[ "$index_base_url" =~ ^http://(127\.0\.0\.1|localhost):[1-9][0-9]{0,4}(/[A-Za-z0-9._~-]+)*$ ]] || fail "fixture indexes require an HTTP loopback URL" else [[ "$base_url" =~ ^https://[A-Za-z0-9][A-Za-z0-9.-]*(/[A-Za-z0-9._~-]+)*$ ]] || fail "download base URL must be HTTPS without credentials, a port, query, or fragment" [[ "$index_base_url" =~ ^https://[A-Za-z0-9][A-Za-z0-9.-]*(/[A-Za-z0-9._~-]+)*$ ]] || fail "index base URL must be HTTPS without credentials, a port, query, or fragment" fi [[ "$base_url" != *"/../"* && "$base_url" != *"/./"* && "$base_url" != */.. && "$base_url" != */. ]] || fail "download base URL contains an unsafe path" [[ "$index_base_url" != *"/../"* && "$index_base_url" != *"/./"* && "$index_base_url" != */.. && "$index_base_url" != */. ]] || fail "index base URL contains an unsafe path" if [[ "$no_path_update" == 0 ]]; then validate_path_configuration fi if [[ -n "$target_override" ]]; then target="$target_override" else system="$(uname -s)" machine="$(uname -m)" if [[ "$system" == Darwin && "$machine" == arm64 ]]; then target="macos-arm64" elif [[ "$system" == Linux && "$machine" == x86_64 ]]; then target="linux-x64" elif [[ "$system" == Linux && ( "$machine" == aarch64 || "$machine" == arm64 ) ]]; then target="linux-arm64" else fail "unsupported platform: $system $machine" fi fi [[ "$target" == macos-arm64 || "$target" == linux-arm64 || "$target" == linux-x64 ]] || fail "unsupported release target: $target" for command in curl tar mktemp wc grep sed find readlink diff uname tr mkdir rmdir rm mv ln sort cp chmod; do command -v "$command" >/dev/null 2>&1 || fail "required command is unavailable: $command" done if command -v shasum >/dev/null 2>&1; then sha256_file() { shasum -a 256 "$1" | sed 's/^\\//; s/[[:space:]].*$//' } elif command -v sha256sum >/dev/null 2>&1; then sha256_file() { sha256sum "$1" | sed 's/^\\//; s/[[:space:]].*$//' } else fail "SHA-256 tool is unavailable (expected shasum or sha256sum)" fi version_less_than() { local left="$1" local right="$2" local left_parts=() local right_parts=() local index IFS=. read -r -a left_parts <<< "$left" IFS=. read -r -a right_parts <<< "$right" for index in 0 1 2; do if [[ ${#left_parts[$index]} -lt ${#right_parts[$index]} ]]; then return 0; fi if [[ ${#left_parts[$index]} -gt ${#right_parts[$index]} ]]; then return 1; fi if [[ ${left_parts[$index]} < ${right_parts[$index]} ]]; then return 0; fi if [[ ${left_parts[$index]} > ${right_parts[$index]} ]]; then return 1; fi done return 1 } managed_release_pattern='^releases/(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?-(macos-arm64|linux-arm64|linux-x64)-[0-9a-f]{16}$' validate_release_link() { local name="$1" local link="$install_root/$name" local destination="" if [[ -L "$link" ]]; then destination="$(readlink "$link")" [[ "$destination" =~ $managed_release_pattern ]] || fail "$link is not a Wax-managed release link" elif [[ -e "$link" ]]; then fail "$link exists and is not a Wax-managed symbolic link" fi } mkdir -p "$install_root/releases" "$install_root/.release-integrity" validate_release_link current validate_release_link previous if [[ -L "$install_root/bin" ]]; then [[ "$(readlink "$install_root/bin")" == current/bin ]] || fail "$install_root/bin is not a Wax-managed symbolic link" elif [[ -e "$install_root/bin" ]]; then fail "$install_root/bin exists and is not a Wax-managed symbolic link" fi lock="$install_root/.install-lock" if ! mkdir "$lock" 2>/dev/null; then lock_pid="" if [[ -f "$lock/pid" ]]; then IFS= read -r lock_pid < "$lock/pid" || true fi if [[ "$lock_pid" =~ ^[1-9][0-9]*$ ]] && kill -0 "$lock_pid" 2>/dev/null; then fail "another Wax installation is already running (process $lock_pid)" fi rm -rf "$lock" mkdir "$lock" 2>/dev/null || fail "another Wax installation acquired the install lock" fi echo "$$" > "$lock/pid" staging="" stable_version_staging="" cleanup() { [[ -z "$staging" ]] || rm -rf "$staging" [[ -z "$stable_version_staging" ]] || rm -f "$stable_version_staging" rm -rf "$lock" } trap cleanup EXIT staging="$(mktemp -d "$install_root/.staging.XXXXXX")" download() { local url="$1" local output="$2" local maximum_size="$3" local requested_release="${4:-}" local arguments=(--fail --silent --show-error --location --max-redirs 0 --connect-timeout 15 --max-time 900 --max-filesize "$maximum_size" --retry 3 --output "$output") if [[ "$allow_fixture" != 1 ]]; then arguments+=(--proto "=https" --proto-redir "=https" --tlsv1.2) fi if ! curl --disable "${arguments[@]}" "$url"; then if [[ -n "$requested_release" ]]; then fail "requested Wax version $requested_release is unavailable (release index request failed)" fi fail "download failed: $url" fi } replace_link() { local source="$1" local destination="$2" if [[ "$(uname -s)" == Darwin ]]; then mv -fh "$source" "$destination" else mv -Tf "$source" "$destination" fi } # Fingerprint the installed tree by sorted path, contents, size, and executable # bit. Ownership, timestamps, and other extraction metadata are intentionally # excluded so the same verified release has one fingerprint on every host. # The record lives outside the immutable release directory so an older install # without one safely falls back to downloading and comparing the archive once. release_fingerprint() { local directory="$1" local listing="$2" local file="" local relative="" local executable=0 [[ -d "$directory" ]] || return 1 : > "$listing" while IFS= read -r file; do relative="${file#"$directory"/}" executable=0 [[ -x "$file" ]] && executable=1 printf '%s|%s|%s|%s\n' "$(sha256_file "$file")" "$(wc -c < "$file" | tr -d ' ')" "$executable" "$relative" >> "$listing" done < <(find "$directory" -type f -print | LC_ALL=C sort) sha256_file "$listing" } if [[ -n "$requested_version" ]]; then index_url="$index_base_url/v$requested_version/index-v1.txt" else index_url="$index_base_url/stable/index-v1.txt" fi index_file="$staging/index-v1.txt" download "$index_url" "$index_file" "$maximum_index_size" "$requested_version" index_size="$(wc -c < "$index_file" | tr -d ' ')" [[ "$index_size" =~ ^[0-9]+$ && "$index_size" -gt 0 && "$index_size" -le "$maximum_index_size" ]] || fail "release index exceeds its size limit" if LC_ALL=C grep -q '[^ -~]' "$index_file"; then fail "release index contains non-portable text" fi index_lines="$(wc -l < "$index_file" | tr -d ' ')" [[ "$index_lines" =~ ^[4-8]$ ]] || fail "release index has an invalid line count" [[ "$(sed -n '1p' "$index_file")" == wax-release-index-v1 ]] || fail "release index has an invalid format" version_line="$(sed -n '2p' "$index_file")" commit_line="$(sed -n '3p' "$index_file")" version="${version_line#version=}" commit="${commit_line#commit=}" [[ "$version_line" == "version=$version" ]] && valid_release_version "$version" || fail "release index has an invalid version" [[ "$commit_line" == "commit=$commit" && "$commit" =~ ^[0-9a-f]{40}$ ]] || fail "release index has an invalid commit" [[ -z "$requested_version" || "$version" == "$requested_version" ]] || fail "release index returned version $version instead of $requested_version" if [[ -z "$requested_version" ]]; then [[ "$version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || fail "stable channel returned a prerelease" stable_version_file="$install_root/.stable-version" if [[ -f "$stable_version_file" ]]; then IFS= read -r installed_stable_version < "$stable_version_file" || fail "could not read installed stable version" [[ "$installed_stable_version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || fail "installed stable version state is invalid" version_less_than "$version" "$installed_stable_version" && fail "stable channel would downgrade from $installed_stable_version to $version" fi fi selected_size="" selected_sha256="" selected_root="" selected_url="" # One record per archive the release produced, in strictly ascending target # order. Records for targets this installer cannot install — the Windows ones, # which install.ps1 serves — are still validated, so a malformed index is # rejected rather than skipped past. supported_targets=(linux-arm64 linux-x64 macos-arm64 windows-arm64 windows-x64) previous_rank=-1 for offset in $(seq 0 $((index_lines - 4))); do line="$(sed -n "$((offset + 4))p" "$index_file")" IFS='|' read -r target_field size sha256 root url extra <<< "$line" record_target="${target_field#target=}" [[ "$target_field" == "target=$record_target" && -z "${extra:-}" ]] || fail "release index has an invalid target record" rank=-1 for candidate in "${!supported_targets[@]}"; do [[ "${supported_targets[$candidate]}" == "$record_target" ]] && rank="$candidate" done [[ "$rank" -ge 0 ]] || fail "release index has an unsupported target: $record_target" [[ "$rank" -gt "$previous_rank" ]] || fail "release index has out-of-order target records" previous_rank="$rank" [[ "$size" =~ ^[1-9][0-9]{0,8}$ && "$size" -le "$maximum_archive_size" ]] || fail "release index has an invalid $record_target size" [[ "$sha256" =~ ^[0-9a-f]{64}$ ]] || fail "release index has an invalid $record_target SHA-256" [[ "$root" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]] || fail "release index has an invalid $record_target archive root" expected_url="$base_url/releases/v$version/$root.tar.gz" [[ "$url" == "$expected_url" ]] || fail "release index has an invalid $record_target URL" if [[ "$record_target" == "$target" ]]; then selected_size="$size" selected_sha256="$sha256" selected_root="$root" selected_url="$url" fi done [[ -n "$selected_url" ]] || fail "release index does not contain $target" release_name="$version-$target-${selected_sha256:0:16}" release_directory="$install_root/releases/$release_name" integrity_file="$install_root/.release-integrity/$release_name" expected_current="releases/$release_name" installed_current="$(readlink "$install_root/current" 2>/dev/null || true)" archive_mebibytes=$(( (selected_size + 1048575) / 1048576 )) note "Wax $version for $target uses a $archive_mebibytes MiB archive" # The release directory name includes the archive digest, so an exact current # target has already passed this installer's archive verification. Re-check the # cheap structural invariants before avoiding a second full archive download. # If anything looks wrong, continue through the normal verified extraction path, # which either repairs from the published archive or reports the mismatch. if [[ -d "$release_directory" ]]; then note "verifying the existing installation" fi installed_fingerprint="$(release_fingerprint "$release_directory" "$staging/installed-tree" 2>/dev/null || true)" recorded_fingerprint="$(sed -n '1p' "$integrity_file" 2>/dev/null || true)" integrity_extra="$(sed -n '2p' "$integrity_file" 2>/dev/null || true)" if [[ "$installed_current" == "$expected_current" && -d "$release_directory/bin" && -f "$release_directory/release.json" && -f "$integrity_file" && ! -L "$integrity_file" && "$installed_fingerprint" == "$recorded_fingerprint" && -z "$integrity_extra" && -z "$(find "$release_directory" -type l -print -quit)" && -z "$(find "$release_directory/bin" -type f ! -perm -111 -print -quit)" ]] && grep -Fqx " \"version\": \"$version\"" "$release_directory/release.json" && grep -Fqx " \"target\": \"$target\"," "$release_directory/release.json" && grep -Fqx " \"commit\": \"$commit\"," "$release_directory/release.json"; then if [[ -z "$requested_version" ]]; then stable_version_staging="$install_root/.stable-version.$$" printf '%s\n' "$version" > "$stable_version_staging" mv -f "$stable_version_staging" "$install_root/.stable-version" stable_version_staging="" fi if [[ ! -L "$install_root/bin" ]]; then bin_link="$install_root/.bin.$$" ln -s current/bin "$bin_link" replace_link "$bin_link" "$install_root/bin" fi echo "Wax $version for $target is already up to date" configure_recording_file_type configure_path echo "run 'wax vscode' to install the editor extension" exit 0 fi archive="$staging/$selected_root.tar.gz" note "downloading the release archive" download "$selected_url" "$archive" "$maximum_archive_size" note "verifying the downloaded archive" actual_size="$(wc -c < "$archive" | tr -d ' ')" [[ "$actual_size" == "$selected_size" ]] || fail "release archive size mismatch" [[ "$(sha256_file "$archive")" == "$selected_sha256" ]] || fail "release archive SHA-256 mismatch" listing="$staging/archive.list" tar -tzf "$archive" > "$listing" [[ -s "$listing" ]] || fail "release archive is empty" entry_count="$(wc -l < "$listing" | tr -d ' ')" [[ "$entry_count" =~ ^[1-9][0-9]*$ && "$entry_count" -le "$maximum_archive_entries" ]] || fail "release archive contains too many entries" if LC_ALL=C grep -q '[^ -~]' "$listing"; then fail "release archive contains a non-portable path" fi # An unsigned macOS candidate is named wax--macos-arm64.unsigned.tar.gz # so that the signing stage can tell the two apart by file name, while the # directory inside it stays wax--macos-arm64 -- signing rewrites the # binaries in place and must not rename the bundle. The index's root field is the # URL's basename, so it carries the suffix and the archive's own root does not. bundle="${selected_root%.unsigned}" while IFS= read -r entry; do [[ "$entry" == "$bundle/"* && "$entry" != *\\* && "$entry" != /* && "$entry" != *"/../"* && "$entry" != *"/./"* && "$entry" != */.. && "$entry" != */. ]] || fail "release archive contains an unsafe path" done < "$listing" verbose_listing="$staging/archive.verbose.list" tar -tvzf "$archive" > "$verbose_listing" while IFS= read -r entry; do [[ "${entry:0:1}" == - || "${entry:0:1}" == d ]] || fail "release archive contains an unsupported entry type" done < "$verbose_listing" extract="$staging/extract" mkdir "$extract" note "extracting the release archive" tar -xzf "$archive" -C "$extract" --no-same-owner --no-same-permissions [[ -d "$extract/$bundle" && -z "$(find "$extract" -mindepth 1 -maxdepth 1 ! -name "$bundle" -print -quit)" ]] || fail "release archive has an invalid root" [[ -z "$(find "$extract/$bundle" -type l -print -quit)" ]] || fail "release archive contains a symbolic link" manifest="$extract/$bundle/release.json" [[ -f "$manifest" ]] || fail "release archive has no release.json" [[ -d "$extract/$bundle/bin" ]] || fail "release archive has no bin directory" grep -Fqx " \"version\": \"$version\"" "$manifest" || fail "release manifest version does not match" grep -Fqx " \"target\": \"$target\"," "$manifest" || fail "release manifest target does not match" grep -Fqx " \"commit\": \"$commit\"," "$manifest" || fail "release manifest commit does not match" # macOS builds are not yet Apple-signed or notarized. That is invisible on this # path -- curl sets no quarantine attribute and arm64 binaries carry the linker's # ad-hoc signature -- but it is visible to anyone who downloads the archive in a # browser and lets Finder unpack it, so say it once rather than never. Every # other target must be a canonical signed release, and macOS will be too once the # Developer ID exists. if grep -Fqx ' "state": "unsigned",' "$manifest"; then [[ "$target" == macos-arm64 ]] || fail "release archive is not a canonical signed release" printf 'wax install: this macOS build is not Apple-notarized; install it with this installer rather than by unpacking the archive in Finder\n' >&2 else grep -Fqx ' "state": "release",' "$manifest" || fail "release archive is not a canonical signed release" fi if [[ -e "$release_directory" ]]; then [[ -f "$release_directory/release.json" ]] || fail "existing release directory is invalid: $release_directory" [[ -z "$(find "$release_directory" -type l -print -quit)" ]] || fail "existing release directory contains a symbolic link: $release_directory" diff -qr "$extract/$bundle" "$release_directory" >/dev/null || fail "existing release directory does not match the verified archive: $release_directory" while IFS= read -r expected_path; do relative_path="${expected_path#"$extract/$bundle"}" installed_path="$release_directory$relative_path" if [[ -x "$expected_path" ]]; then [[ -x "$installed_path" ]] || fail "existing release directory has incorrect executable permissions: $installed_path" else [[ ! -x "$installed_path" ]] || fail "existing release directory has incorrect executable permissions: $installed_path" fi done < <(find "$extract/$bundle" -type f -print) else mv "$extract/$bundle" "$release_directory" fi note "verifying the installed files" verified_fingerprint="$(release_fingerprint "$release_directory" "$staging/verified-tree")" integrity_staging="$install_root/.release-integrity/.$release_name.$$" printf '%s\n' "$verified_fingerprint" > "$integrity_staging" mv -f "$integrity_staging" "$integrity_file" old_current="$(readlink "$install_root/current" 2>/dev/null || true)" new_current="releases/$release_name" if [[ -n "$old_current" && "$old_current" != "$new_current" ]]; then previous_link="$install_root/.previous.$$" ln -s "$old_current" "$previous_link" replace_link "$previous_link" "$install_root/previous" fi current_link="$install_root/.current.$$" ln -s "$new_current" "$current_link" replace_link "$current_link" "$install_root/current" bin_link="$install_root/.bin.$$" ln -s current/bin "$bin_link" replace_link "$bin_link" "$install_root/bin" if [[ -z "$requested_version" ]]; then stable_version_staging="$install_root/.stable-version.$$" printf '%s\n' "$version" > "$stable_version_staging" mv -f "$stable_version_staging" "$install_root/.stable-version" stable_version_staging="" fi echo "installed Wax $version for $target in $release_directory" configure_recording_file_type configure_path echo "run 'wax --version' in a new terminal to verify the installed tools and native compile-and-link path" echo "run 'wax vscode' to install the editor extension"