diff --git a/Makefile b/Makefile index 80d8594..77104d2 100644 --- a/Makefile +++ b/Makefile @@ -5,6 +5,8 @@ BUILD_WIN=@env GOOS=windows GOARCH=amd64 go build -o privateer-windows.exe BUILD_LINUX=@env GOOS=linux GOARCH=amd64 go build -o privateer-linux BUILD_MAC=@env GOOS=darwin GOARCH=amd64 go build -o privateer-darwin +.PHONY: build test testcov release binary tidy test-cov release-candidate release-nix release-win release-mac todo + build: tidy test binary testcov: test test-cov release: tidy test release-nix release-win release-mac @@ -15,6 +17,7 @@ binary: test: @echo " > Validating code ..." + @bash ./test/install_test.sh @go vet ./... @go test ./... diff --git a/install.sh b/install.sh index 4362ce1..fcf0bef 100755 --- a/install.sh +++ b/install.sh @@ -43,6 +43,22 @@ case "$(uname -m)" in ;; esac +extract_download_urls() { + local release_json="$1" + + printf '%s' "$release_json" \ + | tr '\n' ' ' \ + | grep -Eo '"browser_download_url"[[:space:]]*:[[:space:]]*"[^"]+"' \ + | sed -E 's/^"browser_download_url"[[:space:]]*:[[:space:]]*"([^"]+)"$/\1/' +} + +find_release_asset_url() { + local release_json="$1" + local asset_pattern="$2" + + extract_download_urls "$release_json" | grep -Ei "$asset_pattern" | head -n 1 +} + download_latest_release() { local install_dir="$1" local install_file="$install_dir/pvtr" @@ -50,33 +66,91 @@ download_latest_release() { # Ensure the directory exists mkdir -p "$install_dir" + # Fetch release metadata once + local release_json + release_json=$(curl -s "${LATEST_RELEASE_URL}") + # Build the grep pattern based on OS local pattern if [[ "$OS" == "darwin" ]]; then - pattern="browser_download_url.*${OS}.*" + pattern="${OS}" else - pattern="browser_download_url.*${OS}.*${ARCH}.*" + pattern="${OS}.*${ARCH}" fi - # Fetch the download URL for the latest release + # Fetch the download URL for the latest release binary local url - url=$(curl -s ${LATEST_RELEASE_URL} | grep -i "$pattern" | cut -d '"' -f 4) + url=$(find_release_asset_url "$release_json" "$pattern") if [[ -z "$url" ]]; then echo "Failed to fetch the download URL for the latest release." exit 1 fi + # Fetch the checksums file URL + local checksums_url + checksums_url=$(find_release_asset_url "$release_json" 'checksums\.txt$') + + if [[ -z "$checksums_url" ]]; then + echo "ERROR: No checksums file found in release assets. Refusing to install an unverified binary." + exit 1 + fi + + # Create a temporary directory for download and verification + local tmp_dir + tmp_dir=$(mktemp -d) + trap 'rm -rf "$tmp_dir"' RETURN + + local archive_name + archive_name=$(basename "$url") + local tmp_archive="$tmp_dir/$archive_name" + echo "Downloading from: $url" - # Download the binary to the specified install directory - curl -L -0 "$url" | tar xvf - -C "$install_dir" + # Download the archive to a temporary file + curl -fSL -o "$tmp_archive" "$url" - if [[ $? -ne 0 ]]; then - echo "Failed to download the binary." + echo "Verifying checksum..." + local tmp_checksums="$tmp_dir/checksums.txt" + if ! curl -fSL -o "$tmp_checksums" "$checksums_url"; then + echo "ERROR: Failed to download checksums file. Refusing to install an unverified binary." exit 1 fi + # Extract the expected checksum for our archive (exact filename match, not regex) + local expected_checksum + expected_checksum=$(awk -v name="$archive_name" '$2 == name { print $1 }' "$tmp_checksums") + + if [[ -z "$expected_checksum" ]]; then + echo "ERROR: Could not find checksum for ${archive_name} in checksums file." + echo "Aborting installation for security. To skip verification, download manually." + exit 1 + fi + + # Compute actual checksum + local actual_checksum + if command -v sha256sum &>/dev/null; then + actual_checksum=$(sha256sum "$tmp_archive" | awk '{print $1}') + elif command -v shasum &>/dev/null; then + actual_checksum=$(shasum -a 256 "$tmp_archive" | awk '{print $1}') + else + echo "ERROR: No sha256sum or shasum found. Cannot verify checksum." + exit 1 + fi + + if [[ "$actual_checksum" != "$expected_checksum" ]]; then + echo "CHECKSUM MISMATCH!" + echo " Expected: $expected_checksum" + echo " Actual: $actual_checksum" + echo "The downloaded file may have been tampered with. Aborting." + exit 1 + fi + + echo "Checksum verified OK." + + # Extract the verified archive + tar xf "$tmp_archive" -C "$install_dir" + # Ensure the binary is executable chmod +x "$install_file" @@ -150,4 +224,6 @@ main() { echo "pvtr installation complete!" } -main "$@" +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" +fi diff --git a/test/install_test.sh b/test/install_test.sh new file mode 100644 index 0000000..506c2b7 --- /dev/null +++ b/test/install_test.sh @@ -0,0 +1,276 @@ +#!/bin/bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +INSTALL_SCRIPT="$REPO_ROOT/install.sh" + +release_json_with_checksums() { + cat <<'EOF' +{ + "assets": [ + { + "browser_download_url": "https://example.test/pvtr_Linux_x86_64.tar.gz" + }, + { + "browser_download_url": "https://example.test/checksums.txt" + } + ] +} +EOF +} + +release_json_without_checksums() { + cat <<'EOF' +{ + "assets": [ + { + "browser_download_url": "https://example.test/pvtr_Linux_x86_64.tar.gz" + } + ] +} +EOF +} + +release_json_with_checksums_compact() { + printf '%s' '{"assets":[{"browser_download_url":"https://example.test/pvtr_Linux_x86_64.tar.gz"},{"browser_download_url":"https://example.test/checksums.txt"}]}' +} + +make_mock_commands() { + local mock_bin="$1" + + cat <<'EOF' > "$mock_bin/uname" +#!/bin/bash +case "$1" in + -s) + printf '%s\n' "${MOCK_UNAME_S:-Linux}" + ;; + -m) + printf '%s\n' "${MOCK_UNAME_M:-x86_64}" + ;; + *) + /usr/bin/uname "$@" + ;; +esac +EOF + + cat <<'EOF' > "$mock_bin/curl" +#!/bin/bash +set -euo pipefail + +output_file="" +url="" + +while (($#)); do + case "$1" in + -o) + output_file="$2" + shift 2 + ;; + -f|-S|-s|-L|-SL|-fSL) + shift + ;; + -*) + shift + ;; + *) + url="$1" + shift + ;; + esac +done + +if [[ -z "$url" ]]; then + exit 1 +fi + +if [[ "$url" == *"/releases/latest" ]]; then + if [[ -n "$output_file" ]]; then + printf '%s' "$MOCK_RELEASE_JSON" > "$output_file" + else + printf '%s' "$MOCK_RELEASE_JSON" + fi + exit 0 +fi + +if [[ "$url" == *"checksums.txt" ]]; then + if [[ "${MOCK_FAIL_CHECKSUM_DOWNLOAD:-0}" == "1" ]]; then + exit 22 + fi + + if [[ -n "$output_file" ]]; then + printf '%s' "$MOCK_CHECKSUMS_CONTENT" > "$output_file" + else + printf '%s' "$MOCK_CHECKSUMS_CONTENT" + fi + exit 0 +fi + +if [[ -n "$output_file" ]]; then + printf 'mock archive' > "$output_file" +else + printf 'mock archive' +fi +EOF + + cat <<'EOF' > "$mock_bin/tar" +#!/bin/bash +set -euo pipefail + +target_dir="" + +while (($#)); do + case "$1" in + -C) + target_dir="$2" + shift 2 + ;; + *) + shift + ;; + esac +done + +mkdir -p "$target_dir" +printf '#!/bin/sh\nexit 0\n' > "$target_dir/pvtr" +EOF + + cat <<'EOF' > "$mock_bin/chmod" +#!/bin/bash +exit 0 +EOF + + cat <<'EOF' > "$mock_bin/sha256sum" +#!/bin/bash +printf '%s %s\n' "${MOCK_ACTUAL_CHECKSUM:-expected-checksum}" "$1" +EOF + + chmod +x "$mock_bin/uname" "$mock_bin/curl" "$mock_bin/tar" "$mock_bin/chmod" "$mock_bin/sha256sum" +} + +assert_contains() { + local haystack="$1" + local needle="$2" + + if [[ "$haystack" != *"$needle"* ]]; then + echo "expected output to contain: $needle" + echo "$haystack" + exit 1 + fi +} + +run_install() { + local work_dir="$1" + local install_dir="$2" + + local mock_bin="$work_dir/mock-bin" + mkdir -p "$mock_bin" + make_mock_commands "$mock_bin" + + ( + export PATH="$mock_bin:$PATH" + export HOME="$work_dir/home" + mkdir -p "$HOME" + export MOCK_RELEASE_JSON MOCK_CHECKSUMS_CONTENT MOCK_ACTUAL_CHECKSUM MOCK_FAIL_CHECKSUM_DOWNLOAD + bash -c 'source "$1"; download_latest_release "$2"' _ "$INSTALL_SCRIPT" "$install_dir" + ) +} + +test_installs_when_checksum_matches() { + local work_dir + work_dir="$(mktemp -d)" + trap 'rm -rf "$work_dir"' RETURN + + local install_dir="$work_dir/install" + MOCK_RELEASE_JSON="$(release_json_with_checksums)" + MOCK_CHECKSUMS_CONTENT='expected-checksum pvtr_Linux_x86_64.tar.gz' + MOCK_ACTUAL_CHECKSUM='expected-checksum' + MOCK_FAIL_CHECKSUM_DOWNLOAD=0 + + run_install "$work_dir" "$install_dir" + + [[ -f "$install_dir/pvtr" ]] +} + +test_fails_when_checksum_asset_missing() { + local work_dir + work_dir="$(mktemp -d)" + trap 'rm -rf "$work_dir"' RETURN + + local install_dir="$work_dir/install" + MOCK_RELEASE_JSON="$(release_json_without_checksums)" + MOCK_CHECKSUMS_CONTENT='' + MOCK_ACTUAL_CHECKSUM='expected-checksum' + MOCK_FAIL_CHECKSUM_DOWNLOAD=0 + + local output + if output=$(run_install "$work_dir" "$install_dir" 2>&1); then + echo "expected install to fail when checksum asset is missing" + exit 1 + fi + + assert_contains "$output" "No checksums file found in release assets" +} + +test_fails_when_checksum_download_fails() { + local work_dir + work_dir="$(mktemp -d)" + trap 'rm -rf "$work_dir"' RETURN + + local install_dir="$work_dir/install" + MOCK_RELEASE_JSON="$(release_json_with_checksums)" + MOCK_CHECKSUMS_CONTENT='expected-checksum pvtr_Linux_x86_64.tar.gz' + MOCK_ACTUAL_CHECKSUM='expected-checksum' + MOCK_FAIL_CHECKSUM_DOWNLOAD=1 + + local output + if output=$(run_install "$work_dir" "$install_dir" 2>&1); then + echo "expected install to fail when checksum download fails" + exit 1 + fi + + assert_contains "$output" "Failed to download checksums file" +} + +test_fails_when_checksum_mismatches() { + local work_dir + work_dir="$(mktemp -d)" + trap 'rm -rf "$work_dir"' RETURN + + local install_dir="$work_dir/install" + MOCK_RELEASE_JSON="$(release_json_with_checksums)" + MOCK_CHECKSUMS_CONTENT='expected-checksum pvtr_Linux_x86_64.tar.gz' + MOCK_ACTUAL_CHECKSUM='different-checksum' + MOCK_FAIL_CHECKSUM_DOWNLOAD=0 + + local output + if output=$(run_install "$work_dir" "$install_dir" 2>&1); then + echo "expected install to fail when checksum mismatches" + exit 1 + fi + + assert_contains "$output" "CHECKSUM MISMATCH" +} + +test_installs_when_release_json_is_compact() { + local work_dir + work_dir="$(mktemp -d)" + trap 'rm -rf "$work_dir"' RETURN + + local install_dir="$work_dir/install" + MOCK_RELEASE_JSON="$(release_json_with_checksums_compact)" + MOCK_CHECKSUMS_CONTENT='expected-checksum pvtr_Linux_x86_64.tar.gz' + MOCK_ACTUAL_CHECKSUM='expected-checksum' + MOCK_FAIL_CHECKSUM_DOWNLOAD=0 + + run_install "$work_dir" "$install_dir" + + [[ -f "$install_dir/pvtr" ]] +} + +test_installs_when_checksum_matches +test_fails_when_checksum_asset_missing +test_fails_when_checksum_download_fails +test_fails_when_checksum_mismatches +test_installs_when_release_json_is_compact