diff --git a/.github/workflows/release-binaries.yaml b/.github/workflows/release-binaries.yaml index 6fd313f..4321103 100644 --- a/.github/workflows/release-binaries.yaml +++ b/.github/workflows/release-binaries.yaml @@ -131,6 +131,11 @@ jobs: env: HAS_RELEASE_GPG_KEY: ${{ secrets.RELEASE_GPG_PRIVATE_KEY != '' }} steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ format('refs/tags/{0}', inputs.tag) }} + - name: Download build artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -138,17 +143,28 @@ jobs: merge-multiple: true path: release-assets/ + - name: Add installer to release assets + run: | + install -m 755 arcup/arcup release-assets/arcup + cd release-assets + if command -v sha256sum >/dev/null 2>&1; then + sha256sum arcup > arcup.sha256 + else + shasum -a 256 arcup > arcup.sha256 + fi + cat arcup.sha256 + - name: Import GPG key if: env.HAS_RELEASE_GPG_KEY == 'true' uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7.0.0 with: gpg_private_key: ${{ secrets.RELEASE_GPG_PRIVATE_KEY }} - - name: GPG sign archives + - name: GPG sign release assets if: env.HAS_RELEASE_GPG_KEY == 'true' run: | - for archive in release-assets/*.tar.gz; do - gpg --batch --yes --detach-sign --armor --output "${archive}.asc" "${archive}" + for artifact in release-assets/*.tar.gz release-assets/arcup; do + gpg --batch --yes --detach-sign --armor --output "${artifact}.asc" "${artifact}" done - name: Upload release assets diff --git a/arcup/arcup b/arcup/arcup index 3590cd3..077fc4a 100755 --- a/arcup/arcup +++ b/arcup/arcup @@ -6,7 +6,7 @@ set -euo pipefail # NOTE: if you make modifications to this script, please increment the version number. # WARNING: the SemVer pattern: major.minor.patch must be followed as we use it to determine if the script is up to date. -ARCUP_INSTALLER_VERSION="0.2.0" +ARCUP_INSTALLER_VERSION="0.3.0" REPO="${ARC_REPO:-circlefin/arc-node}" if [[ -n "${ARC_REPO:-}" ]] && [[ ! "$ARC_REPO" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then @@ -19,7 +19,9 @@ GPG_KEYSERVER="keyserver.ubuntu.com" ARC_DIR="${ARC_DIR:-${ARC_HOME:-$HOME/.arc}}" BIN_DIR="${ARC_BIN_DIR:-$ARC_DIR/bin}" # Self-update always uses the canonical repo to prevent hijack via ARC_REPO -ARCUP_BIN_URL="https://raw.githubusercontent.com/circlefin/arc-node/main/arcup/arcup" +ARCUP_SELF_UPDATE_REPO="circlefin/arc-node" +# Installer published as a release asset, next to its SHA-256 checksum file +ARCUP_ASSET_NAME="arcup" ARCUP_BIN_PATH="$BIN_DIR/arcup" GITHUB_API_URL="${GITHUB_API_URL:-https://api.github.com}" case "$GITHUB_API_URL" in @@ -146,13 +148,17 @@ resolve_github_asset_download_url() { printf '%s\n' "$location" } +require_sha256_tool() { + if ! command -v sha256sum >/dev/null 2>&1 && ! command -v shasum >/dev/null 2>&1; then + error "No SHA-256 tool found. Please install sha256sum or shasum." + fi +} + require_install_prerequisites() { require_command curl "curl not found. Please install curl." require_command tar "tar not found. Please install tar." - if ! command -v sha256sum >/dev/null 2>&1 && ! command -v shasum >/dev/null 2>&1; then - error "No SHA-256 tool found. Please install sha256sum or shasum." - fi + require_sha256_tool } extract_json_string_field() { @@ -211,40 +217,45 @@ extract_asset_api_url() { fetch_release_json() { local tag="$1" - local api_url="$GITHUB_API_URL/repos/$REPO/releases/tags/$tag" + local repo="${2:-$REPO}" + local api_url="$GITHUB_API_URL/repos/$repo/releases/tags/$tag" curl_github_api_json -fsSL "$api_url" } fetch_latest_release_json() { - local api_url="$GITHUB_API_URL/repos/$REPO/releases/latest" + local repo="${1:-$REPO}" + local api_url="$GITHUB_API_URL/repos/$repo/releases/latest" curl_github_api_json -fsSL "$api_url" } fetch_latest_release_json_anonymous() { - local api_url="$GITHUB_API_URL/repos/$REPO/releases/latest" + local repo="${1:-$REPO}" + local api_url="$GITHUB_API_URL/repos/$repo/releases/latest" curl_github_api_json_anonymous -fsSL "$api_url" } list_release_assets() { local tag="$1" + local repo="${2:-$REPO}" - fetch_release_json "$tag" 2>/dev/null | extract_arc_asset_names || true + fetch_release_json "$tag" "$repo" 2>/dev/null | extract_arc_asset_names || true } download_error() { local tag="$1" local filename="$2" local url="$3" + local repo="${4:-$REPO}" local assets echo -e "${RED}error${NC}: Failed to download $filename from $url" >&2 - echo " repository: $REPO" >&2 + echo " repository: $repo" >&2 echo " tag: $tag" >&2 echo " target: ${TARGET:-unknown}" >&2 - assets="$(list_release_assets "$tag")" + assets="$(list_release_assets "$tag" "$repo")" if [[ -n "$assets" ]]; then echo "Available arc-node release assets for $tag:" >&2 while IFS= read -r asset; do @@ -309,24 +320,25 @@ download_file() { local tag="$1" local filename="$2" local output_dir="$3" + local repo="${4:-$REPO}" - local url="https://github.com/${REPO}/releases/download/${tag}/${filename}" + local url="https://github.com/${repo}/releases/download/${tag}/${filename}" local output_path="$output_dir/$filename" require_command curl "curl not found. Please install curl." info "Downloading $filename..." - if download_file_with_github_api "$tag" "$filename" "$output_dir"; then + if download_file_with_github_api "$tag" "$filename" "$output_dir" "$repo"; then return fi - if download_file_with_gh "$tag" "$filename" "$output_dir"; then + if download_file_with_gh "$tag" "$filename" "$output_dir" "$repo"; then return fi if ! curl_with_headers -#fL --max-time 900 -o "$output_path" "$url"; then rm -f "$output_path" - download_error "$tag" "$filename" "$url" + download_error "$tag" "$filename" "$url" "$repo" fi } @@ -334,6 +346,7 @@ download_file_with_github_api() { local tag="$1" local filename="$2" local output_dir="$3" + local repo="${4:-$REPO}" local output_path="$output_dir/$filename" local release_json asset_api_url download_url @@ -341,7 +354,7 @@ download_file_with_github_api() { return 1 fi - release_json=$(fetch_release_json "$tag" 2>/dev/null) || return 1 + release_json=$(fetch_release_json "$tag" "$repo" 2>/dev/null) || return 1 asset_api_url=$(printf '%s\n' "$release_json" | extract_asset_api_url "$filename") if [[ -z "$asset_api_url" ]]; then return 1 @@ -361,6 +374,7 @@ download_file_with_gh() { local tag="$1" local filename="$2" local output_dir="$3" + local repo="${4:-$REPO}" local output_path="$output_dir/$filename" if ! command -v gh >/dev/null 2>&1; then @@ -369,7 +383,7 @@ download_file_with_gh() { if GH_PROMPT_DISABLED=1 GH_NO_UPDATE_NOTIFIER=1 \ gh release download "$tag" \ - --repo "$REPO" \ + --repo "$repo" \ --pattern "$filename" \ --dir "$output_dir" \ --clobber \ @@ -407,17 +421,50 @@ check_disk_space() { fi } +# Read the installer version declared by an arcup script +read_installer_version() { + awk -F'"' '/^ARCUP_INSTALLER_VERSION=/ {print $2; exit}' "$1" +} + +# Download the installer published with the latest canonical release into $1 and +# verify it against the checksum published in the same release. Prints the release +# tag on success. +# +# The body runs in a subshell so that a failed download or a failed verification +# returns non-zero to the caller instead of exiting the script. Callers decide +# whether that is fatal. +fetch_verified_arcup() { + local dest_dir="$1" + + ( + local tag asset_path + tag=$(get_latest_version "$ARCUP_SELF_UPDATE_REPO") + + download_file "$tag" "$ARCUP_ASSET_NAME" "$dest_dir" "$ARCUP_SELF_UPDATE_REPO" + download_file "$tag" "${ARCUP_ASSET_NAME}.sha256" "$dest_dir" "$ARCUP_SELF_UPDATE_REPO" + + asset_path="$dest_dir/$ARCUP_ASSET_NAME" + verify_checksum_file "$asset_path" "${asset_path}.sha256" "$ARCUP_ASSET_NAME" + verify_gpg_signature "$tag" "$asset_path" "$ARCUP_ASSET_NAME" "$ARCUP_SELF_UPDATE_REPO" + + printf '%s\n' "$tag" + ) +} + # Check if arcup installer is up to date check_installer_up_to_date() { - require_command curl "curl not found. Please install curl." + local check_dir="$TMP_DIR/installer-check" + mkdir -p "$check_dir" + + # Advisory only. Stay quiet when the published installer cannot be fetched or + # verified, so this check never blocks or slows down an install. + fetch_verified_arcup "$check_dir" >/dev/null 2>&1 || return 0 - local response local remote_version - response=$(curl_with_headers -fsSL "$ARCUP_BIN_URL" 2>/dev/null || true) - remote_version=$(printf '%s\n' "$response" | awk -F'"' '/^ARCUP_INSTALLER_VERSION=/ {print $2; exit}') + remote_version=$(read_installer_version "$check_dir/$ARCUP_ASSET_NAME") if [[ -z "$remote_version" ]]; then - return + return 0 fi if version_gt "$remote_version" "$ARCUP_INSTALLER_VERSION"; then @@ -429,21 +476,27 @@ check_installer_up_to_date() { fi } -# Update arcup itself +# Update arcup itself from the installer published with the latest release update_arcup() { info "Updating arcup..." require_command curl "curl not found. Please install curl." + require_sha256_tool - _ARCUP_TMP_FILE=$(mktemp) + local update_dir="$TMP_DIR/self-update" + mkdir -p "$update_dir" - info "Downloading latest arcup..." - if ! curl_with_headers -fsSL -o "$_ARCUP_TMP_FILE" "$ARCUP_BIN_URL"; then - error "Failed to download arcup update" + local remote_tag + if ! remote_tag=$(fetch_verified_arcup "$update_dir"); then + error "Refusing to self-update: no verified arcup installer available. + arcup only replaces itself with a release asset whose SHA-256 matches the + checksum published in the same release, so nothing was changed. + Releases: https://github.com/$ARCUP_SELF_UPDATE_REPO/releases + Reinstall: https://github.com/$ARCUP_SELF_UPDATE_REPO/blob/main/docs/installation.md" fi local remote_version - remote_version=$(grep '^ARCUP_INSTALLER_VERSION=' "$_ARCUP_TMP_FILE" | sed -E 's/ARCUP_INSTALLER_VERSION="(.+)"/\1/') + remote_version=$(read_installer_version "$update_dir/$ARCUP_ASSET_NAME") if [[ -z "$remote_version" ]]; then error "Failed to determine remote version" @@ -454,12 +507,24 @@ update_arcup() { exit 0 fi - info "Updating from $ARCUP_INSTALLER_VERSION to $remote_version..." - if cp "$_ARCUP_TMP_FILE" "$ARCUP_BIN_PATH" 2>/dev/null; then - chmod 755 "$ARCUP_BIN_PATH" - else + info "Updating from $ARCUP_INSTALLER_VERSION to $remote_version ($remote_tag)..." + + # Stage the new installer next to the current one so the final move is a rename + # within one filesystem: it replaces arcup in a single step and it cannot leave + # a half-written executable behind if the process dies. + if ! _ARCUP_TMP_FILE=$(mktemp "$ARCUP_BIN_PATH.XXXXXX" 2>/dev/null); then + error "Failed to stage the arcup update in '$BIN_DIR' — ensure it is writable" + fi + + if ! cp "$update_dir/$ARCUP_ASSET_NAME" "$_ARCUP_TMP_FILE" 2>/dev/null; then + error "Failed to stage the arcup update in '$BIN_DIR' — ensure it is writable" + fi + chmod 755 "$_ARCUP_TMP_FILE" + + if ! mv -f "$_ARCUP_TMP_FILE" "$ARCUP_BIN_PATH" 2>/dev/null; then error "Failed to update arcup at '$ARCUP_BIN_PATH' — ensure it is writable" fi + _ARCUP_TMP_FILE="" echo "" info "Arcup updated successfully to version $remote_version" @@ -511,54 +576,6 @@ uninstall() { exit 0 } -# Parse command-line arguments -VERSION="" -while [[ $# -gt 0 ]]; do - case $1 in - -v|--version) - version - ;; - -i|--install) - if [[ $# -lt 2 || -z "$2" || "$2" == -* ]]; then - error "--install requires a version argument (for example: arcup --install v1.0.0)" - fi - VERSION="$2" - shift 2 - ;; - -U|--self-update) - update_arcup - ;; - --uninstall) - uninstall - ;; - -h|--help) - echo "Usage: arcup [OPTIONS]" - echo "" - echo "Options:" - echo " -v, --version Print arcup installer version" - echo " -i, --install Install specific arc-node version (e.g., v1.0.0)" - echo " -U, --self-update Update arcup to the latest version" - echo " --uninstall Remove arc binaries and env files" - echo " -h, --help Show this help message" - echo "" - echo "Examples:" - echo " arcup # Install latest release" - echo " arcup -i v1.0.0 # Install specific version" - echo " arcup -v # Print installer version" - echo " arcup --self-update # Update arcup itself" - echo " arcup --uninstall # Remove arc installation" - exit 0 - ;; - *) - error "Unknown option: $1. Use --help for usage information." - ;; - esac -done - -if [[ -n "$VERSION" ]]; then - VERSION="$(normalize_version "$VERSION")" -fi - # Detect platform detect_platform() { local platform @@ -630,13 +647,14 @@ detect_target() { get_latest_version() { require_command curl "curl not found. Please install curl." + local repo="${1:-$REPO}" local response token_error - if ! response=$(fetch_latest_release_json 2>&1); then + if ! response=$(fetch_latest_release_json "$repo" 2>&1); then token_error="$(safe_curl_error "$response")" - if [[ -n "$GITHUB_AUTH_TOKEN" ]] && response=$(fetch_latest_release_json_anonymous 2>&1); then + if [[ -n "$GITHUB_AUTH_TOKEN" ]] && response=$(fetch_latest_release_json_anonymous "$repo" 2>&1); then : else - error "Failed to fetch latest version from '$GITHUB_API_URL/repos/$REPO/releases/latest': $token_error" + error "Failed to fetch latest version from '$GITHUB_API_URL/repos/$repo/releases/latest': $token_error" fi fi @@ -681,6 +699,48 @@ verify_checksum_file() { fi } +# Verify a downloaded asset against the detached GPG signature published with the +# same release. Does nothing until a release signing key is pinned in +# GPG_KEY_FINGERPRINT, so wiring a key on the release side turns this on +# everywhere it is called, with no further code change. +verify_gpg_signature() { + local tag="$1" + local asset_path="$2" + local asset_name="$3" + local repo="${4:-$REPO}" + local output_dir + output_dir="$(dirname "$asset_path")" + + if [[ -z "$GPG_KEY_FINGERPRINT" ]]; then + return 0 + fi + + if ! command -v gpg >/dev/null 2>&1; then + warn "gpg not found. Skipping signature verification." + warn "Install gpg to enable signature verification of releases." + return 0 + fi + + info "Verifying GPG signature..." + download_file "$tag" "${asset_name}.asc" "$output_dir" "$repo" + + if ! gpg --list-keys "$GPG_KEY_FINGERPRINT" >/dev/null 2>&1; then + info "Fetching Arc release signing key from $GPG_KEYSERVER..." + if ! gpg --keyserver "$GPG_KEYSERVER" --recv-keys "$GPG_KEY_FINGERPRINT" 2>/dev/null; then + warn "Failed to fetch GPG key from keyserver. Skipping signature verification." + warn "You can manually import the key: gpg --keyserver $GPG_KEYSERVER --recv-keys $GPG_KEY_FINGERPRINT" + fi + fi + + if gpg --list-keys "$GPG_KEY_FINGERPRINT" >/dev/null 2>&1; then + if gpg --batch --verify "$output_dir/${asset_name}.asc" "$asset_path" 2>/dev/null; then + info "GPG signature verified" + else + error "GPG signature verification failed. The binary may have been tampered with." + fi + fi +} + validate_archive_contents() { local archive_path="$1" local listing metadata entry type @@ -784,32 +844,7 @@ main() { info "Checksum verified" - # GPG signature verification - if [[ -n "$GPG_KEY_FINGERPRINT" ]]; then - if command -v gpg >/dev/null 2>&1; then - info "Verifying GPG signature..." - download_file "$VERSION_TAG" "${ARCHIVE_NAME}.asc" "$TMP_DIR" - - if ! gpg --list-keys "$GPG_KEY_FINGERPRINT" >/dev/null 2>&1; then - info "Fetching Arc release signing key from $GPG_KEYSERVER..." - if ! gpg --keyserver "$GPG_KEYSERVER" --recv-keys "$GPG_KEY_FINGERPRINT" 2>/dev/null; then - warn "Failed to fetch GPG key from keyserver. Skipping signature verification." - warn "You can manually import the key: gpg --keyserver $GPG_KEYSERVER --recv-keys $GPG_KEY_FINGERPRINT" - fi - fi - - if gpg --list-keys "$GPG_KEY_FINGERPRINT" >/dev/null 2>&1; then - if gpg --batch --verify "$TMP_DIR/${ARCHIVE_NAME}.asc" "$ARCHIVE_PATH" 2>/dev/null; then - info "GPG signature verified" - else - error "GPG signature verification failed. The binary may have been tampered with." - fi - fi - else - warn "gpg not found. Skipping signature verification." - warn "Install gpg to enable signature verification of releases." - fi - fi + verify_gpg_signature "$VERSION_TAG" "$ARCHIVE_PATH" "$ARCHIVE_NAME" validate_archive_contents "$ARCHIVE_PATH" @@ -836,6 +871,56 @@ main() { fi } +# Parse command-line arguments. This runs after every function definition so that +# each option handler can use the download and verification helpers below: bash +# only defines a function once it has read it. +VERSION="" +while [[ $# -gt 0 ]]; do + case $1 in + -v|--version) + version + ;; + -i|--install) + if [[ $# -lt 2 || -z "$2" || "$2" == -* ]]; then + error "--install requires a version argument (for example: arcup --install v1.0.0)" + fi + VERSION="$2" + shift 2 + ;; + -U|--self-update) + update_arcup + ;; + --uninstall) + uninstall + ;; + -h|--help) + echo "Usage: arcup [OPTIONS]" + echo "" + echo "Options:" + echo " -v, --version Print arcup installer version" + echo " -i, --install Install specific arc-node version (e.g., v1.0.0)" + echo " -U, --self-update Update arcup to the latest version" + echo " --uninstall Remove arc binaries and env files" + echo " -h, --help Show this help message" + echo "" + echo "Examples:" + echo " arcup # Install latest release" + echo " arcup -i v1.0.0 # Install specific version" + echo " arcup -v # Print installer version" + echo " arcup --self-update # Update arcup itself" + echo " arcup --uninstall # Remove arc installation" + exit 0 + ;; + *) + error "Unknown option: $1. Use --help for usage information." + ;; + esac +done + +if [[ -n "$VERSION" ]]; then + VERSION="$(normalize_version "$VERSION")" +fi + if [[ "${ARCUP_SKIP_MAIN:-}" != "1" ]]; then main fi diff --git a/arcup/test_arcup.sh b/arcup/test_arcup.sh index 4902119..e9fed51 100755 --- a/arcup/test_arcup.sh +++ b/arcup/test_arcup.sh @@ -641,6 +641,227 @@ EOF pass "download_file falls back to curl" } +file_inode() { + ls -i "$1" | awk '{print $1}' +} + +# Installs a stub arcup at /bin/arcup and creates /tmp. +seed_installed_arcup() { + local work="$1" + + mkdir -p "$work/bin" "$work/tmp" + printf '#!/usr/bin/env bash\nARCUP_INSTALLER_VERSION="0.3.0"\n' > "$work/bin/arcup" + chmod 755 "$work/bin/arcup" +} + +# Publishes an installer asset with a matching checksum file, the way the release +# workflow does. +publish_installer_asset() { + local release_dir="$1" + local version="$2" + + mkdir -p "$release_dir" + printf '#!/usr/bin/env bash\nARCUP_INSTALLER_VERSION="%s"\n' "$version" > "$release_dir/arcup" + printf '%s arcup\n' "$(compute_sha256 "$release_dir/arcup")" > "$release_dir/arcup.sha256" +} + +# Runs update_arcup against the fake release served by write_self_update_fakebin. +run_self_update() { + local work="$1" + local fakebin="$2" + local release_dir="$3" + local out="$4" + local repo="${5:-$REPO}" + + ( + PATH="$fakebin:$PATH" + export SELF_UPDATE_RELEASE_DIR="$release_dir" + export SELF_UPDATE_TAG="v1.2.3" + GITHUB_AUTH_TOKEN="" + CURL_HEADERS=() + REPO="$repo" + TMP_DIR="$work/tmp" + BIN_DIR="$work/bin" + ARCUP_BIN_PATH="$work/bin/arcup" + update_arcup + ) >"$out" 2>&1 +} + +# Writes fake curl and gh binaries that serve the canonical repo's latest release +# tag and the assets found in $SELF_UPDATE_RELEASE_DIR. Any other URL fails, so a +# request aimed at another repository cannot succeed. +write_self_update_fakebin() { + local fakebin="$1" + + mkdir -p "$fakebin" + + cat > "$fakebin/gh" <<'EOF' +#!/usr/bin/env bash +exit 1 +EOF + chmod 755 "$fakebin/gh" + + cat > "$fakebin/curl" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +out="" +url="" +while [[ $# -gt 0 ]]; do + case "$1" in + -o) + out="$2" + shift 2 + ;; + -H | --retry | --retry-delay | --connect-timeout | --max-time) + shift 2 + ;; + -*) + shift + ;; + *) + url="$1" + shift + ;; + esac +done +case "$url" in + *api.github.com/repos/circlefin/arc-node/releases/latest) + printf '{"tag_name":"%s"}\n' "$SELF_UPDATE_TAG" + ;; + *raw.githubusercontent.com/circlefin/arc-node/main/arcup/arcup) + # Served on purpose. Self-update must not trust the mutable branch, so any + # change that reads the installer from here again fails these tests. + if [[ ! -f "$SELF_UPDATE_RELEASE_DIR/mutable-arcup" ]]; then + printf 'no mutable branch copy for this test\n' >&2 + exit 22 + fi + if [[ -n "$out" ]]; then + cp "$SELF_UPDATE_RELEASE_DIR/mutable-arcup" "$out" + else + cat "$SELF_UPDATE_RELEASE_DIR/mutable-arcup" + fi + ;; + *github.com/circlefin/arc-node/releases/download/*) + name="${url##*/}" + if [[ ! -f "$SELF_UPDATE_RELEASE_DIR/$name" ]]; then + printf 'asset not published: %s\n' "$name" >&2 + exit 22 + fi + cp "$SELF_UPDATE_RELEASE_DIR/$name" "$out" + ;; + *) + printf 'unexpected curl URL: %s\n' "$url" >&2 + exit 22 + ;; +esac +EOF + chmod 755 "$fakebin/curl" +} + +test_self_update_installs_verified_release() { + local work="$TEST_TMP/self-update-ok" + local fakebin="$work/fakebin" + local release_dir="$work/release" + local out="$TEST_TMP/self-update-ok.out" + local before_inode + + seed_installed_arcup "$work" + write_self_update_fakebin "$fakebin" + publish_installer_asset "$release_dir" "9.9.9" + before_inode="$(file_inode "$work/bin/arcup")" + + if ! run_self_update "$work" "$fakebin" "$release_dir" "$out"; then + cat "$out" >&2 + fail "self-update installs the verified release asset" + fi + + assert_eq "$(cat "$release_dir/arcup")" "$(cat "$work/bin/arcup")" \ + "self-update installs the verified release asset" + + [[ -x "$work/bin/arcup" ]] || fail "self-update keeps arcup executable" + pass "self-update keeps arcup executable" + + if [[ "$before_inode" == "$(file_inode "$work/bin/arcup")" ]]; then + fail "self-update replaces arcup by rename" + fi + pass "self-update replaces arcup by rename" +} + +test_self_update_rejects_tampered_installer() { + local work="$TEST_TMP/self-update-tampered" + local fakebin="$work/fakebin" + local release_dir="$work/release" + local out="$TEST_TMP/self-update-tampered.out" + local before + + seed_installed_arcup "$work" + write_self_update_fakebin "$fakebin" + publish_installer_asset "$release_dir" "9.9.9" + before="$(cat "$work/bin/arcup")" + + # Same checksum file, different installer: what a compromised delivery path + # would serve. The mutable branch serves the same payload, so an installer that + # trusts the branch installs it. + printf '#!/usr/bin/env bash\nARCUP_INSTALLER_VERSION="9.9.9"\necho "unverified installer ran" >&2\n' \ + > "$release_dir/arcup" + cp "$release_dir/arcup" "$release_dir/mutable-arcup" + + if run_self_update "$work" "$fakebin" "$release_dir" "$out"; then + cat "$out" >&2 + fail "self-update rejects a tampered installer" + fi + + grep -q "Checksum verification failed" "$out" || fail "self-update reports the checksum mismatch" + grep -q "Refusing to self-update" "$out" || fail "self-update refuses to install" + assert_eq "$before" "$(cat "$work/bin/arcup")" "self-update keeps the installed arcup on mismatch" +} + +test_self_update_rejects_missing_installer_asset() { + local work="$TEST_TMP/self-update-missing" + local fakebin="$work/fakebin" + local release_dir="$work/release" + local out="$TEST_TMP/self-update-missing.out" + local before + + seed_installed_arcup "$work" + write_self_update_fakebin "$fakebin" + mkdir -p "$release_dir" + # No installer asset in the release, but the mutable branch still answers. + printf '#!/usr/bin/env bash\nARCUP_INSTALLER_VERSION="9.9.9"\necho "unverified installer ran" >&2\n' \ + > "$release_dir/mutable-arcup" + before="$(cat "$work/bin/arcup")" + + if run_self_update "$work" "$fakebin" "$release_dir" "$out"; then + cat "$out" >&2 + fail "self-update fails closed without a published installer" + fi + + grep -q "Refusing to self-update" "$out" || fail "self-update explains why it refused" + assert_eq "$before" "$(cat "$work/bin/arcup")" \ + "self-update keeps the installed arcup when nothing is published" +} + +test_self_update_ignores_arc_repo() { + local work="$TEST_TMP/self-update-arc-repo" + local fakebin="$work/fakebin" + local release_dir="$work/release" + local out="$TEST_TMP/self-update-arc-repo.out" + + seed_installed_arcup "$work" + write_self_update_fakebin "$fakebin" + publish_installer_asset "$release_dir" "9.9.9" + + # The fake curl only answers for circlefin/arc-node, so this only passes if + # self-update ignored ARC_REPO. + if ! run_self_update "$work" "$fakebin" "$release_dir" "$out" "attacker/arc-node"; then + cat "$out" >&2 + fail "self-update ignores ARC_REPO" + fi + + assert_eq "$(cat "$release_dir/arcup")" "$(cat "$work/bin/arcup")" "self-update ignores ARC_REPO" +} + test_fixture_install_matrix() { local fixture_dir="$TEST_TMP/fixture" local fakebin="$TEST_TMP/fakebin" @@ -664,6 +885,9 @@ test_fixture_install_matrix() { printf '%s %s\n' "$(compute_sha256 "$archive")" "$archive_name" > "$checksum_file" done + # Every release also publishes the installer, which the up-to-date check reads. + publish_installer_asset "$release_dir" "$ARCUP_INSTALLER_VERSION" + cat > "$fakebin/uname" <<'EOF' #!/usr/bin/env bash case "${1:-}" in @@ -706,8 +930,13 @@ while [[ $# -gt 0 ]]; do done case "$url" in - *raw.githubusercontent.com/circlefin/arc-node/main/arcup/arcup) - data='ARCUP_INSTALLER_VERSION="0.2.0"' + *api.github.com/repos/circlefin/arc-node/releases/latest) + data='{"tag_name":"v1.2.3"}' + ;; + *releases/download/v1.2.3/arcup*) + name="${url##*/}" + cp "$FIXTURE_RELEASE_DIR/$name" "$out" + exit 0 ;; *releases/download/v1.2.3/arc-node-v1.2.3-*.tar.gz*) name="${url##*/}" @@ -823,6 +1052,10 @@ test_latest_version_retries_anonymous_after_token_failure test_latest_version_redacts_authenticated_failure test_download_file_uses_gh_when_available test_download_file_falls_back_to_curl +test_self_update_installs_verified_release +test_self_update_rejects_tampered_installer +test_self_update_rejects_missing_installer_asset +test_self_update_ignores_arc_repo test_fixture_install_matrix test_archive_path_traversal_fails test_archive_link_entries_fail diff --git a/docs/installation.md b/docs/installation.md index 4cd389c..e71f7c5 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -93,6 +93,14 @@ arcup --uninstall ``` `arcup` always verifies the downloaded archive against its `.sha256` file. +`arcup --self-update` is verified the same way. It takes the installer from the +latest release of `circlefin/arc-node`, checks it against the `arcup.sha256` +published in that release, and leaves the installed `arcup` untouched if that +check cannot be completed. Self-update ignores `ARC_REPO`, so pointing `arcup` +at another repository cannot redirect an installer update. Releases made before +the installer was published as a release asset have nothing to verify against, +so `--self-update` refuses to run on them and the installer has to be +reinstalled with the bootstrap command above. GPG signature verification is disabled until the Arc release signing key is published. If an archive for your operating system or CPU architecture is not available in the selected release, `arcup` prints the expected asset name and @@ -106,6 +114,9 @@ Troubleshooting: an archive for your platform. - `Checksum verification failed`: retry the install; if it persists, do not run the downloaded binaries. +- `Refusing to self-update`: the latest release publishes no installer that can + be verified, so nothing was replaced. Reinstall with the bootstrap command + above. - On macOS, if manually copied binaries are blocked by quarantine attributes, run `xattr -dr com.apple.quarantine "$ARC_BIN_DIR"` after reviewing the downloaded files.