| #!/bin/sh |
| # |
| # Licensed to the Apache Software Foundation (ASF) under one or more |
| # contributor license agreements. See the NOTICE file distributed with |
| # this work for additional information regarding copyright ownership. |
| # The ASF licenses this file to You under the Apache License, Version 2.0 |
| # (the "License"); you may not use this file except in compliance with |
| # the License. You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| # See the License for the specific language governing permissions and |
| # limitations under the License. |
| # |
| |
| set -eu |
| |
| # Test seams only: production installs never set these, so the defaults below are always used. |
| manifest_base_url="${CAMEL_INSTALL_MANIFEST_BASE_URL:-https://camel.apache.org/camel-cli/releases}" |
| maven_base_url="${CAMEL_INSTALL_MAVEN_BASE_URL:-https://repo1.maven.org/maven2/org/apache/camel/camel-launcher}" |
| ca_cert="${CAMEL_INSTALL_CA_CERT:-}" |
| |
| data_root="${XDG_DATA_HOME:-$HOME/.local/share}/camel-cli/versions" |
| camel_cli_root="${XDG_DATA_HOME:-$HOME/.local/share}/camel-cli" |
| bin_dir="$HOME/.local/bin" |
| |
| fail() { |
| echo "install.sh: $1" 1>&2 |
| exit 1 |
| } |
| |
| is_valid_version() { |
| v="$1" |
| case "$v" in |
| '' | .* | *. | *..* | *[!0-9.]*) return 1 ;; |
| esac |
| # The case above guarantees digits and single dots only, with no leading, trailing, or doubled |
| # dot, so a valid X.Y.Z is exactly the value with three non-empty components (two dots). |
| case "$v" in |
| *.*.*.*) return 1 ;; |
| *.*.*) return 0 ;; |
| *) return 1 ;; |
| esac |
| } |
| |
| validate_sha256() { |
| value="$1" |
| label="$2" |
| [ ${#value} -eq 64 ] || fail "$label is not a 64-character lowercase hex value" |
| case "$value" in |
| *[!0-9a-f]*) fail "$label is not a 64-character lowercase hex value" ;; |
| esac |
| } |
| |
| fetch() { |
| url="$1" |
| outfile="$2" |
| if command -v curl >/dev/null 2>&1; then |
| if [ -n "$ca_cert" ]; then |
| curl -fsSL --cacert "$ca_cert" -o "$outfile" "$url" |
| else |
| curl -fsSL -o "$outfile" "$url" |
| fi |
| elif command -v wget >/dev/null 2>&1; then |
| if [ -n "$ca_cert" ]; then |
| wget -nv --ca-certificate="$ca_cert" -O "$outfile" "$url" |
| else |
| wget -nv -O "$outfile" "$url" |
| fi |
| else |
| fail "curl or wget is required to install the Camel CLI" |
| fi |
| } |
| |
| sha256() { |
| 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 |
| fail "sha256sum, shasum, or openssl is required to verify the download" |
| fi |
| } |
| |
| # Reads $manifest_file line by line without ever sourcing, dot-invoking, or eval-ing its content. |
| parse_manifest() { |
| file="$1" |
| cr=$(printf '\r') |
| p_format="" |
| p_version="" |
| p_tar="" |
| p_zip="" |
| seen_format=0 |
| seen_version=0 |
| seen_tar=0 |
| seen_zip=0 |
| line_count=0 |
| while IFS='=' read -r key value || [ -n "$key" ]; do |
| # Tolerate a manifest served with CRLF line endings, matching the PowerShell parser. |
| key="${key%"$cr"}" |
| value="${value%"$cr"}" |
| # Skip comment lines (properties-style '#', and the '##' ASF license header the website's |
| # manifest generator prepends). They carry no data, so they are neither counted toward the |
| # required line total nor validated as key=value pairs. Blank lines are still rejected below. |
| case "$key" in |
| \#*) continue ;; |
| esac |
| line_count=$((line_count + 1)) |
| [ -n "$key" ] || fail "manifest contains a blank line" |
| [ -n "$value" ] || fail "manifest key '$key' has an empty value" |
| case "$key" in |
| format) |
| [ "$seen_format" -eq 0 ] || fail "manifest has duplicate key: format" |
| seen_format=1 |
| p_format="$value" |
| ;; |
| version) |
| [ "$seen_version" -eq 0 ] || fail "manifest has duplicate key: version" |
| seen_version=1 |
| p_version="$value" |
| ;; |
| tar_sha256) |
| [ "$seen_tar" -eq 0 ] || fail "manifest has duplicate key: tar_sha256" |
| seen_tar=1 |
| p_tar="$value" |
| ;; |
| zip_sha256) |
| [ "$seen_zip" -eq 0 ] || fail "manifest has duplicate key: zip_sha256" |
| seen_zip=1 |
| p_zip="$value" |
| ;; |
| *) |
| fail "manifest has unknown key: $key" |
| ;; |
| esac |
| done < "$file" |
| |
| if [ "$seen_format" -ne 1 ] || [ "$seen_version" -ne 1 ] || [ "$seen_tar" -ne 1 ] || [ "$seen_zip" -ne 1 ]; then |
| fail "manifest is missing a required key" |
| fi |
| [ "$line_count" -eq 4 ] || fail "manifest must contain exactly four lines" |
| |
| [ "$p_format" = "1" ] || fail "unsupported manifest format: $p_format" |
| is_valid_version "$p_version" || fail "manifest version is not a valid X.Y.Z value" |
| validate_sha256 "$p_tar" "manifest tar_sha256" |
| validate_sha256 "$p_zip" "manifest zip_sha256" |
| } |
| |
| # Lists archive entries with 'tar -tzf' and rejects absolute paths, traversal, symlinks/hardlinks, |
| # multiple top-level roots, and a missing expected launcher, before anything is extracted. |
| validate_tar() { |
| archive="$1" |
| version="$2" |
| expected_root="camel-launcher-$version" |
| |
| listing="$staging_dir/listing.txt" |
| tar_err=$(tar -tzf "$archive" 2>&1 1>"$listing") || fail "archive is not a valid tar.gz${tar_err:+: $tar_err}" |
| |
| verbose_listing="$staging_dir/listing-verbose.txt" |
| # LC_ALL=C keeps tar's hard-link annotation as the literal 'link to' string; GNU tar localizes it. |
| tar_err=$(LC_ALL=C tar -tvzf "$archive" 2>&1 1>"$verbose_listing") \ |
| || fail "archive is not a valid tar.gz${tar_err:+: $tar_err}" |
| # tar -tv renders symlinks as 'name -> target' and hard links as 'name link to target'. Matching |
| # those substrings could in theory over-reject a regular file whose name contains ' -> ' or |
| # ' link to '; that fail-closed bias is deliberate, the release archive never carries such names. |
| grep -E -- ' -> | link to ' "$verbose_listing" >/dev/null 2>&1 \ |
| && fail "archive contains a symbolic or hard link entry, which is not allowed" |
| |
| saw_entry=0 |
| while IFS= read -r entry; do |
| [ -n "$entry" ] || continue |
| case "$entry" in |
| /*) fail "archive contains an absolute path entry: $entry" ;; |
| esac |
| case "$entry" in |
| *"../"* | ".." | *"/..") fail "archive contains a path traversal entry: $entry" ;; |
| esac |
| # Every entry must sit under the single expected top-level directory; a differing root means a |
| # stray directory or the wrong version, so reject it directly rather than collecting roots. |
| root="${entry%%/*}" |
| [ "$root" = "$expected_root" ] \ |
| || fail "archive top-level directory does not match expected version: $root" |
| saw_entry=1 |
| done < "$listing" |
| |
| [ "$saw_entry" -eq 1 ] || fail "archive must contain exactly one top-level directory" |
| |
| grep -Fqx "$expected_root/bin/camel.sh" "$listing" || fail "archive is missing bin/camel.sh" |
| } |
| |
| # Runs the freshly staged upstream launcher; a nonzero exit (e.g. no Java 17+ available) aborts |
| # the install and leaves the previously active installation untouched. |
| verify_staged() { |
| staged_sh="$1" |
| chmod +x "$staged_sh" 2>/dev/null || true |
| "$staged_sh" version >/dev/null 2>&1 || fail "staged launcher failed verification (Java 17+ required)" |
| } |
| |
| activate() { |
| version="$1" |
| staged_root_dir="$2" |
| target_dir="$data_root/$version" |
| |
| mkdir -p "$data_root" |
| rm -rf "$target_dir" |
| mv "$staged_root_dir" "$target_dir" |
| |
| mkdir -p "$bin_dir" |
| tmp_link="$bin_dir/.camel.tmp.$$" |
| rm -f "$tmp_link" |
| ln -s "$target_dir/bin/camel.sh" "$tmp_link" |
| mv -f "$tmp_link" "$bin_dir/camel" |
| } |
| |
| # Records whether this install is pinned to an explicit version, so `camel self-update` can refuse to move |
| # a deliberately pinned install. Skipped when CAMEL_INSTALL_SELF_UPDATE=true: self-update always passes its |
| # own resolved --version for TOCTOU-safety (see SelfUpdateCommand), which is not a human pinning a version |
| # and must not create or clear a pin either way. |
| update_pin_state() { |
| [ "${CAMEL_INSTALL_SELF_UPDATE:-}" = "true" ] && return 0 |
| pinned_version_file="$camel_cli_root/pinned-version" |
| if [ -n "$requested_version" ]; then |
| printf '%s\n' "$version" > "$pinned_version_file" |
| else |
| rm -f "$pinned_version_file" |
| fi |
| } |
| |
| requested_version="" |
| while [ $# -gt 0 ]; do |
| case "$1" in |
| --version) |
| [ $# -ge 2 ] || fail "Usage: install.sh [--version X.Y.Z]" |
| requested_version="$2" |
| shift 2 |
| ;; |
| *) |
| fail "Usage: install.sh [--version X.Y.Z]" |
| ;; |
| esac |
| done |
| |
| if [ -n "$requested_version" ]; then |
| is_valid_version "$requested_version" || fail "invalid --version value: $requested_version (expected X.Y.Z)" |
| fi |
| |
| staging_dir="$camel_cli_root/.staging.$$" |
| cleanup() { |
| rm -rf "$staging_dir" |
| } |
| trap cleanup EXIT INT TERM |
| |
| mkdir -p "$camel_cli_root" |
| rm -rf "$staging_dir" |
| mkdir -m 700 "$staging_dir" |
| |
| if [ -n "$requested_version" ]; then |
| manifest_url="$manifest_base_url/$requested_version.properties" |
| else |
| manifest_url="$manifest_base_url/latest.properties" |
| fi |
| |
| manifest_file="$staging_dir/manifest.properties" |
| fetch "$manifest_url" "$manifest_file" || fail "failed to download manifest from $manifest_url" |
| |
| parse_manifest "$manifest_file" |
| version="$p_version" |
| |
| if [ -n "$requested_version" ] && [ "$requested_version" != "$version" ]; then |
| fail "manifest version ($version) does not match requested version ($requested_version)" |
| fi |
| |
| archive_url="$maven_base_url/$version/camel-launcher-$version-bin.tar.gz" |
| archive_file="$staging_dir/camel-launcher-$version-bin.tar.gz" |
| fetch "$archive_url" "$archive_file" || fail "failed to download archive from $archive_url" |
| |
| actual_tar_sha256="$(sha256 "$archive_file")" |
| [ "$actual_tar_sha256" = "$p_tar" ] || fail "checksum mismatch for downloaded archive" |
| |
| validate_tar "$archive_file" "$version" |
| |
| extract_dir="$staging_dir/extract" |
| mkdir -p "$extract_dir" |
| tar -xzf "$archive_file" -C "$extract_dir" |
| |
| staged_root_dir="$extract_dir/camel-launcher-$version" |
| verify_staged "$staged_root_dir/bin/camel.sh" |
| |
| activate "$version" "$staged_root_dir" |
| update_pin_state |
| |
| case ":$PATH:" in |
| *":$bin_dir:"*) ;; |
| *) echo "Add $bin_dir to your PATH to use the 'camel' command." ;; |
| esac |
| |
| echo "Installed Camel CLI $version to $data_root/$version" |