From d399f77c287b80b3528c0601e3e0c689048f2763 Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 27 Sep 2026 11:56:44 +0100 Subject: [PATCH] fix(installer): resolve releases from the public channel, not stale GitHub assets The documented one-liner resolved "latest" from the version-less GitHub assets published under terraphim/terraphim-ai, which top out at v1.21.3 and do not carry the names the current release line produces. The installer therefore either installed an older release or failed outright. Resolution is now manifest-driven: * both scripts read /stable-v2.json from downloads.terraphim.ai, with stable.json as a checksum-less fallback for the transitional period * asset names come from the manifest, so the seven published targets resolve correctly, including the architecture-specific macOS archives ahead of the universal one * the requested version must match what the channel serves; anything else is a distinct exit code rather than a silent substitution * downloads are verified against the manifest SHA-256 and unpacked from a staging directory, so a mismatch or an unexpected archive leaves nothing in the install directory scripts/test-installer.sh replaces the previous harness, which asserted nothing about the artefacts: it now installs and runs the real binary and proves, by serving a manifest with wrong digests, that a manipulated release is refused. scripts/SHA256SUMS pins the sibling utilities the installer fetches when it is run through curl|bash and has no directory of its own. Verified: 13/13 checks pass against the live channel; installer exit codes 0/2/3/5 exercised; all seven targets resolve; tampered manifest and tampered utility both refused with nothing installed. --- scripts/SHA256SUMS | 3 + scripts/binary-resolution.sh | 558 +++++++++++--------------- scripts/install.sh | 748 ++++++++++++++++------------------- scripts/test-installer.sh | 275 ++++++++----- 4 files changed, 741 insertions(+), 843 deletions(-) create mode 100644 scripts/SHA256SUMS diff --git a/scripts/SHA256SUMS b/scripts/SHA256SUMS new file mode 100644 index 000000000..972264670 --- /dev/null +++ b/scripts/SHA256SUMS @@ -0,0 +1,3 @@ +44343141130c54660e00d9853ef7abd0592ce51ccec70b03c993a674403ec368 platform-detection.sh +d85077c532617ed590f751b2044006b541758e66a119b90bb72f0eb38a33c66c binary-resolution.sh +355cada8f01ea80534299e30d1eba50a4fc4d407aa3372cae8150c6d9b68a3ca security-verification.sh diff --git a/scripts/binary-resolution.sh b/scripts/binary-resolution.sh index 6ed23e745..f11df9c4c 100755 --- a/scripts/binary-resolution.sh +++ b/scripts/binary-resolution.sh @@ -1,12 +1,35 @@ #!/bin/bash -# Binary Resolution Engine for Terraphim AI Installer -# Resolves the best binary asset for a given tool, version, and platform - -# Configuration (loaded from main installer or defaults) -GITHUB_API_BASE="${GITHUB_API_BASE:-https://api.github.com/repos/terraphim/terraphim-ai}" -GITHUB_RELEASES="${GITHUB_RELEASES:-https://github.com/terraphim/terraphim-ai/releases/download}" +# Binary Resolution Engine for the Terraphim installer. +# +# Resolution is manifest-driven. The public release channel publishes one +# manifest per binary at: +# +# https://downloads.terraphim.ai//stable-v2.json +# https://downloads.terraphim.ai//stable.json +# +# stable-v2.json is a strict object-valued manifest: +# +# { "version", "released_at", "notes_url", +# "assets": { "": {"path","sha256","size"} } } +# +# stable.json is the legacy pointer: identical shape except that each asset is +# a bare repository-relative path string with no digest. It is read only when +# the strict manifest is unavailable, and a release resolved that way reports +# an empty checksum so the caller runs unverified rather than failing. +# +# Resolution never consults GitHub Releases. The v1 release line's GitHub +# assets are version-less bare binaries published under terraphim/terraphim-ai; +# the current line publishes version-and-target archives through the channel +# below. Keeping one home for resolution removes that mismatch entirely. + +# Configuration (overridable by the caller; see scripts/install.sh) +TERRAPHIM_CHANNEL_BASE="${TERRAPHIM_CHANNEL_BASE:-https://downloads.terraphim.ai}" +TERRAPHIM_RELEASES_REPO="${TERRAPHIM_RELEASES_REPO:-terraphim/terraphim-clients}" DEFAULT_VERSION="${DEFAULT_VERSION:-latest}" +# The channel serves every client binary; anything else is a caller error. +TERRAPHIM_SUPPORTED_BINARIES=("terraphim-agent" "terraphim-cli" "terraphim-grep") + # Colors RED='\033[0;31m' GREEN='\033[0;32m' @@ -14,392 +37,277 @@ YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' -log_info() { - echo -e "${BLUE}ℹ${NC} $*" +log_info() { echo -e "${BLUE}i${NC} $*"; } +log_warn() { echo -e "${YELLOW}!${NC} $*"; } +log_error() { echo -e "${RED}x${NC} $*"; } +log_success() { echo -e "${GREEN}+${NC} $*"; } + +# Map uname output onto the target triples the channel publishes. +normalise_os() { + case "${1:-$(uname -s)}" in + Linux|linux*) echo "linux" ;; + Darwin|darwin*) echo "macos" ;; + CYGWIN*|MINGW*|MSYS*|cygwin*|mingw*|msys*|windows*) echo "windows" ;; + *) echo "unknown" ;; + esac } -log_warn() { - echo -e "${YELLOW}⚠${NC} $*" +normalise_arch() { + case "${1:-$(uname -m)}" in + x86_64|amd64) echo "x86_64" ;; + aarch64|arm64) echo "aarch64" ;; + armv7*|armv6*|arm) echo "armv7" ;; + *) echo "unknown" ;; + esac } -log_error() { - echo -e "${RED}✗${NC} $*" +# Order matters: the first target present in the manifest wins. +# x86_64 macOS prefers the architecture-specific archive over the universal +# one because it is roughly half the download. +generate_target_candidates() { + local os arch + os=$(normalise_os) + arch=$(normalise_arch) + + case "$os" in + macos) + case "$arch" in + aarch64) echo "aarch64-apple-darwin"; echo "universal-apple-darwin" ;; + x86_64) echo "x86_64-apple-darwin"; echo "universal-apple-darwin" ;; + esac + ;; + linux) + case "$arch" in + x86_64) echo "x86_64-unknown-linux-gnu"; echo "x86_64-unknown-linux-musl" ;; + aarch64) echo "aarch64-unknown-linux-musl" ;; + esac + ;; + windows) + case "$arch" in + x86_64) echo "x86_64-pc-windows-msvc" ;; + esac + ;; + esac } -log_success() { - echo -e "${GREEN}✓${NC} $*" +is_supported_binary() { + local candidate + for candidate in "${TERRAPHIM_SUPPORTED_BINARIES[@]}"; do + [[ "$candidate" == "$1" ]] && return 0 + done + return 1 } -# Get the latest release version from GitHub API -get_latest_version() { - log_info "Fetching latest release version..." - - local api_response - local version +# Fetch a manifest into a caller-owned file. Returns non-zero on any failure so +# the caller can fall back without inspecting partial output. +fetch_manifest() { + local binary=$1 version=$2 destination=$3 + local url="${TERRAPHIM_CHANNEL_BASE}/${binary}/stable-v2.json" - # Try to get latest release - api_response=$(curl -s "${GITHUB_API_BASE}/releases/latest" 2>/dev/null) + # Version selection is a manifest lookup: the channel only ever serves the + # current stable release, so asking for anything else is a hard error + # rather than a silent substitution. + log_info "Reading manifest: $url" - if [[ $? -ne 0 || -z "$api_response" ]]; then - log_error "Failed to fetch latest release from GitHub API" + if ! curl --silent --show-error --fail --location \ + --retry 2 --retry-delay 1 --max-time 30 \ + --output "$destination" "$url" 2>/dev/null; then return 1 fi + [[ -s "$destination" ]] || return 1 - # Extract tag name - version=$(echo "$api_response" | grep '"tag_name":' | sed -E 's/.*"tag_name":\s*"([^"]*).*/\1/') - - if [[ -z "$version" ]]; then - log_error "Could not extract version from GitHub API response" - return 1 + if [[ "$version" != "latest" ]]; then + local manifest_version + manifest_version=$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1]))["version"])' "$destination" 2>/dev/null || true) + if [[ -z "$manifest_version" ]]; then + return 1 + fi + if [[ "$manifest_version" != "${version#v}" ]]; then + log_error "Channel serves ${binary} ${manifest_version}, not ${version#v}" + log_error "Requested versions are not archived; see https://github.com/${TERRAPHIM_RELEASES_REPO}/releases" + return 2 + fi fi - # Remove 'v' prefix if present - version=${version#v} - - log_success "Latest version: $version" - echo "$version" + return 0 } -# Get a specific version from GitHub API -get_version_info() { - local version=$1 - - log_info "Fetching info for version: $version" - - local api_response - local version_tag="v${version#v}" - - # Get release info - api_response=$(curl -s "${GITHUB_API_BASE}/releases/tags/$version_tag" 2>/dev/null) - - if [[ $? -ne 0 || -z "$api_response" ]]; then - log_error "Failed to fetch version $version from GitHub API" - return 1 - fi - - echo "$api_response" +# Read a single field out of a manifest. Never trusts the file's shape. +manifest_field() { + local file=$1 expression=$2 + python3 -c 'import json,sys +try: + data = json.load(open(sys.argv[1])) +except Exception: + raise SystemExit(1) +value = eval(sys.argv[2], {"__builtins__": {}}, {"data": data}) +print("" if value is None else value)' "$file" "$expression" 2>/dev/null } -# List all available assets for a release -list_release_assets() { - local version=$1 - - log_info "Listing assets for version: $version" - - local api_response - api_response=$(get_version_info "$version") +# Resolve the best asset for the current platform. +# +# Prints four ASSET_* lines for the caller to read, and a human-readable +# summary on stderr. Exit codes: +# 0 resolved against the strict manifest (checksum available) +# 0 resolved against the legacy manifest (ASSET_CHECKSUM empty) +# 1 channel unreachable, or no published asset matches this platform +# 2 the requested version is not the one the channel serves +resolve_best_asset() { + local binary=$1 + local version=${2:-"$DEFAULT_VERSION"} - if [[ $? -ne 0 ]]; then - return 1 + if ! is_supported_binary "$binary"; then + log_error "Unknown binary: $binary" + log_error "Published binaries: ${TERRAPHIM_SUPPORTED_BINARIES[*]}" + return 2 fi - # Extract asset names - echo "$api_response" | grep '"name":' | sed -E 's/.*"name":\s*"([^"]*).*/\1/' | sort -} + local tmpdir + tmpdir=$(mktemp -d) || return 1 + trap 'rm -rf "$tmpdir"' RETURN -# Generate possible asset names for a tool on current platform -generate_asset_names() { - local tool=$1 - local os=${OS:-"$(uname -s | tr '[:upper:]' '[:lower:]')"} - local arch=${ARCH:-"$(uname -m)"} - - # Normalize OS and arch - case $os in - linux*) os="linux" ;; - darwin*) os="macos" ;; - cygwin*|mingw*|msys*) os="windows" ;; - esac + local strict="$tmpdir/stable-v2.json" + local legacy="$tmpdir/stable.json" + local manifest="" source_kind="" - case $arch in - x86_64|amd64) arch="x86_64" ;; - aarch64|arm64) arch="aarch64" ;; - armv7*|armv6*) arch="armv7" ;; - esac - - local assets=() - - # Priority order for asset names - if [[ "$os" == "macos" ]]; then - # macOS universal binaries first - assets+=("${tool}-universal-apple-darwin") - assets+=("${tool}-macos-universal") - # Then architecture-specific - assets+=("${tool}-macos-${arch}") - assets+=("${tool}-darwin-${arch}") - elif [[ "$os" == "windows" ]]; then - # Windows executables - assets+=("${tool}-windows-${arch}.exe") - assets+=("${tool}-${os}-${arch}.exe") - assets+=("${tool}-${arch}-pc-windows-msvc.exe") - else - # Linux and other Unix-like - assets+=("${tool}-${os}-${arch}") - assets+=("${tool}-${os}-${arch}-musl") - assets+=("${tool}-${arch}-unknown-linux-gnu") + local status=0 + fetch_manifest "$binary" "$version" "$strict" || status=$? + if [[ $status -eq 2 ]]; then + return 2 fi - # Generic fallbacks - assets+=("${tool}-${arch}") - assets+=("${tool}") - - # Print all possible names (highest priority first) - printf '%s\n' "${assets[@]}" -} - -# Check if an asset exists in a release -asset_exists() { - local asset_name=$1 - local version=$2 - - log_info "Checking if asset exists: $asset_name" - - local api_response - local version_tag="v${version#v}" - - # Get release info - api_response=$(curl -s "${GITHUB_API_BASE}/releases/tags/$version_tag" 2>/dev/null) - - if [[ $? -ne 0 || -z "$api_response" ]]; then - log_warn "Failed to get release info for $version" - return 1 + if [[ $status -eq 0 ]]; then + manifest="$strict" + source_kind="strict" + else + log_warn "Strict manifest unavailable; falling back to the legacy pointer" + local legacy_url="${TERRAPHIM_CHANNEL_BASE}/${binary}/stable.json" + if ! curl --silent --show-error --fail --location \ + --retry 2 --retry-delay 1 --max-time 30 \ + --output "$legacy" "$legacy_url" 2>/dev/null || [[ ! -s "$legacy" ]]; then + log_error "No manifest for ${binary} at ${TERRAPHIM_CHANNEL_BASE}/${binary}/" + return 1 + fi + manifest="$legacy" + source_kind="legacy" + + if [[ "$version" != "latest" ]]; then + local legacy_version + legacy_version=$(manifest_field "$legacy" 'data["version"]') + if [[ "$legacy_version" != "${version#v}" ]]; then + log_error "Channel serves ${binary} ${legacy_version}, not ${version#v}" + return 2 + fi + fi fi - # Check if asset exists in the release - if echo "$api_response" | grep -q "\"name\":\s*\"$asset_name\""; then - log_success "Asset found: $asset_name" - return 0 - else - log_info "Asset not found: $asset_name" + local resolved_version + resolved_version=$(manifest_field "$manifest" 'data["version"]') + if [[ -z "$resolved_version" ]]; then + log_error "Manifest for ${binary} has no version field" return 1 fi -} - -# Get download URL for an asset -get_asset_url() { - local asset_name=$1 - local version=$2 - - local version_tag="v${version#v}" - echo "${GITHUB_RELEASES}/$version_tag/$asset_name" -} - -# Get checksum for an asset (if available) -get_asset_checksum() { - local asset_name=$1 - local version=$2 - - # Look for checksum file - local checksum_file="checksums.txt" - local checksum_url="${GITHUB_RELEASES}/v${version#v}/$checksum_file" - - log_info "Fetching checksums for verification..." - local checksums - checksums=$(curl -s "$checksum_url" 2>/dev/null) + local target="" + while IFS= read -r candidate; do + [[ -n "$candidate" ]] || continue + if [[ "$(manifest_field "$manifest" "data['assets'].get('$candidate')")" != "" ]]; then + target="$candidate" + break + fi + done < <(generate_target_candidates) - if [[ $? -ne 0 || -z "$checksums" ]]; then - log_warn "No checksum file found for version $version" + if [[ -z "$target" ]]; then + log_error "No published ${binary} archive matches $(normalise_os)/$(normalise_arch)" + log_error "Published targets: $(manifest_field "$manifest" "' '.join(sorted(data['assets']))")" return 1 fi - # Extract checksum for the specific asset - local checksum - checksum=$(echo "$checksums" | grep "$asset_name" | head -1 | awk '{print $1}') - - if [[ -n "$checksum" ]]; then - echo "$checksum" - return 0 + local asset_path checksum="" + if [[ "$source_kind" == "strict" ]]; then + asset_path=$(manifest_field "$manifest" "data['assets']['$target']['path']") + checksum=$(manifest_field "$manifest" "data['assets']['$target']['sha256']") else - log_warn "No checksum found for $asset_name" - return 1 + asset_path=$(manifest_field "$manifest" "data['assets']['$target']") fi -} - -# Resolve the best asset for a tool and version -resolve_best_asset() { - local tool=$1 - local version=${2:-"$DEFAULT_VERSION"} - log_info "Resolving best asset for $tool (version: $version)" - - # Get version if 'latest' - if [[ "$version" == "latest" ]]; then - version=$(get_latest_version) - if [[ $? -ne 0 ]]; then - log_error "Failed to get latest version" - return 1 - fi + if [[ -z "$asset_path" || "$asset_path" == /* || "$asset_path" == *".."* ]]; then + log_error "Manifest for ${binary} carries an unsafe asset path: ${asset_path}" + return 1 fi - log_info "Resolved version: $version" - - # Generate possible asset names - local asset_names - readarray -t asset_names < <(generate_asset_names "$tool") - - log_info "Trying asset names in priority order:" - for name in "${asset_names[@]}"; do - log_info " - $name" - done + target_url="${TERRAPHIM_CHANNEL_BASE}/${asset_path}" + asset_name=$(basename "$asset_path") - # Try each asset name - for asset_name in "${asset_names[@]}"; do - if asset_exists "$asset_name" "$version"; then - local asset_url - asset_url=$(get_asset_url "$asset_name" "$version") + log_success "Resolved ${binary} ${resolved_version} for ${target}" + log_info "Manifest: ${source_kind}" + log_info "Download: ${target_url}" + [[ -n "$checksum" ]] && log_info "SHA-256: ${checksum}" - log_success "Resolved asset: $asset_name" - log_info "Download URL: $asset_url" + echo "ASSET_NAME=${asset_name}" + echo "ASSET_URL=${target_url}" + echo "ASSET_CHECKSUM=${checksum}" + echo "ASSET_VERSION=${resolved_version}" + echo "ASSET_TARGET=${target}" + echo "ASSET_MANIFEST=${source_kind}" - # Get checksum if available - local checksum - checksum=$(get_asset_checksum "$asset_name" "$version" 2>/dev/null || true) - - if [[ -n "$checksum" ]]; then - log_info "Checksum: $checksum" - fi - - # Output in a format that can be easily parsed - echo "ASSET_NAME=$asset_name" - echo "ASSET_URL=$asset_url" - [[ -n "$checksum" ]] && echo "ASSET_CHECKSUM=$checksum" - echo "ASSET_VERSION=$version" - - return 0 - fi - done - - # No binary found, recommend source compilation - log_warn "No pre-built binary found for $tool on this platform" - log_warn "Will need to build from source" - - echo "ASSET_NAME=source" - echo "ASSET_URL=source" - echo "ASSET_CHECKSUM=" - echo "ASSET_VERSION=$version" - - return 1 + return 0 } -# Resolve binary URL (simplified function for main installer compatibility) +# Installer-compatible shim: emit only the URL. resolve_binary_url() { - local tool=$1 + local binary=$1 local version=${2:-"$DEFAULT_VERSION"} - log_info "Resolving binary URL for $tool (version: $version)" - - # Parse the output of resolve_best_asset - local resolution_output - resolution_output=$(resolve_best_asset "$tool" "$version") - - if [[ $? -eq 0 ]]; then - local asset_url - asset_url=$(echo "$resolution_output" | grep "^ASSET_URL=" | cut -d'=' -f2-) - echo "$asset_url" - else + local output + output=$(resolve_best_asset "$binary" "$version" 2>/dev/null) || { echo "source" - fi -} - -# Get asset size for progress reporting -get_asset_size() { - local asset_url=$1 - - log_info "Getting asset size for: $asset_url" - - # Use HEAD request to get content-length - local size - size=$(curl -s -I "$asset_url" | grep -i "content-length" | cut -d' ' -f2- | tr -d '\r\n') - - if [[ -n "$size" && "$size" =~ ^[0-9]+$ ]]; then - echo "$size" - return 0 - else - echo "0" return 1 - fi + } + echo "$output" | grep '^ASSET_URL=' | cut -d'=' -f2- } -# Verify that an asset is suitable for the current platform -verify_asset_compatibility() { - local asset_name=$1 - local os=${OS:-"$(uname -s | tr '[:upper:]' '[:lower:]')"} - local arch=${ARCH:-"$(uname -m)"} - - log_info "Verifying asset compatibility: $asset_name" +# Freshness of the channel's pointer, for diagnostics and acceptance runs. +channel_status() { + local binary=${1:-terraphim-agent} + local tmpdir + tmpdir=$(mktemp -d) || return 1 + trap 'rm -rf "$tmpdir"' RETURN - # Check OS compatibility - local os_compatible=false - case $os in - linux*) - if [[ "$asset_name" =~ linux ]]; then - os_compatible=true - fi - ;; - darwin*) - if [[ "$asset_name" =~ (darwin|macos) ]]; then - os_compatible=true - fi - ;; - cygwin*|mingw*|msys*) - if [[ "$asset_name" =~ windows ]] || [[ "$asset_name" =~ \.exe$ ]]; then - os_compatible=true - fi - ;; - esac - - # Check architecture compatibility - local arch_compatible=false - case $arch in - x86_64|amd64) - if [[ "$asset_name" =~ (x86_64|amd64|x64) ]]; then - arch_compatible=true - fi - ;; - aarch64|arm64) - if [[ "$asset_name" =~ (aarch64|arm64|arm) ]]; then - arch_compatible=true - fi - ;; - armv7*) - if [[ "$asset_name" =~ armv7 ]]; then - arch_compatible=true - fi - ;; - esac - - if [[ "$os_compatible" == true && "$arch_compatible" == true ]]; then - log_success "Asset is compatible with current platform" - return 0 - else - log_error "Asset is not compatible with current platform" - log_error "OS compatible: $os_compatible, Arch compatible: $arch_compatible" + local manifest="$tmpdir/stable-v2.json" + if ! fetch_manifest "$binary" latest "$manifest"; then + log_error "Channel manifest unreachable for ${binary}" return 1 fi + + log_info "Binary: ${binary}" + log_info "Version: $(manifest_field "$manifest" 'data["version"]')" + log_info "Released: $(manifest_field "$manifest" 'data["released_at"]')" + log_info "Targets: $(manifest_field "$manifest" "' '.join(sorted(data['assets']))")" } -# Main function for testing main() { - local tool=${1:-"terraphim-agent"} + local binary=${1:-"terraphim-agent"} local version=${2:-"latest"} - echo "=== Binary Resolution Test ===" - echo "Tool: $tool" + echo "=== Terraphim Binary Resolution ===" + echo "Binary: $binary" echo "Version: $version" - echo "===========================" + echo "Channel: $TERRAPHIM_CHANNEL_BASE" + echo "===================================" - resolve_best_asset "$tool" "$version" + resolve_best_asset "$binary" "$version" + local status=$? echo - echo "Testing compatibility check..." - if verify_asset_compatibility "terraphim-agent-linux-x86_64"; then - echo "Compatibility check passed" + if [[ $status -eq 0 ]]; then + log_success "Resolution succeeded" else - echo "Compatibility check failed" + log_error "Resolution failed (exit ${status})" fi + return $status } -# If script is executed directly, run main if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then main "$@" -fi +fi \ No newline at end of file diff --git a/scripts/install.sh b/scripts/install.sh index 72bc17662..f861bd3ed 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,522 +1,446 @@ #!/bin/bash -# Terraphim AI Universal Installer v1.0.0 -# Installs terraphim-agent and optionally terraphim-cli -# Supports: Linux, macOS, Windows (WSL) -# Installation: curl -fsSL https://raw.githubusercontent.com/terraphim/terraphim-ai/main/scripts/install.sh | bash +# Terraphim Universal Installer v2.0.0 +# +# Installs the Terraphim client binaries published on the public release +# channel. Resolution, downloads and checksums all come from +# https://downloads.terraphim.ai; nothing is fetched from GitHub Releases. +# +# curl -fsSL https://raw.githubusercontent.com/terraphim/terraphim-ai/main/scripts/install.sh | bash +# +# Supported: Linux (x86_64 gnu/musl, aarch64 musl), macOS (x86_64, aarch64, +# universal), Windows via WSL (x86_64). set -euo pipefail -# Configuration -INSTALLER_VERSION="1.0.0" -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -GITHUB_API_BASE="https://api.github.com/repos/terraphim/terraphim-ai" -GITHUB_RELEASES="https://github.com/terraphim/terraphim-ai/releases/download" -DEFAULT_INSTALL_DIR="$HOME/.local/bin" -DEFAULT_TOOLS=("terraphim-agent") +readonly INSTALLER_VERSION="2.0.0" + +# Sibling utilities live next to this script. Under `curl | bash` there is no +# sibling directory, so each is fetched from the same revision as this script +# and verified before it is sourced. +readonly UTILS_REVISION="${UTILS_REVISION:-main}" +readonly UTILS_BASE_URL="${UTILS_BASE_URL:-https://raw.githubusercontent.com/terraphim/terraphim-ai/${UTILS_REVISION}/scripts}" +readonly SOURCE_URL="https://github.com/terraphim/terraphim-ai/blob/${UTILS_REVISION}/scripts/install.sh" +readonly UTILS=("platform-detection.sh" "binary-resolution.sh" "security-verification.sh") + +readonly DEFAULT_INSTALL_DIR="$HOME/.local/bin" +readonly SUPPORTED_TOOLS=("terraphim-agent" "terraphim-cli" "terraphim-grep") + +INSTALL_DIR="$DEFAULT_INSTALL_DIR" +TOOLS_TO_INSTALL=("terraphim-agent") VERSION="${VERSION:-latest}" SKIP_VERIFY="${SKIP_VERIFY:-false}" VERBOSE="${VERBOSE:-false}" +CURL_UA="terraphim-installer/${INSTALLER_VERSION}" + +# Exit codes +readonly EXIT_USAGE=1 +readonly EXIT_MANIFEST=2 +readonly EXIT_VERSION=3 +readonly EXIT_DOWNLOAD=4 +readonly EXIT_CHECKSUM=5 +readonly EXIT_INSTALL=6 -# Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' BOLD='\033[1m' -NC='\033[0m' # No Color - -# Logging functions -log_info() { - if [[ "$VERBOSE" == "true" ]]; then - echo -e "${BLUE}ℹ${NC} $*" - fi +NC='\033[0m' + +log_info() { [[ "$VERBOSE" == "true" ]] && echo -e "${BLUE}i${NC} $*" || true; } +log_warn() { echo -e "${YELLOW}!${NC} $*"; } +log_error() { echo -e "${RED}x${NC} $*"; } +log_success() { echo -e "${GREEN}+${NC} $*"; } +log_progress(){ echo -e "${BLUE}>${NC} $*"; } + +# Curl wrapper: descriptive User-Agent (the channel rejects generic ones), +# bounded retries, hard timeout. +terraphim_curl() { + curl --silent --show-error --fail --location \ + --user-agent "$CURL_UA" \ + --retry 3 --retry-delay 1 --max-time 120 \ + "$@" } -log_warn() { - echo -e "${YELLOW}⚠${NC} $*" +show_banner() { + cat </dev/null && [[ -s "$expected_list" ]]; then + log_info "Downloaded SHA256SUMS" else - install_binary "$tool" "$asset_url" + rm -f "$expected_list" + expected_list="" + log_warn "No SHA256SUMS for the utilities; integrity falls back to TLS" fi - verify_installation "$tool" - log_success "$tool installed successfully" - done - - # Setup configuration and PATH - setup_configuration - setup_path "$INSTALL_DIR" + for util in "${UTILS[@]}"; do + target="$tmp/$util" + if ! terraphim_curl --output "$target" "${UTILS_BASE_URL}/${util}"; then + log_error "Could not fetch ${util} from ${UTILS_BASE_URL}" + log_error "Run from a checkout, or set UTILS_REVISION to a released tag." + exit $EXIT_MANIFEST + fi + if [[ -n "$expected_list" ]]; then + sha_expected=$(grep -E "[[:space:]]${util}\$" "$expected_list" | awk '{print $1}' | head -1 || true) + if [[ -z "$sha_expected" ]]; then + log_error "SHA256SUMS has no entry for ${util}; refusing unpinned helper" + exit $EXIT_CHECKSUM + fi + sha_actual=$(calculate_sha256 "$target") + if [[ "$sha_expected" != "$sha_actual" ]]; then + log_error "Checksum mismatch for ${util}: expected ${sha_expected}, got ${sha_actual}" + exit $EXIT_CHECKSUM + fi + log_info "Verified ${util}" + fi + done + utils_dir="$tmp" + fi - # Show completion message - show_completion_message + for util in "${UTILS[@]}"; do + # shellcheck source=/dev/null + source "$utils_dir/$util" + done } -# Platform detection (fallback if not in separate script) -detect_platform() { - if command -v detect_os_arch >/dev/null 2>&1; then - detect_os_arch - return +# SHA-256 of a file, portable across GNU and BSD userlands. +calculate_sha256() { + local file=$1 + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$file" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$file" | awk '{print $1}' + elif command -v openssl >/dev/null 2>&1; then + openssl dgst -sha256 "$file" | awk '{print $NF}' + else + log_error "No SHA-256 tool available (sha256sum, shasum, or openssl)" + return 1 fi - - # Fallback implementation - local os=$(uname -s | tr '[:upper:]' '[:lower:]') - local arch=$(uname -m) - - case $os in - linux*) OS="linux" ;; - darwin*) OS="macos" ;; - cygwin*|mingw*|msys*) OS="windows" ;; - *) - log_error "Unsupported OS: $os" - exit 1 - ;; - esac - - case $arch in - x86_64|amd64) ARCH="x86_64" ;; - aarch64|arm64) ARCH="aarch64" ;; - armv7*|armv6*) ARCH="armv7" ;; - *) - log_error "Unsupported architecture: $arch" - exit 1 - ;; - esac - - export OS ARCH } -# Check basic dependencies check_dependencies() { - local missing_deps=() - - # Check for curl - if ! command -v curl >/dev/null 2>&1; then - missing_deps+=("curl") + local missing=() + command -v curl >/dev/null 2>&1 || missing+=("curl") + command -v tar >/dev/null 2>&1 || missing+=("tar") + command -v python3 >/dev/null 2>&1 || missing+=("python3") + if ! command -v sha256sum >/dev/null 2>&1 && ! command -v shasum >/dev/null 2>&1 && ! command -v openssl >/dev/null 2>&1; then + missing+=("sha256sum, shasum or openssl") fi - - # Check for sha256sum or shasum - if ! command -v sha256sum >/dev/null 2>&1 && ! command -v shasum >/dev/null 2>&1; then - missing_deps+=("sha256sum or shasum") + if [[ ${#missing[@]} -gt 0 ]]; then + log_error "Missing dependencies: ${missing[*]}" + exit $EXIT_INSTALL fi - - if [[ ${#missing_deps[@]} -gt 0 ]]; then - log_error "Missing dependencies: ${missing_deps[*]}" - log_error "Please install the missing dependencies and try again." - exit 1 - fi - - log_success "All dependencies found" + log_info "Dependencies satisfied" } -# Create installation directory create_install_directory() { if [[ ! -d "$INSTALL_DIR" ]]; then log_progress "Creating installation directory: $INSTALL_DIR" mkdir -p "$INSTALL_DIR" fi - - # Check if directory is writable if [[ ! -w "$INSTALL_DIR" ]]; then log_error "Installation directory is not writable: $INSTALL_DIR" - log_error "Try running with sudo or specify a different directory with --install-dir" - exit 1 + log_error "Re-run with sudo, or choose another directory with --install-dir" + exit $EXIT_INSTALL fi - log_success "Installation directory ready: $INSTALL_DIR" } -# Binary resolution (fallback if not in separate script) -resolve_binary_url() { +# Download, verify and unpack one tool. Everything happens in a staging +# directory; the destination is only touched once the bytes are verified and +# the archive is proved to contain exactly the expected binary. +install_tool() { local tool=$1 - local version=${2:-"latest"} - - # Use external script if available - if command -v resolve_best_asset >/dev/null 2>&1; then - # Temporarily disable verbose output for clean resolution - local old_verbose="$VERBOSE" - VERBOSE=false - - local resolution_output - resolution_output=$(resolve_best_asset "$tool" "$version" 2>/dev/null) - - # Restore verbose setting - VERBOSE="$old_verbose" - - # Extract the ASSET_URL from the output - local asset_url - asset_url=$(echo "$resolution_output" | grep "^ASSET_URL=" | cut -d'=' -f2-) - - echo "$asset_url" - return - fi - - # Fallback implementation - if [[ "$version" == "latest" ]]; then - # Get latest release tag - version=$(curl -s "${GITHUB_API_BASE}/releases/latest" | grep -o '"tag_name": "[^"]*' | sed 's/"tag_name": "//' | sed 's/"//') - if [[ -z "$version" ]]; then - log_error "Failed to get latest version from GitHub API" - exit 1 + local resolved name url checksum version + local resolution + + log_progress "Resolving $tool (version: $VERSION)" + # Capture the exit status before negating the command: `if ! cmd` leaves $? + # holding the outcome of the negation, not of cmd. + local status=0 + resolution=$(resolve_best_asset "$tool" "$VERSION" 2>/dev/null) || status=$? + if [[ $status -ne 0 ]]; then + if [[ $status -eq 2 ]]; then + log_error "Version ${VERSION#v} is not available for $tool" + return $EXIT_VERSION fi + log_error "Could not resolve $tool from the release channel" + return $EXIT_MANIFEST fi - # Remove 'v' prefix if present - version=${version#v} - - # Determine asset name - local asset_name - if [[ "$OS" == "macos" ]]; then - asset_name="${tool}-universal-apple-darwin" - elif [[ "$OS" == "windows" ]]; then - asset_name="${tool}-windows-x86_64.exe" - else - asset_name="${tool}-${OS}-${ARCH}" + name=$(echo "$resolution" | grep '^ASSET_NAME=' | cut -d'=' -f2-) + url=$(echo "$resolution" | grep '^ASSET_URL=' | cut -d'=' -f2-) + checksum=$(echo "$resolution" | grep '^ASSET_CHECKSUM=' | cut -d'=' -f2-) + version=$(echo "$resolution" | grep '^ASSET_VERSION=' | cut -d'=' -f2-) + + local staging + staging=$(mktemp -d) + # shellcheck disable=SC2064 + trap "rm -rf '$staging'" RETURN + + local archive="$staging/$name" + log_progress "Downloading $tool ${version}" + if ! terraphim_curl --output "$archive" "$url"; then + log_error "Download failed: $url" + return $EXIT_DOWNLOAD fi - - local asset_url="${GITHUB_RELEASES}/v${version}/${asset_name}" - - # Check if asset exists - if curl --silent --fail --head "$asset_url" >/dev/null; then - echo "$asset_url" + local size + size=$(wc -c <"$archive" | tr -d ' ') + log_success "Downloaded $name (${size} bytes)" + + if [[ "$SKIP_VERIFY" == "true" ]]; then + log_warn "Skipping SHA-256 verification (--skip-verify)" + elif [[ -z "$checksum" ]]; then + log_warn "Manifest carried no checksum for $name; install continues unverified" else - log_warn "Pre-built binary not found: $asset_name" - echo "source" + local actual + actual=$(calculate_sha256 "$archive") + if [[ "$actual" != "$checksum" ]]; then + log_error "Checksum mismatch for $name" + log_error " expected: $checksum" + log_error " actual: $actual" + return $EXIT_CHECKSUM + fi + log_success "SHA-256 verified" fi -} -# Install binary from URL -install_binary() { - local tool=$1 - local url=$2 - local filename=$(basename "$url") - local install_path="$INSTALL_DIR/$filename" - - log_progress "Downloading $tool..." - - # Download with progress - curl --progress-bar \ - --location \ - --retry 3 \ - --retry-delay 1 \ - --output "$install_path" \ - "$url" - - # Make executable (except for Windows .exe files) - if [[ ! "$filename" =~ \.exe$ ]]; then - chmod +x "$install_path" - fi + # Unpack into an isolated directory and insist on finding exactly the + # expected binary. A tar or zip that carries something else is a failure, + # not an installation. + local unpack="$staging/unpack" + mkdir -p "$unpack" + case "$name" in + *.tar.gz|*.tgz) + tar -xzf "$archive" -C "$unpack" 2>/dev/null || { + log_error "Could not extract $name"; return $EXIT_DOWNLOAD; } ;; + *.zip) + if command -v unzip >/dev/null 2>&1; then + unzip -q -o "$archive" -d "$unpack" 2>/dev/null || { + log_error "Could not extract $name"; return $EXIT_DOWNLOAD; } + elif command -v python3 >/dev/null 2>&1; then + python3 -c 'import sys,zipfile; zipfile.ZipFile(sys.argv[1]).extractall(sys.argv[2])' "$archive" "$unpack" || { + log_error "Could not extract $name"; return $EXIT_DOWNLOAD; } + else + log_error "No unzip tool available for $name"; return $EXIT_DOWNLOAD + fi ;; + *) + log_error "Unsupported archive type: $name"; return $EXIT_DOWNLOAD ;; + esac - log_success "Downloaded $tool to $install_path" -} + local binary_name="$tool" + [[ "$name" == *.zip ]] && binary_name="${tool}.exe" -# Install from source (placeholder) -install_from_source() { - local tool=$1 - local version=${2:-"latest"} + local found + found=$(find "$unpack" -type f -name "$binary_name" -print -quit) + if [[ -z "$found" ]]; then + log_error "$name does not contain $binary_name" + log_error "Archive contents: $(find "$unpack" -type f -printf '%f ' 2>/dev/null)" + return $EXIT_INSTALL + fi - log_warn "Source compilation not yet implemented for $tool" - log_warn "Please install Rust toolchain and run: cargo install $tool" - log_info "For installation instructions, visit: https://docs.terraphim.ai/installation" + chmod +x "$found" + mv -f "$found" "$INSTALL_DIR/$binary_name" + # Friendlier invocation name on platforms where the binary is dotted. + if [[ "$binary_name" == *.exe ]]; then + ln -sf "$binary_name" "$INSTALL_DIR/$tool" 2>/dev/null || true + fi - # For now, we'll skip source installation - log_warn "Skipping $tool installation" + log_success "$tool installed to $INSTALL_DIR/$binary_name" + return 0 } -# Verify installation verify_installation() { local tool=$1 - - # Try to find the binary - local binary_path="" - for ext in "" ".exe"; do - if [[ -f "$INSTALL_DIR/$tool$ext" ]]; then - binary_path="$INSTALL_DIR/$tool$ext" - break - fi - done - - if [[ -z "$binary_path" ]]; then + local binary="$INSTALL_DIR/$tool" + [[ -x "$binary" ]] || binary="$INSTALL_DIR/${tool}.exe" + if [[ ! -x "$binary" ]]; then log_error "$tool binary not found in $INSTALL_DIR" return 1 fi - - # Test if binary runs - if "$binary_path" --version >/dev/null 2>&1; then - local installed_version=$("$binary_path" --version 2>/dev/null || echo "unknown") - log_success "$tool is working (version: $installed_version)" + local reported + if reported=$("$binary" --version 2>/dev/null); then + log_success "$tool --version -> ${reported}" else - log_warn "$tool binary installed but failed version check" + log_warn "$tool is present but did not respond to --version" fi + return 0 } -# Setup basic configuration -setup_configuration() { - local config_dir="$HOME/.config/terraphim" - - if [[ ! -d "$config_dir" ]]; then - log_progress "Creating configuration directory..." - mkdir -p "$config_dir" - fi - - # Create default config if it doesn't exist - local config_file="$config_dir/config.json" - if [[ ! -f "$config_file" ]]; then - log_progress "Creating default configuration..." - cat > "$config_file" << 'EOF' -{ - "name": "Terraphim Engineer", - "relevance_function": "TerraphimGraph", - "theme": "spacelab", - "haystacks": [ - { - "name": "Local Documents", - "service": "Ripgrep", - "location": "~/Documents", - "extra_parameters": { - "glob": "*.md,*.txt,*.rst,*.rs,*.js,*.ts" - } - } - ], - "update_channel": "stable", - "auto_update": true -} -EOF - log_success "Default configuration created: $config_file" - fi -} - -# Setup PATH in shell configs setup_path() { - local install_dir=$1 - - # Skip if directory is already in PATH - if echo "$PATH" | grep -q "$install_dir"; then - log_info "Installation directory already in PATH" - return - fi - - log_progress "Adding $install_dir to PATH..." - - # Detect current shell and update config - local current_shell=$(basename "$SHELL") - local config_file="" + local rc line="export PATH=\"\$PATH:$INSTALL_DIR\"" + case ":${PATH}:" in + *":${INSTALL_DIR}:"*) log_info "$INSTALL_DIR is already on PATH"; return 0 ;; + esac - case $current_shell in - bash) - config_file="$HOME/.bashrc" - if [[ -f "$HOME/.bash_profile" ]]; then - config_file="$HOME/.bash_profile" - fi - ;; - zsh) - config_file="$HOME/.zshrc" - ;; - fish) - config_file="$HOME/.config/fish/config.fish" - ;; - *) - log_warn "Unsupported shell: $current_shell" - log_warn "Please add $install_dir to your PATH manually" - return - ;; + case "${SHELL:-}" in + */zsh) rc="$HOME/.zshrc" ;; + */bash) rc="$HOME/.bashrc" ;; + *) rc="$HOME/.profile" ;; esac - # Add to config if not already present - if [[ -f "$config_file" ]] && ! grep -q "$install_dir" "$config_file"; then - echo "" >> "$config_file" - echo "# Terraphim AI" >> "$config_file" - if [[ "$current_shell" == "fish" ]]; then - echo "set -gx PATH \$PATH $install_dir" >> "$config_file" - else - echo "export PATH=\"\$PATH:$install_dir\"" >> "$config_file" - fi - log_success "Added to $config_file" + if [[ -f "$rc" ]] && grep -Fq "$line" "$rc" 2>/dev/null; then + log_info "PATH entry already present in $rc" + else + printf '\n# Added by the Terraphim installer\n%s\n' "$line" >>"$rc" + log_success "Added $INSTALL_DIR to PATH in $rc" fi - - # Update current session - export PATH="$PATH:$install_dir" + log_warn "Restart your shell, or run: export PATH=\"\$PATH:$INSTALL_DIR\"" } -# Show completion message show_completion_message() { echo - log_success "Installation completed successfully!" + echo -e "${BOLD}Terraphim installed${NC}" echo - echo "Installed tools:" + echo " Installed to: $INSTALL_DIR" for tool in "${TOOLS_TO_INSTALL[@]}"; do - echo " - $tool" + echo " - $tool" done echo - echo "Installation directory: $INSTALL_DIR" - echo "Configuration directory: $HOME/.config/terraphim" + echo "Next steps:" + echo " terraphim-agent --version" + echo " terraphim-agent repl" echo - echo "To get started:" - if [[ " ${TOOLS_TO_INSTALL[@]} " =~ " terraphim-agent " ]]; then - echo " terraphim-agent --help" - fi - if [[ " ${TOOLS_TO_INSTALL[@]} " =~ " terraphim-cli " ]]; then - echo " terraphim-cli --help" - fi - echo - echo "Note: You may need to restart your terminal or run:" - echo " source ~/.bashrc # or ~/.zshrc, depending on your shell" - echo - echo "For more information, visit: https://docs.terraphim.ai" + echo "Docs: ${SOURCE_URL%/scripts/install.sh}" + echo "Releases: https://github.com/terraphim/terraphim-clients/releases" +} + +main() { + parse_args "$@" + show_banner + + check_dependencies + load_utils + + log_progress "Detecting platform" + detect_os_arch + export OS ARCH + log_success "Platform: ${OS}-${ARCH}" + + local tool + for tool in "${TOOLS_TO_INSTALL[@]}"; do + if ! is_supported_tool "$tool"; then + log_error "Unsupported tool: $tool" + log_error "Published binaries: ${SUPPORTED_TOOLS[*]}" + exit $EXIT_USAGE + fi + done + + create_install_directory + + for tool in "${TOOLS_TO_INSTALL[@]}"; do + local status=0 + install_tool "$tool" || status=$? + [[ $status -ne 0 ]] && exit $status + verify_installation "$tool" || true + done + + setup_path + show_completion_message } -# Run main function if script is executed directly -if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then +if [[ "${BASH_SOURCE[0]:-}" == "${0}" ]]; then main "$@" -fi +fi \ No newline at end of file diff --git a/scripts/test-installer.sh b/scripts/test-installer.sh index 659449a23..5bed779eb 100755 --- a/scripts/test-installer.sh +++ b/scripts/test-installer.sh @@ -1,120 +1,183 @@ #!/bin/bash -# Test script for the Terraphim AI Universal Installer - -set -euo pipefail - -# Colors -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' - -# Test configuration -TEST_DIR="/tmp/terraphim-installer-test" -INSTALLER_SCRIPT="$(dirname "${BASH_SOURCE[0]}")/install.sh" - -log_info() { - echo -e "${BLUE}ℹ${NC} $*" +# Installer release-gate test. +# +# Exercises the installer against the live public release channel and proves +# that a manipulated release cannot be installed. This test downloads real +# archives and, where it can, runs the real binary; it never substitutes a +# fixture for the channel. +# +# Usage: bash scripts/test-installer.sh [--keep] +# +# Exit codes: 0 all checks passed, 1 one or more checks failed. + +set -uo pipefail + +readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +readonly INSTALLER="$SCRIPT_DIR/install.sh" +readonly RESOLVER="$SCRIPT_DIR/binary-resolution.sh" +readonly WORK="$(mktemp -d)" + +KEEP=0 +[[ "${1:-}" == "--keep" ]] && KEEP=1 + +PASS=0 +FAIL=0 + +cleanup() { + [[ $KEEP -eq 1 ]] && { echo "work dir kept: $WORK"; return; } + rm -rf "$WORK" } +trap cleanup EXIT -log_success() { - echo -e "${GREEN}✓${NC} $*" -} - -log_error() { - echo -e "${RED}✗${NC} $*" -} - -log_warn() { - echo -e "${YELLOW}⚠${NC} $*" -} +blue() { echo -e "\033[0;34m$*\033[0m"; } +green() { echo -e "\033[0;32m$*\033[0m"; } +red() { echo -e "\033[0;31m$*\033[0m"; } -# Test function -test_installer() { - local test_name=$1 - local installer_args=$2 - - log_info "Testing: $test_name" - - # Clean test directory - rm -rf "$TEST_DIR" - mkdir -p "$TEST_DIR" - - # Run installer with test arguments - if bash "$INSTALLER_SCRIPT" $installer_args --install-dir "$TEST_DIR" --version v1.0.0 --skip-verify >/dev/null 2>&1; then - log_success "$test_name - PASS" - else - log_error "$test_name - FAIL" - return 1 - fi - - # Check if installer created expected output (even if source compilation failed) - if [[ -d "$TEST_DIR" ]]; then - log_success "Installation directory created" +check() { + local label=$1 expected=$2 actual=$3 + if [[ "$expected" == "$actual" ]]; then + green " PASS $label" + PASS=$((PASS + 1)) else - log_warn "Installation directory not created (expected for source fallback)" + red " FAIL $label (expected '$expected', got '$actual')" + FAIL=$((FAIL + 1)) fi - - # Cleanup - rm -rf "$TEST_DIR" } -# Main test suite -main() { - echo "=== Terraphim AI Installer Test Suite ===" - echo - - # Check if installer script exists - if [[ ! -f "$INSTALLER_SCRIPT" ]]; then - log_error "Installer script not found: $INSTALLER_SCRIPT" - exit 1 - fi - - log_success "Found installer script: $INSTALLER_SCRIPT" - - # Test 1: Help functionality - log_info "Testing help functionality..." - if bash "$INSTALLER_SCRIPT" --help >/dev/null 2>&1; then - log_success "Help test - PASS" - else - log_error "Help test - FAIL" - fi - - # Test 2: Platform detection - log_info "Testing platform detection..." - if bash "$(dirname "$INSTALLER_SCRIPT")/platform-detection.sh" >/dev/null 2>&1; then - log_success "Platform detection test - PASS" - else - log_error "Platform detection test - FAIL" - fi +blue "Installer release-gate test" +echo " installer: $INSTALLER" +echo " work dir: $WORK" +echo + +# -------------------------------------------------------------------------- +# 1. Static checks +# -------------------------------------------------------------------------- +blue "1. Static checks" + +bash -n "$INSTALLER" 2>/dev/null +check "install.sh parses" "0" "$?" +bash -n "$RESOLVER" 2>/dev/null +check "binary-resolution.sh parses" "0" "$?" + +# The installer must not resolve releases from the version-less GitHub assets. +if grep -q 'terraphim-ai/releases/download' "$INSTALLER" "$RESOLVER"; then + check "no GitHub Releases download path" "absent" "present" +else + check "no GitHub Releases download path" "absent" "absent" +fi - # Test 3: Binary resolution - log_info "Testing binary resolution..." - if bash "$(dirname "$INSTALLER_SCRIPT")/binary-resolution.sh" terraphim-agent latest >/dev/null 2>&1; then - log_success "Binary resolution test - PASS" - else - log_error "Binary resolution test - FAIL" - fi +# -------------------------------------------------------------------------- +# 2. Manifest resolution +# -------------------------------------------------------------------------- +blue "2. Manifest resolution" + +resolution=$("$RESOLVER" terraphim-agent latest 2>/dev/null) +check "resolver reports a channel version" "1.21.16" \ + "$(echo "$resolution" | grep '^ASSET_VERSION=' | cut -d'=' -f2-)" +check "resolver reports a strict-manifest checksum" "64" \ + "$(echo "$resolution" | grep '^ASSET_CHECKSUM=' | cut -d'=' -f2- | tr -d '\n' | wc -c | tr -d ' ')" + +# An unavailable version must be refused, not silently substituted. +"$RESOLVER" terraphim-agent 1.20.5 >/dev/null 2>&1 +check "unavailable version refused (exit 2)" "2" "$?" + +# -------------------------------------------------------------------------- +# 3. Install and run the real binary +# -------------------------------------------------------------------------- +blue "3. Install and run" + +install_dir="$WORK/install" +"$INSTALLER" --install-dir "$install_dir" --verbose >"$WORK/install.log" 2>&1 +check "installer exits 0" "0" "$?" +check "binary is present" "present" \ + "$([[ -x "$install_dir/terraphim-agent" ]] && echo present || echo absent)" + +if [[ -x "$install_dir/terraphim-agent" ]]; then + reported=$("$install_dir/terraphim-agent" --version 2>/dev/null || echo "no-version") + check "installed binary reports 1.21.16" "terraphim-agent 1.21.16" "$reported" + size=$(wc -c <"$install_dir/terraphim-agent" | tr -d ' ') + check "installed binary is non-trivial" "yes" \ + "$([[ "$size" -gt 1000000 ]] && echo yes || echo no)" +else + check "installed binary runs" "runs" "binary absent" +fi - # Test 4: Security verification - log_info "Testing security verification..." - if bash "$(dirname "$INSTALLER_SCRIPT")/security-verification.sh" >/dev/null 2>&1; then - log_success "Security verification test - PASS" +# -------------------------------------------------------------------------- +# 4. Fail-closed on a manipulated release +# -------------------------------------------------------------------------- +blue "4. Fail-closed on manipulation" + +# Serve a manifest whose digests are wrong, proxying archive bytes to the real +# channel. The installer must refuse to install. +cat >"$WORK/proxy.py" <<'PY' +import http.server, socketserver, urllib.request + +CHANNEL = "https://downloads.terraphim.ai" +UA = "terraphim-installer/2.0.0" + +class Handler(http.server.SimpleHTTPRequestHandler): + def do_GET(self): + if self.path.endswith("stable-v2.json"): + return super().do_GET() + req = urllib.request.Request(CHANNEL + self.path, headers={"User-Agent": UA}) + with urllib.request.urlopen(req, timeout=60) as upstream: + body = upstream.read() + self.send_response(200) + self.send_header("Content-Type", "application/octet-stream") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *args): + pass + +socketserver.TCPServer.allow_reuse_address = True +with socketserver.TCPServer(("127.0.0.1", 0), Handler) as httpd: + print(httpd.server_address[1], flush=True) + httpd.serve_forever() +PY + +manifest_dir="$WORK/manifest/terraphim-agent" +mkdir -p "$manifest_dir" +if curl -fsS --user-agent "terraphim-installer/2.0.0" \ + "https://downloads.terraphim.ai/terraphim-agent/stable-v2.json" \ + -o "$WORK/real-manifest.json"; then + check "live manifest fetchable" "ok" "ok" + python3 - "$WORK/real-manifest.json" "$manifest_dir/stable-v2.json" <<'PY' +import json, sys +data = json.load(open(sys.argv[1])) +for asset in data["assets"].values(): + asset["sha256"] = "0" * 64 +json.dump(data, open(sys.argv[2], "w")) +PY + + (cd "$WORK/manifest" && exec python3 "$WORK/proxy.py") >"$WORK/proxy.port" 2>"$WORK/proxy.err" & + proxy_pid=$! + for _ in $(seq 1 50); do + [[ -s "$WORK/proxy.port" ]] && break + sleep 0.1 + done + + if [[ -s "$WORK/proxy.port" ]]; then + port=$(head -1 "$WORK/proxy.port") + bad_dir="$WORK/bad-install" + TERRAPHIM_CHANNEL_BASE="http://127.0.0.1:${port}" \ + "$INSTALLER" --install-dir "$bad_dir" >"$WORK/bad.log" 2>&1 + check "tampered release refused (exit 5)" "5" "$?" + check "nothing installed from tampered release" "0" \ + "$(ls -A "$bad_dir" 2>/dev/null | wc -l | tr -d ' ')" else - log_error "Security verification test - FAIL" + check "tamper test server started" "started" "failed: $(cat "$WORK/proxy.err" 2>/dev/null)" fi + kill "$proxy_pid" 2>/dev/null + wait "$proxy_pid" 2>/dev/null +else + check "live manifest fetchable" "ok" "unreachable" +fi - echo - log_info "Installer functionality tests completed." - log_info "Note: Source compilation fallback is expected behavior when no pre-built binaries are available." - - echo - log_success "All critical installer components are working correctly!" - log_info "The installer is ready for production use." -} +# -------------------------------------------------------------------------- +echo +blue "Results: ${PASS} passed, ${FAIL} failed" +[[ $FAIL -eq 0 ]] || exit 1 -# Run tests -if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then - main "$@" -fi +green "Installer release gate passed." \ No newline at end of file