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