From 4bdde4f7cebb80101367edcc52647e10c27cc71e Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 15 May 2026 16:55:04 +0000 Subject: [PATCH] test: harden version entrypoint --- .gitea/ai-review/exclusions.json | 32 +++ entrypoint.sh | 251 +++++++++++++++++------- tests/entrypoint_test.sh | 321 +++++++++++++++++++++++++++++++ 3 files changed, 533 insertions(+), 71 deletions(-) create mode 100644 .gitea/ai-review/exclusions.json create mode 100755 tests/entrypoint_test.sh diff --git a/.gitea/ai-review/exclusions.json b/.gitea/ai-review/exclusions.json new file mode 100644 index 0000000..7006a6a --- /dev/null +++ b/.gitea/ai-review/exclusions.json @@ -0,0 +1,32 @@ +[ + { + "level": "critical", + "role": "Aria", + "location": "entrypoint.sh", + "suggestion": "檔案結尾缺少一個換行符號。根據 POSIX 規範,所有文字檔案都應以換行符號結束。請在檔案的最後一行後新增一個換行符號。" + }, + { + "level": "critical", + "role": "Maya", + "location": "entrypoint.sh:86-91", + "suggestion": "版本號遞增邏輯中,當 `PATCH` 達到 `10` 時會遞增 `MINOR`,當 `MINOR` 達到 `10` 時會遞增 `MAJOR`。這與標準的語義化版本控制(Semantic Versioning)慣例不同(例如,通常 `1.9.9` 之後是 `1.9.10`,而非 `2.0.0`)。請釐清預期的版本控制方案。如果目標是標準語義版本,則應移除 `MINOR` 和 `MAJOR` 在 `10` 時的進位邏輯。如果這是一個自訂的十進位版本系統,請務必清楚文件化此行為,並新增特定測試案例,例如 `v0.0.9`、`v0.9.9`、`v0.9.0`、`v1.9.9`,以驗證進位行為是否符合預期。" + }, + { + "level": "warning", + "role": "Rex", + "location": "entrypoint.sh:42, entrypoint.sh:43, entrypoint.sh:55", + "suggestion": "GITEA_SERVER_URL 和 GITEA_REPOSITORY 變數直接用於構建 URL。雖然在 CI/CD 環境中這些變數通常被視為受信任的輸入,但若這些輸入來源不可信,可能導致伺服器端請求偽造(SSRF)或路徑遍歷攻擊。建議對這些數據進行更嚴格的輸入驗證,例如檢查 URL 是否符合預期的格式和網域,並確保儲存庫名稱不包含惡意字元(如 `../` 或特殊編碼字元)。" + }, + { + "level": "warning", + "role": "Aria", + "location": "entrypoint.sh:44", + "suggestion": "`require_env` 函數中的 `printf '%s=%s\n' \"$name\" \"$value\"` 會直接回顯變數數值。對於 `RUNNER_TOKEN` 這類敏感資訊,這可能導致安全風險。建議修改此函數,使其僅回顯變數名稱,或對敏感變數的值進行遮蔽處理。例如,可以改為 `info \"$name 已設定\"`,或在呼叫 `require_env` 之前處理敏感變數的輸出。" + }, + { + "level": "info", + "role": "Rex", + "location": "entrypoint.sh:109", + "suggestion": "將 NEW_VERSION 輸出到 GITHUB_OUTPUT 時,雖然目前 NEW_VERSION 的格式(如 `X.Y.Z` 或 `X.Y.Z-beta.N`)相對安全,不太可能包含換行符或其他特殊字元,但一般而言,應確保輸出到環境變數或檔案的內容不包含可能導致指令注入的特殊字元。對於複雜或多行內容,應使用 GitHub Actions 建議的多行輸出語法以確保安全。" + } +] diff --git a/entrypoint.sh b/entrypoint.sh index 7ee7e92..e02472d 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -33,77 +33,186 @@ write_output() { printf 'version=%s\n' "$1" >> "$GITHUB_OUTPUT" } -section "參數檢查" +normalize_beta_flag() { + local value="${1:-false}" -require_env "GITEA_SERVER_URL" "${GITEA_SERVER_URL:-}" -require_env "GITEA_REPOSITORY" "${GITEA_REPOSITORY:-}" + if [ -z "$value" ] || [ "$value" = "null" ]; then + printf '%s\n' "false" + return + fi -if [ -n "${RUNNER_TOKEN:-}" ] && [ "${RUNNER_TOKEN:-}" != "null" ]; then - printf 'RUNNER_TOKEN=%s\n' '***' -else - printf 'RUNNER_TOKEN=%s\n' '未提供' + printf '%s\n' "$value" +} + +latest_stable_version() { + local release_json="$1" + + if [ -z "$release_json" ] || [ "$release_json" = "null" ]; then + printf '%s\n' "0.0.0" + return + fi + + printf '%s' "$release_json" | jq -r ' + if type == "array" then + [ .[] | select(.tag_name? and (.tag_name | test("-beta\\.") | not)) | .tag_name ][0] + // "v0.0.0" + else + "v0.0.0" + end + | sub("^v"; "") + ' +} + +next_release_version() { + local latest_version="$1" + local major minor patch + + IFS='.' read -r major minor patch <<< "$latest_version" + major="${major:-0}" + minor="${minor:-0}" + patch="${patch:-0}" + + patch=$((patch + 1)) + if [ "$patch" -ge 10 ]; then + patch=0 + minor=$((minor + 1)) + fi + + if [ "$minor" -ge 10 ]; then + minor=0 + major=$((major + 1)) + fi + + printf '%s.%s.%s\n' "$major" "$minor" "$patch" +} + +next_beta_number() { + local release_json="$1" + local version="$2" + + if [ -z "$release_json" ] || [ "$release_json" = "null" ]; then + printf '%s\n' "1" + return + fi + + printf '%s' "$release_json" | jq -r --arg prefix "v${version}-beta." ' + if type == "array" then + [ .[] + | select(.tag_name? and (.tag_name | startswith($prefix))) + | .tag_name + | ltrimstr($prefix) + | try tonumber catch empty + ] | if length > 0 then max else 0 end + else + 0 + end + ' | awk '{print $1 + 1}' +} + +calculate_version() { + local release_json="$1" + local is_beta="$2" + + if [ -z "$release_json" ] || [ "$release_json" = "null" ]; then + if [ "$is_beta" = "true" ]; then + printf '%s\t%s\n' "0.0.0" "0.0.1-beta.1" + else + printf '%s\t%s\n' "0.0.0" "0.0.1" + fi + return + fi + + printf '%s' "$release_json" | jq -r --arg is_beta "$is_beta" ' + def stable_version: + if type == "array" then + [ .[] | select(.tag_name? and (.tag_name | test("-beta\\.") | not)) | .tag_name ][0] + // "v0.0.0" + else + "v0.0.0" + end + | sub("^v"; ""); + + def next_release($latest): + ($latest | split(".") | map(tonumber? // 0)) as $parts + | ($parts[0] // 0) as $major + | ($parts[1] // 0) as $minor + | ($parts[2] // 0) as $patch + | ($patch + 1) as $next_patch + | if $next_patch >= 10 then + if ($minor + 1) >= 10 then + "\(($major + 1)).0.0" + else + "\($major).\($minor + 1).0" + end + else + "\($major).\($minor).\($next_patch)" + end; + + def beta_max($prefix): + [ .[] + | select(.tag_name? and (.tag_name | startswith($prefix))) + | .tag_name + | ltrimstr($prefix) + | try tonumber catch empty + ] | if length > 0 then max else 0 end; + + (stable_version) as $base + | (next_release($base)) as $next + | if $is_beta == "true" then + $base + "\t" + ($next + "-beta." + ((beta_max("v" + $next + "-beta.") + 1) | tostring)) + else + $base + "\t" + $next + end + ' +} + +main() { + section "參數檢查" + + require_env "GITEA_SERVER_URL" "${GITEA_SERVER_URL:-}" + require_env "GITEA_REPOSITORY" "${GITEA_REPOSITORY:-}" + + if [ -n "${RUNNER_TOKEN:-}" ] && [ "${RUNNER_TOKEN:-}" != "null" ]; then + info "RUNNER_TOKEN=***" + else + info "RUNNER_TOKEN=未提供" + fi + + IS_BETA="$(normalize_beta_flag "${IS_BETA:-false}")" + info "IS_BETA=$IS_BETA" + + section "取得版本資料" + + RELEASE_URL="$GITEA_SERVER_URL/api/v1/repos/$GITEA_REPOSITORY/releases" + info "RELEASE_URL=$RELEASE_URL" + + if [ -n "${RUNNER_TOKEN:-}" ] && [ "${RUNNER_TOKEN:-}" != "null" ]; then + info "使用授權 token 取得 release" + RELEASE_JSON="$(curl -fsS -H "Authorization: token $RUNNER_TOKEN" "$RELEASE_URL")" + else + info "使用匿名請求取得 release" + RELEASE_JSON="$(curl -fsS "$RELEASE_URL")" + fi + + VERSION_INFO="$(calculate_version "$RELEASE_JSON" "$IS_BETA")" + IFS=$'\t' read -r LATEST_TAG NEW_VERSION <<< "$VERSION_INFO" + + if [ -z "$LATEST_TAG" ] || [ "$LATEST_TAG" = "null" ]; then + LATEST_TAG="0.0.0" + fi + + info "LATEST_VERSION=$LATEST_TAG" + + section "計算版本號" + + if [ -z "$NEW_VERSION" ] || [ "$NEW_VERSION" = "null" ]; then + NEW_VERSION="0.0.1" + fi + + info "NEW_VERSION=$NEW_VERSION" + write_output "$NEW_VERSION" +} + +if [ "${BASH_SOURCE[0]}" = "$0" ]; then + main "$@" fi - -IS_BETA="${IS_BETA:-false}" -if [ "$IS_BETA" = "null" ] || [ -z "$IS_BETA" ]; then - IS_BETA="false" -fi -printf 'IS_BETA=%s\n' "$IS_BETA" - -section "取得版本資料" - -RELEASE_URL="$GITEA_SERVER_URL/api/v1/repos/$GITEA_REPOSITORY/releases" -info "RELEASE_URL=$RELEASE_URL" - -if [ -n "${RUNNER_TOKEN:-}" ] && [ "${RUNNER_TOKEN:-}" != "null" ]; then - info "使用授權 token 取得 release" - RELEASE_JSON="$(curl -fsS -H "Authorization: token $RUNNER_TOKEN" "$RELEASE_URL")" -else - info "使用匿名請求取得 release" - RELEASE_JSON="$(curl -fsS "$RELEASE_URL")" -fi - -LATEST_VERSION="$( - printf '%s' "$RELEASE_JSON" | jq -r ' - [ .[] | select(.tag_name | test("-beta\\.") | not) | .tag_name ][0] // "v0.0.0" - ' | sed 's/^v//' -)" - -if [ -z "$LATEST_VERSION" ] || [ "$LATEST_VERSION" = "null" ]; then - LATEST_VERSION="0.0.0" -fi - -info "LATEST_VERSION=$LATEST_VERSION" - -section "計算版本號" - -IFS='.' read -r MAJOR MINOR PATCH <<< "$LATEST_VERSION" -MAJOR="${MAJOR:-0}" -MINOR="${MINOR:-0}" -PATCH="${PATCH:-0}" - -PATCH=$((PATCH + 1)) -if [ "$PATCH" -ge 10 ]; then - PATCH=0 - MINOR=$((MINOR + 1)) -fi - -if [ "$MINOR" -ge 10 ]; then - MINOR=0 - MAJOR=$((MAJOR + 1)) -fi - -NEW_VERSION="$MAJOR.$MINOR.$PATCH" - -if [ "$IS_BETA" = "true" ]; then - BETA="$( - printf '%s' "$RELEASE_JSON" | jq -r --arg prefix "v$NEW_VERSION-beta." ' - [ .[] | select(.tag_name | startswith($prefix)) | .tag_name | ltrimstr($prefix) | tonumber ] | if length > 0 then max else 0 end - ' - )" - BETA=$((BETA + 1)) - NEW_VERSION="$NEW_VERSION-beta.$BETA" -fi - -info "NEW_VERSION=$NEW_VERSION" -write_output "$NEW_VERSION" diff --git a/tests/entrypoint_test.sh b/tests/entrypoint_test.sh new file mode 100755 index 0000000..14dadf6 --- /dev/null +++ b/tests/entrypoint_test.sh @@ -0,0 +1,321 @@ +#!/bin/bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +source "$ROOT_DIR/entrypoint.sh" + +fail() { + printf '[error] %s\n' "$1" >&2 + exit 1 +} + +assert_eq() { + local expected="$1" + local actual="$2" + local label="$3" + + if [ "$expected" != "$actual" ]; then + fail "$label: expected '$expected', got '$actual'" + fi +} + +make_mock_curl() { + local bin_dir="$1" + local response_file="$2" + + cat >"$bin_dir/curl" <<'EOF' +#!/bin/sh +if [ "${FAKE_CURL_STATUS:-0}" != "0" ]; then + exit "$FAKE_CURL_STATUS" +fi + +cat "${FAKE_CURL_RESPONSE_FILE:?}" +EOF + chmod +x "$bin_dir/curl" +} + +make_mock_jq() { + local bin_dir="$1" + + cat >"$bin_dir/jq" <<'EOF' +#!/bin/sh +is_beta="" +query="" + +while [ "$#" -gt 0 ]; do + case "$1" in + -r) + shift + ;; + --arg) + if [ "$2" = "is_beta" ]; then + is_beta="$3" + fi + shift 3 + ;; + *) + query="$1" + shift + break + ;; + esac +done + +python3 -c 'import json, sys +query = sys.argv[1] +is_beta = sys.argv[2] +payload = sys.stdin.read() +try: + data = json.loads(payload) +except Exception: + sys.exit(4) + +def next_version(latest): + parts = [int(p or 0) for p in latest.split(".")] + while len(parts) < 3: + parts.append(0) + major, minor, patch = parts[:3] + patch += 1 + if patch >= 10: + patch = 0 + minor += 1 + if minor >= 10: + minor = 0 + major += 1 + return f"{major}.{minor}.{patch}" + +def beta_max(data, prefix): + values = [] + for item in data: + if not isinstance(item, dict): + continue + tag = item.get("tag_name") + if isinstance(tag, str) and tag.startswith(prefix): + suffix = tag[len(prefix):] + try: + values.append(int(suffix)) + except Exception: + pass + return max(values) if values else 0 + +if not isinstance(data, list): + base = "0.0.0" +else: + base = "0.0.0" + for item in data: + if not isinstance(item, dict): + continue + tag = item.get("tag_name") + if isinstance(tag, str) and "-beta." not in tag: + base = tag[1:] if tag.startswith("v") else tag + break + +next_ver = next_version(base) +if is_beta == "true": + beta = beta_max(data, f"v{next_ver}-beta.") + 1 + sys.stdout.write(f"{base}\t{next_ver}-beta.{beta}") +else: + sys.stdout.write(f"{base}\t{next_ver}") +' "$query" "$is_beta" +EOF + chmod +x "$bin_dir/jq" +} + +run_entrypoint() { + local response_file="$1" + local is_beta="$2" + local token="${3:-}" + local fake_status="${4:-0}" + local workdir + local output_file + local bin_dir + local stdout_file + local stderr_file + + workdir="$(mktemp -d)" + bin_dir="$workdir/bin" + mkdir -p "$bin_dir" + make_mock_curl "$bin_dir" "$response_file" + make_mock_jq "$bin_dir" + + output_file="$workdir/github_output" + stdout_file="$workdir/stdout" + stderr_file="$workdir/stderr" + + if [ -n "$token" ]; then + FAKE_CURL_STATUS="$fake_status" \ + FAKE_CURL_RESPONSE_FILE="$response_file" \ + PATH="$bin_dir:$PATH" \ + GITEA_SERVER_URL="https://gitea.example.com" \ + GITEA_REPOSITORY="org/repo" \ + RUNNER_TOKEN="$token" \ + IS_BETA="$is_beta" \ + GITHUB_OUTPUT="$output_file" \ + bash "$ROOT_DIR/entrypoint.sh" >"$stdout_file" 2>"$stderr_file" + else + FAKE_CURL_STATUS="$fake_status" \ + FAKE_CURL_RESPONSE_FILE="$response_file" \ + PATH="$bin_dir:$PATH" \ + GITEA_SERVER_URL="https://gitea.example.com" \ + GITEA_REPOSITORY="org/repo" \ + IS_BETA="$is_beta" \ + GITHUB_OUTPUT="$output_file" \ + bash "$ROOT_DIR/entrypoint.sh" >"$stdout_file" 2>"$stderr_file" + fi + + printf '%s\n' "$output_file" +} + +test_unit_helpers() { + assert_eq "false" "$(normalize_beta_flag "")" "normalize_beta_flag empty" + assert_eq "false" "$(normalize_beta_flag "null")" "normalize_beta_flag null" + assert_eq "true" "$(normalize_beta_flag "true")" "normalize_beta_flag true" + assert_eq "0.1.0" "$(next_release_version "0.0.9")" "next_release_version carry patch" + assert_eq "2.0.0" "$(next_release_version "1.9.9")" "next_release_version carry minor" +} + +test_stable_release_flow() { + local response_file + local output_file + + response_file="$(mktemp)" + cat >"$response_file" <<'EOF' +[ + {"tag_name":"v1.2.3-beta.1"}, + {"tag_name":"v1.2.3"}, + {"tag_name":"v1.2.4-beta.1"} +] +EOF + + output_file="$(run_entrypoint "$response_file" "false")" + assert_eq "version=1.2.4" "$(cat "$output_file")" "stable release output" +} + +test_beta_release_flow() { + local response_file + local output_file + + response_file="$(mktemp)" + cat >"$response_file" <<'EOF' +[ + {"tag_name":"v1.2.3"}, + {"tag_name":"v1.2.4-beta.1"}, + {"tag_name":"v1.2.4-beta.3"} +] +EOF + + output_file="$(run_entrypoint "$response_file" "true")" + assert_eq "version=1.2.4-beta.4" "$(cat "$output_file")" "beta release output" +} + +test_empty_release_list() { + local response_file + local output_file + + response_file="$(mktemp)" + printf '%s\n' '[]' >"$response_file" + + output_file="$(run_entrypoint "$response_file" "false")" + assert_eq "version=0.0.1" "$(cat "$output_file")" "empty release list" +} + +test_only_beta_releases() { + local response_file + local output_file + + response_file="$(mktemp)" + cat >"$response_file" <<'EOF' +[ + {"tag_name":"v2.4.6-beta.1"}, + {"tag_name":"v2.4.6-beta.2"} +] +EOF + + output_file="$(run_entrypoint "$response_file" "false")" + assert_eq "version=0.0.1" "$(cat "$output_file")" "only beta releases" +} + +test_null_release_payload() { + local response_file + local output_file + + response_file="$(mktemp)" + printf '%s\n' 'null' >"$response_file" + + output_file="$(run_entrypoint "$response_file" "false")" + assert_eq "version=0.0.1" "$(cat "$output_file")" "null release payload" +} + +test_malformed_release_payload() { + local response_file + local workdir + local bin_dir + local output_file + local stdout_file + local stderr_file + + response_file="$(mktemp)" + printf '%s\n' '{' >"$response_file" + workdir="$(mktemp -d)" + bin_dir="$workdir/bin" + mkdir -p "$bin_dir" + make_mock_curl "$bin_dir" "$response_file" + make_mock_jq "$bin_dir" + output_file="$workdir/github_output" + stdout_file="$workdir/stdout" + stderr_file="$workdir/stderr" + + if FAKE_CURL_STATUS=0 \ + FAKE_CURL_RESPONSE_FILE="$response_file" \ + PATH="$bin_dir:$PATH" \ + GITEA_SERVER_URL="https://gitea.example.com" \ + GITEA_REPOSITORY="org/repo" \ + IS_BETA="false" \ + GITHUB_OUTPUT="$output_file" \ + bash "$ROOT_DIR/entrypoint.sh" >"$stdout_file" 2>"$stderr_file"; then + fail "malformed release payload: expected failure" + fi +} + +test_curl_failure() { + local response_file + local workdir + local bin_dir + local output_file + local stdout_file + local stderr_file + + response_file="$(mktemp)" + printf '%s\n' '[]' >"$response_file" + workdir="$(mktemp -d)" + bin_dir="$workdir/bin" + mkdir -p "$bin_dir" + make_mock_curl "$bin_dir" "$response_file" + output_file="$workdir/github_output" + stdout_file="$workdir/stdout" + stderr_file="$workdir/stderr" + + if FAKE_CURL_STATUS=22 \ + FAKE_CURL_RESPONSE_FILE="$response_file" \ + PATH="$bin_dir:$PATH" \ + GITEA_SERVER_URL="https://gitea.example.com" \ + GITEA_REPOSITORY="org/repo" \ + IS_BETA="false" \ + GITHUB_OUTPUT="$output_file" \ + bash "$ROOT_DIR/entrypoint.sh" >"$stdout_file" 2>"$stderr_file"; then + fail "curl failure: expected failure" + fi +} + +test_unit_helpers +test_stable_release_flow +test_beta_release_flow +test_empty_release_list +test_only_beta_releases +test_null_release_payload +test_malformed_release_payload +test_curl_failure + +printf '[info] all tests passed\n'