diff --git a/.github/workflows/plugin-versions.yml b/.github/workflows/plugin-versions.yml index 8f1d6b0..83c9722 100644 --- a/.github/workflows/plugin-versions.yml +++ b/.github/workflows/plugin-versions.yml @@ -10,6 +10,15 @@ permissions: contents: read jobs: + sdkman-script-contracts: + name: SDKMAN JDK script contracts + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Run SDKMAN JDK contract tests + run: bash test/sdkman-switch-jdk-contract.sh + verify: name: Verify on Node.js ${{ matrix.node-version }} runs-on: ubuntu-latest diff --git a/plugins/dev/skills/sdkman-switch-jdk/SKILL.md b/plugins/dev/skills/sdkman-switch-jdk/SKILL.md index 2857b04..e5ac0b1 100644 --- a/plugins/dev/skills/sdkman-switch-jdk/SKILL.md +++ b/plugins/dev/skills/sdkman-switch-jdk/SKILL.md @@ -1,259 +1,314 @@ --- name: sdkman-switch-jdk -description: Use when Java/JDK version mismatch causes build or test failures, or when user requests switching JDK version on a system with SDKMAN installed +description: Safely resolve, install, and switch SDKMAN-managed Java/JDK versions without widening scope or changing the default unintentionally. Use when a user asks to switch JDK, project JDK metadata conflicts with the active Java, or a Java version mismatch causes Maven or Gradle build/test failures on a system with SDKMAN installed. --- # SDKMAN 切換 JDK -## 概覽 +採用最小範圍:預設只在執行實際命令的 shell 暫時切換 Java。只有使用者明確要求時,才變更 SDKMAN default、`.sdkmanrc` 或完整專案環境。 -使用這個技能以安全且可預期的方式透過 SDKMAN 切換 Java 版本。 -優先選擇影響範圍最小的變更:暫時切換用 `sdk use java`,只有在使用者明確要求永久預設時才用 `sdk default java`。 +## 不變條件 -## When to Use - -- `java -version` 顯示的版本與專案要求不符 -- 執行 `mvn test` / `gradle build` 失敗,錯誤含 "unsupported class file version"、"invalid source release"、"source/target compatibility" -- 專案有 `.sdkmanrc` 或 `.java-version` 但當前 Java 版本未匹配 -- 使用者明確要求切換 JDK 版本 - -**不要用於**: - -- 系統沒有安裝 SDKMAN(改為引導安裝) -- 問題不是 JDK 版本相關(如 Maven/Gradle 設定錯誤、依賴缺失) -- 只需查看版本不需要切換(直接跑 `java -version`) - -## Quick Reference - -所有指令前都要加 SDKMAN 初始化前綴(見下方「非互動式 Shell 初始化」)。 - -| 動作 | 指令 | -|------|------| -| 檢查實際使用版本 | `java -version 2>&1` | -| 確認 java 路徑 | `which java` | -| 列出已安裝版本 | `ls -1 "${SDKMAN_CANDIDATES_DIR:-${SDKMAN_DIR:-$HOME/.sdkman}/candidates}/java" \| grep -v '^current$'` | -| 從主版本號找 identifier | `ls ... \| grep '^21\.' \| head -1` | -| 暫時切換 + 執行 | `sdk use java && ` | -| 永久切換 | `sdk default java ` | -| 專案切換(安裝 + 切換) | `sdk env install` | -| 安裝新版本 | `SDKMAN_AUTO_ANSWER=true sdk install java ` | - -## 重要:非互動式 Shell 初始化 - -在非互動式命令執行環境中,SDKMAN 預設不會被載入。 -**每個 bash 命令都必須先 source SDKMAN 初始化腳本**: - -```bash -source "${SDKMAN_DIR:-$HOME/.sdkman}/bin/sdkman-init.sh" && -``` - -後續所有範例皆省略此前綴,但實際執行時**一律需要加上**。 - -**注意**:SDKMAN 的 `sdk` 指令會輸出 ANSI 色碼(如 `\033[1;32m`),且無法透過環境變數關閉(`sdk()` 函數每次調用都會重新載入 config 覆蓋設定)。這些色碼不影響功能,但**不要 parse `sdk` 指令的輸出文字來判斷版本**——一律用 `java -version` 和 `which java` 驗證。 - -## 重要:`sdk use` 跨命令失效 - -`sdk use java ` 只影響當前 shell session。每次命令執行都可能開新 shell,導致切換失效。 - -**解法**:在需要特定 JDK 的命令前,一律串接 source 與 `sdk use`: - -```bash -source "${SDKMAN_DIR:-$HOME/.sdkman}/bin/sdkman-init.sh" && sdk use java && -``` - -範例(用 JDK 21 跑 Maven 測試): - -```bash -source "${SDKMAN_DIR:-$HOME/.sdkman}/bin/sdkman-init.sh" && sdk use java 21.0.9-tem && mvn test -``` - -若使用者同意永久切換,則改用 `sdk default java `,後續新 shell 會自動使用該版本。 - -## 切換範圍決策 - -```dot -digraph scope { - "需要切換 JDK?" -> "專案有 .sdkmanrc?"; - "專案有 .sdkmanrc?" -> "sdk env install" [label="yes"]; - "專案有 .sdkmanrc?" -> "使用者要求永久?" [label="no"]; - "使用者要求永久?" -> "sdk default java " [label="yes"]; - "使用者要求永久?" -> "sdk use java && \n(預設選項)" [label="no"]; -} -``` +- 先將需求解析成唯一的完整 SDKMAN identifier,再變更環境。 +- 將使用者明確指定的版本置於專案推論之前;衝突時揭露兩者。 +- 將 Java-only 要求限制在 Java,不連帶安裝或切換其他 SDK。 +- 在變更前記錄 default 狀態;除非使用者明確要求,完成後必須完全相同。 +- 將初始化、切換、驗證與實際命令放在同一個 shell。 +- 以 `java -version`、`command -v java` 與實際命令結果作為完成證據。 ## 工作流程 -1. 偵測專案所需 JDK 版本(不變更環境)。 -2. 檢查 SDKMAN 與目前 Java 狀態。 -3. 先列出本機已安裝 JDK,必要時再安裝。 -4. 依需求套用切換範圍:暫時、預設或專案層級。 -5. 驗證結果並提供回復命令。 +### 1. 解析 JDK 需求 -## 偵測專案所需 JDK 版本 +依下列順序找出需求: -依以下優先順序檢查: +1. 使用者明確指定的 identifier、主版本或 distribution。 +2. `.sdkmanrc` 的 `java=` 與 `.java-version`。 +3. Gradle Java toolchain、Maven Toolchains 或 Maven Enforcer `requireJavaVersion`。 +4. 專案文件、CI 設定與可重現的 build/test 錯誤。 -1. **`.sdkmanrc`** — 讀取 `java=` 值(可能是完整 identifier 如 `21.0.9-tem`,也可能只有主版本號) -2. **`.java-version`** — 讀取檔案內容取得版本號 -3. **`pom.xml`** — 查看 ``、`` 或 `` property -4. **`build.gradle` / `build.gradle.kts`** — 查看 `sourceCompatibility`、`targetCompatibility` 或 `jvmToolchain` -5. **使用者明確指定** +當目標需要從 `.sdkmanrc` 解析、需要修改該檔,或需要套用完整 SDKMAN 環境時,先計算其中有效的 `java=` 項目。沒有時繼續找其他證據;只有一個時使用它;超過一個時列出每個衝突並停止,要求使用者選定或授權修正,不得任選其中一筆。若使用者已明確指定一次性的完整 identifier,則回報 `.sdkmanrc` 衝突後仍可使用隔離 runner 繼續,因為該路徑不會套用 auto-env。 -讀取 `.sdkmanrc` 中 Java 識別碼: - -```bash -grep '^java=' .sdkmanrc | head -1 | cut -d= -f2- -``` +將 Maven `source`、`target`、`release` 與 Gradle `sourceCompatibility`、`targetCompatibility` 視為編譯相容性,不直接當作執行 Maven/Gradle 所需的 JDK。將一般性的 `` 視為線索,並用 toolchain、文件或錯誤訊息確認其語意。 -若取得的是**完整 identifier**(如 `21.0.9-tem`),直接使用。 -若取得的是**主版本號**(如 `21`),需從已安裝清單比對完整 identifier: +若使用者要求一次性 JDK 與專案 metadata 衝突,採用使用者指定值並回報衝突。若要求持久化變更,先說明將修改哪個狀態。 -```bash -ls -1 "${SDKMAN_CANDIDATES_DIR:-${SDKMAN_DIR:-$HOME/.sdkman}/candidates}/java" | grep -v '^current$' | grep "^21\." | head -1 -``` +**完成條件:**取得一個有來源證據的完整 identifier,或取得仍需解析的唯一主版本與 distribution 約束。 -若本機沒有匹配的版本,先安裝再使用(見下方「找出或安裝目標 JDK」)。 +### 2. 初始化並記錄原始狀態 -## 檢查環境 +在隔離的 Bash process 中載入 SDKMAN、停用本次初始化的 auto-env、檢查目前 Java,並記錄 `java/current`: ```bash -sdk version && java -version 2>&1 && which java +bash -c ' + set -e -o pipefail + unset SDKMAN_ENV + export SDKMAN_OLD_PWD="$PWD" + source "${SDKMAN_DIR:-$HOME/.sdkman}/bin/sdkman-init.sh" + sdk version + if sdkman_switch_jdk_java="$(command -v java 2>/dev/null)"; then + printf "java: %s\n" "$sdkman_switch_jdk_java" + if ! java -version 2>&1; then + printf "WARNING: active java could not report its version.\n" >&2 + fi + else + printf "Active java: absent\n" + fi + sdkman_switch_jdk_current="${SDKMAN_CANDIDATES_DIR}/java/current" + if [[ -L "$sdkman_switch_jdk_current" ]]; then + printf "SDKMAN Java default state: link-hex:" + LC_ALL=C readlink -n "$sdkman_switch_jdk_current" | \ + LC_ALL=C od -An -v -tx1 | LC_ALL=C tr -d "[:space:]" + printf "\n" + elif [[ -e "$sdkman_switch_jdk_current" ]]; then + printf "ERROR: current is not a symlink: %s\n" "$sdkman_switch_jdk_current" >&2 + exit 1 + else + printf "SDKMAN Java default state: absent\n" + fi +' ``` -- 若 `source` 失敗或 `sdk` 不可用,先停止並詢問是否要安裝 SDKMAN。 -- **`java -version` 和 `which java` 是真實版本依據**。 -- 注意:`sdk current java` 只顯示 default 版本,不反映 `sdk use` 的效果,**不要用它來驗證切換結果**。 +將 `SDKMAN Java default state:` 後的完整值逐字保存為 ``;值只會是 `link-hex:` 或 `absent`。編碼避免 shell command substitution 遺失 target 尾端 newline,也讓 state 能安全地作為單一 argv 傳遞。若初始化失敗,停止並說明 SDKMAN 尚未安裝或不可用;取得同意前不安裝 SDKMAN。 -## 找出或安裝目標 JDK +**完成條件:**SDKMAN 可用、目前 Java 已觀察、default 已記為完整 symlink target 或 `absent`。 -優先列出本機已安裝的 JDK(低輸出、節省 context): - -```bash -ls -1 "${SDKMAN_CANDIDATES_DIR:-${SDKMAN_DIR:-$HOME/.sdkman}/candidates}/java" | grep -v '^current$' -``` +### 3. 解析完整 identifier -若本機沒有目標版本,再列出可用的遠端候選版本: +列出已安裝版本: ```bash -sdk list java +bash -c ' + set -e -o pipefail + unset SDKMAN_ENV + export SDKMAN_OLD_PWD="$PWD" + source "${SDKMAN_DIR:-$HOME/.sdkman}/bin/sdkman-init.sh" + sdkman_switch_jdk_java_dir="${SDKMAN_CANDIDATES_DIR}/java" + if [[ -d "$sdkman_switch_jdk_java_dir" ]]; then + find "$sdkman_switch_jdk_java_dir" -mindepth 1 -maxdepth 1 \ + \( -type d -o -type l \) ! -name current -exec basename {} \; | \ + LC_ALL=C sort + else + printf "Installed SDKMAN Java candidates: none\n" + fi +' ``` -安裝目標版本(已安裝的版本會自動跳過,不會報錯): - -```bash -SDKMAN_AUTO_ANSWER=true sdk install java -``` +依以下規則解析: -`SDKMAN_AUTO_ANSWER=true` 防止非互動式 shell 卡在 "Set as default?" 提示。 -識別碼格式範例:`21.0.9-tem`。 +- metadata 或使用者已給完整 identifier 時,使用該值。 +- 只有主版本時,先沿用使用者、專案 metadata 或目前 default 明確指出的 distribution。 +- 只有一個符合主版本與 distribution 的已安裝版本時,使用它。 +- 多個版本仍符合時,列出它們並要求選定 distribution 或 patch;不得取清單第一筆。 +- 沒有已安裝版本時,查看 `sdk list java` 並選定一個完整可用 identifier;僅在使用者接受 SDKMAN 預設 distribution 時代為選擇 Temurin。 -**簡化策略**:因為 `sdk install` 對已安裝版本無害(輸出 "already installed" 並正常結束),可以無條件先 install 再 use,不需要先檢查是否已安裝。 +將 `sdk list java` 當作顯示資料;不要用 ANSI 輸出判斷目前生效的 Java。 -## 套用要求的範圍 +**完成條件:**只剩一個完整 identifier,例如 `21.0.9-tem`。 -### 暫時(僅目前 shell)— 預設選項 +### 4. 必要時安全安裝 -當使用者要求測試或暫時切換時使用。**注意搭配實際命令串接**: +若 identifier 尚未安裝,執行此 skill 目錄中的安裝 script;將 `` 解析為包含本檔案的目錄: ```bash -sdk use java && +bash /scripts/install-java.sh ``` -效果會在目前 shell 結束後失效。 - -### 永久預設(新 shell) - -只有在使用者明確要求全域或預設 JDK 時才使用: - -```bash -sdk default java -``` - -效果會套用到所有新開的 shell。 - -### 專案層級(`.sdkmanrc`) +script 會對 SDKMAN 的 default 提示明確回答 `n`,並驗證安裝前後的 `java/current` 完全相同。不要改寫成 `SDKMAN_AUTO_ANSWER=true sdk install ...`。 -在專案根目錄執行(僅 `.sdkmanrc` 不存在時): +**完成條件:**`${SDKMAN_CANDIDATES_DIR}/java/` 存在,且 default 狀態與步驟 2 完全相同。 -```bash -sdk use java && sdk env init -``` +### 5. 套用最小範圍並執行 -這會將目前版本寫入 `.sdkmanrc`。 +#### 暫時切換,預設分支 -若 `.sdkmanrc` **已存在**,不要執行 `sdk env init`(會報錯),改為只更新 `java=` 行並保留其他設定: +使用此 skill 的 runner 在同一個 Bash process 完成安全初始化、切換、驗證與實際命令。runner 在既有 default 時使用 `sdk use`,接著在所有情況下將精確 candidate 的 `bin` 放到 `PATH` 最前方;default 為 `absent` 時不建立 `java/current`: ```bash -cp .sdkmanrc .sdkmanrc.bak -# 只更新 java= 行,保留 .sdkmanrc 中的其他候選設定(如 maven=、gradle= 等) -awk -v id="" 'BEGIN{updated=0} /^java=/{print "java=" id; updated=1; next} {print} END{if(!updated) print "java=" id}' .sdkmanrc > .sdkmanrc.tmp && mv .sdkmanrc.tmp .sdkmanrc +bash /scripts/run-java.sh -- ``` -之後可用以下命令安裝(若缺少)並啟用專案版本: - -```bash -sdk env install -``` +暫時分支必須一律呼叫這個 runner;這是唯一允許的入口。不得以裸 `sdk use`、自行 source 的 subshell 或手動設定 `JAVA_HOME` 取代,因為 runner 同時負責阻止 auto-env、保留 default、處理原本沒有 default 的狀態,以及維持 command argv 邊界。 -注意 `sdk env` 和 `sdk env install` 的區別: -- `sdk env` → 切換到 `.sdkmanrc` 指定的版本,**若版本未安裝只會警告** -- `sdk env install` → **自動安裝**缺少的版本並切換(推薦使用) +需要 compound command 時,將它明確交給 Bash,例如 `-- bash -lc 'mvn test && mvn package'`。不要自行拼接或 `eval` 使用者輸入。 -## 驗證與回復 +#### 永久 default,僅限明確要求 -驗證(不要用 `sdk current java`,它只顯示 default 不反映 `sdk use`): +使用步驟 2 保存的 ``。將 identifier 與 state 分別作為單一 argv 傳入,不得把 state 當作 SDKMAN identifier,也不得用 `eval`。設定前再次確認 default 沒有漂移,然後保存輸出的 ``: ```bash -java -version 2>&1 && which java +bash -c ' + set -e -o pipefail + unset SDKMAN_ENV + export SDKMAN_OLD_PWD="$PWD" + source "${SDKMAN_DIR:-$HOME/.sdkman}/bin/sdkman-init.sh" + sdkman_switch_jdk_current="${SDKMAN_CANDIDATES_DIR}/java/current" + sdkman_switch_jdk_link_state() { + printf "link-hex:" + LC_ALL=C readlink -n "$1" | LC_ALL=C od -An -v -tx1 | \ + LC_ALL=C tr -d "[:space:]" || return 1 + printf "\n" + } + sdkman_switch_jdk_default_state() { + if [[ -L "$sdkman_switch_jdk_current" ]]; then + sdkman_switch_jdk_link_state "$sdkman_switch_jdk_current" + elif [[ -e "$sdkman_switch_jdk_current" ]]; then + printf "unsupported\n" + else + printf "absent\n" + fi + } + sdkman_switch_jdk_before="$(sdkman_switch_jdk_default_state)" + if [[ "$sdkman_switch_jdk_before" != "$2" ]]; then + printf "Refusing to change a default that drifted.\nExpected: %s\nActual: %s\n" \ + "$2" "$sdkman_switch_jdk_before" >&2 + exit 1 + fi + sdk default java "$1" + sdkman_switch_jdk_created="$(sdkman_switch_jdk_default_state)" + if [[ "$sdkman_switch_jdk_created" != link-hex:* ]]; then + printf "SDKMAN did not create a Java default symlink.\n" >&2 + exit 1 + fi + printf "Created default state: %s\n" "$sdkman_switch_jdk_created" +' bash + +bash -c ' + set -e -o pipefail + unset SDKMAN_ENV + export SDKMAN_OLD_PWD="$PWD" + source "${SDKMAN_DIR:-$HOME/.sdkman}/bin/sdkman-init.sh" + java -version 2>&1 + command -v java +' ``` -預期 `which java` 應指向 `$HOME/.sdkman/candidates/java//bin/java`。 - -依切換範圍回復: +只在使用者要求回復時執行以下命令。它先要求目前 state 逐字等於 ``,避免覆蓋後續變更;原本有 default 時,以 `java` 目錄內的暫存 symlink 原子還原 raw target,原本為 `absent` 時則移除本次建立的 symlink。兩個 state 都必須分別作為單一 argv 傳入: ```bash -# temporary — 下次命令不串接 sdk use 即可恢復 default - -# default -sdk default java - -# project-level(若有備份) -[ -f .sdkmanrc.bak ] && mv .sdkmanrc.bak .sdkmanrc -sdk env install +bash -c ' + set -e -o pipefail + unset SDKMAN_ENV + export SDKMAN_OLD_PWD="$PWD" + source "${SDKMAN_DIR:-$HOME/.sdkman}/bin/sdkman-init.sh" + sdkman_switch_jdk_java_dir="${SDKMAN_CANDIDATES_DIR}/java" + sdkman_switch_jdk_current="${SDKMAN_CANDIDATES_DIR}/java/current" + sdkman_switch_jdk_link_state() { + printf "link-hex:" + LC_ALL=C readlink -n "$1" | LC_ALL=C od -An -v -tx1 | \ + LC_ALL=C tr -d "[:space:]" || return 1 + printf "\n" + } + sdkman_switch_jdk_default_state() { + if [[ -L "$sdkman_switch_jdk_current" ]]; then + sdkman_switch_jdk_link_state "$sdkman_switch_jdk_current" + elif [[ -e "$sdkman_switch_jdk_current" ]]; then + printf "unsupported\n" + else + printf "absent\n" + fi + } + sdkman_switch_jdk_decode_link_state() { + local sdkman_switch_jdk_encoded="${1#link-hex:}" + local sdkman_switch_jdk_escaped="" + if [[ "$1" != link-hex:* || -z "$sdkman_switch_jdk_encoded" || \ + $(( ${#sdkman_switch_jdk_encoded} % 2 )) -ne 0 || \ + ! "$sdkman_switch_jdk_encoded" =~ ^[[:xdigit:]]+$ ]]; then + return 1 + fi + while [[ -n "$sdkman_switch_jdk_encoded" ]]; do + sdkman_switch_jdk_escaped="${sdkman_switch_jdk_escaped}\\x${sdkman_switch_jdk_encoded:0:2}" + sdkman_switch_jdk_encoded="${sdkman_switch_jdk_encoded:2}" + done + printf -v sdkman_switch_jdk_decoded_target "%b" \ + "$sdkman_switch_jdk_escaped" + } + sdkman_switch_jdk_expected="$1" + sdkman_switch_jdk_previous="$2" + if [[ "$sdkman_switch_jdk_expected" != link-hex:* ]] || \ + [[ "$sdkman_switch_jdk_previous" != link-hex:* && \ + "$sdkman_switch_jdk_previous" != absent ]]; then + printf "Invalid saved default state.\n" >&2 + exit 2 + fi + sdkman_switch_jdk_actual="$(sdkman_switch_jdk_default_state)" + if [[ "$sdkman_switch_jdk_actual" != "$sdkman_switch_jdk_expected" ]]; then + printf "Refusing to overwrite a default that drifted.\nExpected: %s\nActual: %s\n" \ + "$sdkman_switch_jdk_expected" "$sdkman_switch_jdk_actual" >&2 + exit 1 + fi + if [[ "$sdkman_switch_jdk_previous" == absent ]]; then + unlink "$sdkman_switch_jdk_current" + else + if ! sdkman_switch_jdk_decode_link_state "$sdkman_switch_jdk_previous"; then + printf "Invalid encoded previous default state.\n" >&2 + exit 2 + fi + sdkman_switch_jdk_previous_target="$sdkman_switch_jdk_decoded_target" + sdkman_switch_jdk_temp_dir="$( + mktemp -d "$sdkman_switch_jdk_java_dir/.sdkman-switch-jdk-restore.XXXXXX" + )" + sdkman_switch_jdk_temp_link="$sdkman_switch_jdk_temp_dir/current" + sdkman_switch_jdk_cleanup() { + if [[ -L "${sdkman_switch_jdk_temp_link:-}" ]]; then + unlink "$sdkman_switch_jdk_temp_link" 2>/dev/null || true + fi + if [[ -d "${sdkman_switch_jdk_temp_dir:-}" ]]; then + rmdir "$sdkman_switch_jdk_temp_dir" 2>/dev/null || true + fi + } + trap sdkman_switch_jdk_cleanup EXIT + ln -s -- "$sdkman_switch_jdk_previous_target" "$sdkman_switch_jdk_temp_link" + if [[ ! -L "$sdkman_switch_jdk_temp_link" ]] || \ + [[ "$(sdkman_switch_jdk_link_state "$sdkman_switch_jdk_temp_link")" != \ + "$sdkman_switch_jdk_previous" ]]; then + printf "Could not create the exact rollback symlink.\n" >&2 + exit 1 + fi + if [[ "$(sdkman_switch_jdk_default_state)" != \ + "$sdkman_switch_jdk_expected" ]]; then + printf "Refusing to overwrite a default that drifted during rollback.\n" >&2 + exit 1 + fi + if mv -fh "$sdkman_switch_jdk_temp_link" \ + "$sdkman_switch_jdk_current" 2>/dev/null; then + : + elif [[ -L "$sdkman_switch_jdk_temp_link" ]] && \ + mv -Tf "$sdkman_switch_jdk_temp_link" \ + "$sdkman_switch_jdk_current" 2>/dev/null; then + : + else + printf "Could not atomically restore the Java default.\n" >&2 + exit 1 + fi + rmdir "$sdkman_switch_jdk_temp_dir" + sdkman_switch_jdk_temp_dir= + sdkman_switch_jdk_temp_link= + trap - EXIT + fi + sdkman_switch_jdk_actual="$(sdkman_switch_jdk_default_state)" + if [[ "$sdkman_switch_jdk_actual" != "$sdkman_switch_jdk_previous" ]]; then + printf "Java default rollback verification failed.\nExpected: %s\nActual: %s\n" \ + "$sdkman_switch_jdk_previous" "$sdkman_switch_jdk_actual" >&2 + exit 1 + fi + printf "Restored default state: %s\n" "$sdkman_switch_jdk_actual" +' bash ``` -## 備援:直接設定 JAVA_HOME +#### 專案或完整環境 -若 `sdk use` 無法生效(例如某些工具不走 PATH 而直接讀 `JAVA_HOME`),可直接導出: - -```bash -export JAVA_HOME="${SDKMAN_DIR:-$HOME/.sdkman}/candidates/java/" -export PATH="$JAVA_HOME/bin:$PATH" -``` - -或使用 SDKMAN 的 `current` symlink(指向 default 版本): - -```bash -export JAVA_HOME="$HOME/.sdkman/candidates/java/current" -export PATH="$JAVA_HOME/bin:$PATH" -``` +當使用者明確要求修改 `.sdkmanrc`,或要求套用其中所有 SDK 時,讀取 [project-scope.md](references/project-scope.md)。一般 Java-only 要求留在暫時分支。 -## 常見錯誤處理 +**完成條件:**同一 shell 中的版本、路徑與完整 identifier 相符,且使用者要求的 build/test/command 成功;任何持久化狀態都有具體回復方式。 -| 錯誤訊息 | 原因 | 解法 | -| --- | --- | --- | -| `sdk: command not found` | SDKMAN 未初始化 | 確認 `source` 初始化腳本有在命令前執行 | -| `Stop! is not available.` | 版本識別碼錯誤 | 用 `sdk list java` 重新確認正確的識別碼 | -| `java -version` 與預期不符 | `PATH` 中有其他 Java(如 `/usr/bin/java`) | 檢查 `which java` 確認路徑,移除或調整衝突的 PATH 項目 | -| `sdk env init` 失敗 | `.sdkmanrc` 已存在 | 用 `awk` 只更新 `java=` 行,避免覆蓋其他候選設定 | -| Shell 卡住無回應 | `sdk install` 等待 "Set as default?" 互動式提示 | 加 `SDKMAN_AUTO_ANSWER=true` 環境變數 | -| 輸出含亂碼或色碼 | SDKMAN `sdk` 指令輸出帶 ANSI escape code | 不影響功能,不要 parse `sdk` 輸出來判斷版本,一律用 `java -version` 和 `which java` 驗證 | +### 6. 回報結果 -## 回覆格式 +回報以下證據: -回覆時請一律: +1. 選用的完整 identifier 與來源。 +2. 套用範圍:暫時、default、`.sdkmanrc` 或完整專案環境。 +3. `java -version`、`command -v java` 與實際命令結果。 +4. 任何持久化變更及其具體回復命令。 -1. 說明選擇的範圍:暫時、預設或專案層級。 -2. 提供包含具體識別碼的精確命令(含 `source` 前綴)。 -3. 附上驗證命令。 -4. 若更改預設值,附上回復命令。 +核心流程失敗時才讀取 [troubleshooting.md](references/troubleshooting.md),並在替代路徑上重新滿足相同完成條件。 diff --git a/plugins/dev/skills/sdkman-switch-jdk/agents/openai.yaml b/plugins/dev/skills/sdkman-switch-jdk/agents/openai.yaml index 91f46de..cce859b 100644 --- a/plugins/dev/skills/sdkman-switch-jdk/agents/openai.yaml +++ b/plugins/dev/skills/sdkman-switch-jdk/agents/openai.yaml @@ -1,3 +1,4 @@ interface: - display_name: "Sdkman Switch Jdk" - short_description: "Sdkman switch jdk" + display_name: "SDKMAN Switch JDK" + short_description: "Safely switch SDKMAN-managed Java versions" + default_prompt: "Use $sdkman-switch-jdk to select and safely switch to the JDK required by this project." diff --git a/plugins/dev/skills/sdkman-switch-jdk/references/project-scope.md b/plugins/dev/skills/sdkman-switch-jdk/references/project-scope.md new file mode 100644 index 0000000..0020fd1 --- /dev/null +++ b/plugins/dev/skills/sdkman-switch-jdk/references/project-scope.md @@ -0,0 +1,59 @@ +# 專案範圍 JDK 變更 + +只有在使用者明確要求持久化 `.sdkmanrc`,或套用其中完整 SDKMAN 環境時,才讀取本參考。 + +## 只執行專案指定的 Java + +即使 `.sdkmanrc` 另含 `maven=`、`gradle=` 或其他 candidate,也將 Java-only 要求限制在 Java: + +1. 從 `.sdkmanrc` 讀出完整 `java=` identifier。 +2. 必要時用 `scripts/install-java.sh` 只安裝該 Java。 +3. 回到 `SKILL.md` 步驟 5 的暫時分支;default 不存在時使用其中指向的 SDK home 分支。 + +此分支不執行 `sdk env install`,因為它會安裝並切換 `.sdkmanrc` 中的每一個 candidate。 + +**完成條件:**同一 shell 中的 `java -version`、`command -v java` 與實際命令都使用該完整 identifier。 + +## 將 identifier 持久化至 `.sdkmanrc` + +只有在使用者要求專案層級持久化時才套用: + +1. 記錄 `.sdkmanrc` 原先是否存在,並保存其完整原始內容。若既有檔案未受版本控制,使用 repo 外的唯一備份,且回報實際路徑: + + ```bash + sdkman_switch_jdk_backup="$(mktemp "${TMPDIR:-/tmp}/sdkmanrc.XXXXXX")" && \ + cp .sdkmanrc "$sdkman_switch_jdk_backup" && \ + printf 'SDKMANRC backup: %s\n' "$sdkman_switch_jdk_backup" + ``` + +2. 修改前計算有效的 `java=` 項目。沒有時新增 `java=`;只有一個時更新該項並保留註解與其他 candidate。若超過一個,停止並列出衝突;只有在使用者明確要求修正重複項目時,才能更新第一項並移除其餘項目。 +3. 修改後驗證恰好只有一個有效的 `java=` 項目,再檢查 diff,確認只包含預期的 Java 項目變更。 +4. 必要時用 `scripts/install-java.sh` 安裝該 Java,再回到 `SKILL.md` 步驟 5 的暫時分支執行與驗證。 + +回復時,既有檔案應還原為保存的完整內容;若本次建立了新檔,先確認它仍是本次產物,再依使用者要求執行 `unlink .sdkmanrc`。不要使用會覆寫既有內容的固定 `.sdkmanrc.bak`。 + +**完成條件:**`.sdkmanrc` 恰好只有一個有效的 `java=` 項目、diff 只包含預期 Java 項目、實際命令成功,且回復方式能精確還原變更前狀態。 + +## 套用完整 SDKMAN 環境 + +只有在使用者明確要求套用 `.sdkmanrc` 的每個 candidate 時,才進入完整環境分支。先通過 `SKILL.md` 步驟 1 的重複 `java=` gate;有衝突時不得進入本分支。 + +必須從包含 `.sdkmanrc` 的目錄執行專用 runner。不要自行 source SDKMAN 後直接執行 `sdk env install`,因為該 inline 流程無法在 payload 前逐 candidate 驗證與還原 default。 + +runner 會以一次讀取解析並驗證 `.sdkmanrc`,把 candidate 與 version 保留在 indexed arrays;接著只以這些記憶體值明確呼叫 `sdk install ` 與 `sdk use `,不會呼叫 `sdk env install` 或讓 SDKMAN 第二次讀取 `.sdkmanrc`。在共用跨程序 lock 內,runner 會記錄每個已驗證 candidate 的 raw `current` symlink。未獲准的變更只有在 current 仍精確等於該 candidate 的已驗證 version 時才會原子還原;若 fingerprint 顯示 project `.sdkmanrc` 在讀取或執行期間改變、有第三方漂移、比較失敗、還原失敗或 lock ownership 改變,runner 會保留外部的新狀態並禁止 payload。 + +SDKMAN 可能為原本沒有 `current` 的 candidate 建立第一個 default。逐項說明這些 candidate,並只在使用者明確同意保留該 candidate 的 `.sdkmanrc` version 時加入對應授權: + +```bash +bash /scripts/run-sdkman-env.sh \ + [--allow-default ]... \ + -- [args...] +``` + +`--allow-default` 只適用於執行前為 `absent`、且存在於 `.sdkmanrc` 的 candidate;它只允許保留 `.sdkmanrc` 指定的 exact raw version target。它不允許改寫既有 default,也不接受重複或未列於 `.sdkmanrc` 的 candidate。沒有任何建立 default 的授權時,省略所有 `--allow-default`,runner 會還原每一個新建的 default。 + +需要 compound command 時,將 `bash -lc 'mvn test && mvn package'` 當成 ` [args...]` 傳入;不要把命令文字插入 `bash -c` 程式本文。 + +runner 只在所有 candidate 的比較、還原、最終驗證與 lock release 都成功後才以 `exec` 執行 payload。執行 SDKMAN 期間若收到 `HUP`、`INT` 或 `TERM`,會先以相同的 operation-owned 比較完成 reconciliation,再保留對應 signal status;lock metadata 初始化期間的 signal 也會延後到安全清理後處理。SDKMAN 失敗且安全 reconciliation 成功時,保留 SDKMAN 的原始非零 status;CLI 或 `.sdkmanrc` 格式錯誤回傳 `2`,其他安全拒絕回傳 `1`。 + +**完成條件:**回報 SDKMAN 安裝或切換的每個 candidate、明確授權的新 default、實際命令結果,且所有未獲准變更的 default symlink 與執行前逐 byte 相同。 diff --git a/plugins/dev/skills/sdkman-switch-jdk/references/troubleshooting.md b/plugins/dev/skills/sdkman-switch-jdk/references/troubleshooting.md new file mode 100644 index 0000000..850b3ba --- /dev/null +++ b/plugins/dev/skills/sdkman-switch-jdk/references/troubleshooting.md @@ -0,0 +1,30 @@ +# SDKMAN JDK 疑難排解 + +只有在核心安裝或 runner 流程失敗時,才讀取本參考。 + +## 驗證目前生效的 Java + +以 `java -version` 與 `command -v java` 作為可觀察的真相。SDKMAN 5.20.0 在暫時 `sdk use` 後,`sdk current java` 仍可能回報 default;不要用它驗證目前 shell。 + +SDKMAN 輸出可能包含 ANSI escape。將 `sdk list java` 當作顯示資料,不要解析它來推論生效版本。 + +## 維持 default 不變 + +`scripts/install-java.sh` 會預先設定 `USE=n` 並對提示送入 `n`;`scripts/run-java.sh` 則在所有情況下將精確 candidate 的 `bin` 放到 `PATH` 最前方,且不會在缺少 Java default 時建立它。兩者都會比較執行前後的 `java/current`。`scripts/run-sdkman-env.sh` 會一次解析並驗證 `.sdkmanrc`,再以已驗證的記憶體 candidate 清單逐項明確執行 `sdk install` 與 `sdk use`;它不會讓 SDKMAN 重新讀取檔案,也會對每個 candidate 執行相同的 raw-state 保護。 + +三個 runner 共用 `${SDKMAN_CANDIDATES_DIR}/.sdkman-switch-jdk.lock`,讓 SDKMAN 操作與 reconciliation 不會彼此交錯。live owner 會使新呼叫 fail fast;metadata 完整、同一 EUID 且 PID 已不存在的 stale lock 會在重新核對後回收。metadata 不完整、ownership 不符或 PID 仍存活時一律保留 lock 並停止;先確認沒有相關 runner 後再人工檢查,不要直接遞迴刪除。 + +此 lock 是 bundled runner 間的合作式協調邊界。不要在 runner 執行期間以裸 `sdk default`、手動改寫 `current` 或其他不取得同一 lock 的流程並行變更 default;runner 會在每次還原寫入前重查漂移,但 portable Bash 無法把 state comparison 與 filesystem mutation 合併成單一 kernel operation。`HUP`、`INT` 與 `TERM` 會先執行相同 reconciliation 再釋放 lock;`SIGKILL` 或系統崩潰無法由 shell trap 處理,後續呼叫只會依完整 owner metadata 的 stale-lock 規則回收 lock,不會猜測或覆寫 default。 + +runner 已同時設定 `JAVA_HOME` 與 `PATH`。若它回報 active Java 不符,檢查 `${SDKMAN_CANDIDATES_DIR}/java//bin/java` 是否存在且可執行。不要將 `JAVA_HOME` 指向 `java/current`;該 symlink 代表 default,不一定是要求的 identifier。 + +## 失敗處理 + +| 觀察 | 動作 | +| --- | --- | +| SDKMAN init script 不存在 | 停止並在安裝 SDKMAN 前取得同意。 | +| 沒有唯一 identifier 符合主版本 | 列出候選並要求選定 distribution;不要取清單第一筆。 | +| `Stop! is not available.` | 重新查看 `sdk list java` 並選定完整可用 identifier。 | +| runner 回報 active Java 不符 | 檢查 candidate 的 `bin/java` 與 shell 執行環境;不要繞過 runner,修正原因後重跑。 | +| runner 回報 project `.sdkmanrc` 在 activation 期間改變 | 保留並檢查並行修改;runner 只會執行已驗證的記憶體 entries,不會覆寫或第二次讀取 project 檔案,確認內容穩定後再重跑。 | +| 專案命令連帶影響 Maven、Gradle 或其他 SDK | 回到 Java-only 分支;只在明確要求完整環境時使用專用 runner。 | diff --git a/plugins/dev/skills/sdkman-switch-jdk/scripts/install-java.sh b/plugins/dev/skills/sdkman-switch-jdk/scripts/install-java.sh new file mode 100755 index 0000000..19cda55 --- /dev/null +++ b/plugins/dev/skills/sdkman-switch-jdk/scripts/install-java.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash + +set -e -o pipefail + +sdkman_switch_jdk_usage() { + printf 'Usage: %s \n' "${0##*/}" >&2 +} + +if [[ $# -ne 1 ]]; then + sdkman_switch_jdk_usage + exit 2 +fi + +sdkman_switch_jdk_identifier="$1" +if [[ ! "$sdkman_switch_jdk_identifier" =~ ^[A-Za-z0-9][A-Za-z0-9._+-]*$ ]]; then + printf 'Invalid SDKMAN Java identifier: %s\n' "$sdkman_switch_jdk_identifier" >&2 + exit 2 +fi +if [[ "$sdkman_switch_jdk_identifier" == "current" ]]; then + printf 'Invalid SDKMAN Java identifier (reserved name): %s\n' \ + "$sdkman_switch_jdk_identifier" >&2 + exit 2 +fi + +sdkman_switch_jdk_script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +sdkman_switch_jdk_state_helper="$sdkman_switch_jdk_script_dir/sdkman-current-state.sh" +if [[ ! -r "$sdkman_switch_jdk_state_helper" ]]; then + printf 'SDKMAN state helper is not readable: %s\n' \ + "$sdkman_switch_jdk_state_helper" >&2 + exit 1 +fi +# shellcheck source=sdkman-current-state.sh +source "$sdkman_switch_jdk_state_helper" + +sdkman_switch_jdk_root="${SDKMAN_DIR:-${HOME:?HOME is not set}/.sdkman}" +sdkman_switch_jdk_init="$sdkman_switch_jdk_root/bin/sdkman-init.sh" +if [[ ! -r "$sdkman_switch_jdk_init" ]]; then + printf 'SDKMAN init script is not readable: %s\n' "$sdkman_switch_jdk_init" >&2 + exit 1 +fi + +# SDKMAN is a shell function and must be loaded into this process. +# Prevent sdkman_auto_env from applying a project file while SDKMAN initializes. +unset SDKMAN_ENV +export SDKMAN_OLD_PWD="$PWD" +# shellcheck source=/dev/null +source "$sdkman_switch_jdk_init" + +if ! type sdk >/dev/null 2>&1; then + printf 'SDKMAN did not define the sdk command.\n' >&2 + exit 1 +fi + +sdkman_switch_jdk_java_dir="${SDKMAN_CANDIDATES_DIR:?SDKMAN_CANDIDATES_DIR is not set}/java" +sdkman_switch_jdk_current="$sdkman_switch_jdk_java_dir/current" +sdkman_switch_jdk_target="$sdkman_switch_jdk_java_dir/$sdkman_switch_jdk_identifier" + +sdkman_switch_jdk_install_cleanup_traps +if ! sdkman_switch_jdk_acquire_lock install-java; then + exit 1 +fi + +sdkman_switch_jdk_default_before="$(sdkman_switch_jdk_default_state "$sdkman_switch_jdk_current")" +if [[ "$sdkman_switch_jdk_default_before" == "unsupported" ]]; then + printf 'Refusing to continue: SDKMAN Java current is not a symlink: %s\n' \ + "$sdkman_switch_jdk_current" >&2 + exit 1 +fi + +sdkman_switch_jdk_owned_state='' +if [[ -e "$sdkman_switch_jdk_target" || -L "$sdkman_switch_jdk_target" ]]; then + if [[ ! -x "$sdkman_switch_jdk_target/bin/java" ]]; then + printf 'Refusing to use an incomplete SDKMAN Java candidate: %s\n' \ + "$sdkman_switch_jdk_target" >&2 + exit 1 + fi + sdkman_switch_jdk_install_status=0 +else + sdkman_switch_jdk_owned_state="$(sdkman_switch_jdk_target_state "$sdkman_switch_jdk_identifier")" + if ! sdkman_switch_jdk_register_default_reconciliation \ + "$sdkman_switch_jdk_current" \ + "$sdkman_switch_jdk_default_before" \ + "$sdkman_switch_jdk_owned_state"; then + exit 1 + fi + # Pre-seeding USE covers both SDKMAN auto-answer modes and the no-default case. + # The here-string supplies the same explicit answer when SDKMAN prompts. + set +e + USE=n sdk install java "$sdkman_switch_jdk_identifier" <<< 'n' + sdkman_switch_jdk_install_status=$? + set -e +fi + +set +e +sdkman_switch_jdk_finish_operation "$sdkman_switch_jdk_install_status" +sdkman_switch_jdk_finish_status=$? +set -e + +if (( sdkman_switch_jdk_deferred_signal_status != 0 )); then + exit "$sdkman_switch_jdk_finish_status" +fi +if (( sdkman_switch_jdk_default_reconcile_failed != 0 )) || \ + (( sdkman_switch_jdk_finish_status != sdkman_switch_jdk_install_status )); then + exit 1 +fi + +if (( sdkman_switch_jdk_install_status != 0 )); then + printf 'SDKMAN failed to install Java %s (status %d); the default is unchanged.\n' \ + "$sdkman_switch_jdk_identifier" "$sdkman_switch_jdk_install_status" >&2 + exit "$sdkman_switch_jdk_install_status" +fi + +if (( sdkman_switch_jdk_default_reconcile_changed != 0 )); then + printf 'SDKMAN changed the Java default unexpectedly; the previous state was restored.\n' >&2 + exit 1 +fi + +if [[ ! -x "$sdkman_switch_jdk_target/bin/java" ]]; then + printf 'SDKMAN reported success but Java is incomplete or not executable at: %s\n' \ + "$sdkman_switch_jdk_target" >&2 + exit 1 +fi + +printf 'Java %s is installed; SDKMAN default is unchanged.\n' \ + "$sdkman_switch_jdk_identifier" diff --git a/plugins/dev/skills/sdkman-switch-jdk/scripts/run-java.sh b/plugins/dev/skills/sdkman-switch-jdk/scripts/run-java.sh new file mode 100755 index 0000000..6519a70 --- /dev/null +++ b/plugins/dev/skills/sdkman-switch-jdk/scripts/run-java.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash + +set -e -o pipefail + +sdkman_switch_jdk_run_usage() { + printf 'Usage: %s -- [args...]\n' \ + "${0##*/}" >&2 +} + +if [[ $# -lt 3 || "$2" != "--" ]]; then + sdkman_switch_jdk_run_usage + exit 2 +fi + +sdkman_switch_jdk_run_identifier="$1" +if [[ ! "$sdkman_switch_jdk_run_identifier" =~ ^[A-Za-z0-9][A-Za-z0-9._+-]*$ ]]; then + printf 'Invalid SDKMAN Java identifier: %s\n' \ + "$sdkman_switch_jdk_run_identifier" >&2 + exit 2 +fi +if [[ "$sdkman_switch_jdk_run_identifier" == "current" ]]; then + printf 'Invalid SDKMAN Java identifier (reserved name): %s\n' \ + "$sdkman_switch_jdk_run_identifier" >&2 + exit 2 +fi +shift 2 + +sdkman_switch_jdk_run_script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +sdkman_switch_jdk_run_state_helper="$sdkman_switch_jdk_run_script_dir/sdkman-current-state.sh" +if [[ ! -r "$sdkman_switch_jdk_run_state_helper" ]]; then + printf 'SDKMAN state helper is not readable: %s\n' \ + "$sdkman_switch_jdk_run_state_helper" >&2 + exit 1 +fi +# shellcheck source=sdkman-current-state.sh +source "$sdkman_switch_jdk_run_state_helper" + +sdkman_switch_jdk_run_root="${SDKMAN_DIR:-${HOME:?HOME is not set}/.sdkman}" +sdkman_switch_jdk_run_init="$sdkman_switch_jdk_run_root/bin/sdkman-init.sh" +if [[ ! -r "$sdkman_switch_jdk_run_init" ]]; then + printf 'SDKMAN init script is not readable: %s\n' \ + "$sdkman_switch_jdk_run_init" >&2 + exit 1 +fi + +# SDKMAN is a shell function and must be loaded into this process. +# Prevent sdkman_auto_env from applying a project file while SDKMAN initializes. +unset SDKMAN_ENV +export SDKMAN_OLD_PWD="$PWD" +# shellcheck source=/dev/null +source "$sdkman_switch_jdk_run_init" + +if ! type sdk >/dev/null 2>&1; then + printf 'SDKMAN did not define the sdk command.\n' >&2 + exit 1 +fi + +sdkman_switch_jdk_run_java_dir="${SDKMAN_CANDIDATES_DIR:?SDKMAN_CANDIDATES_DIR is not set}/java" +sdkman_switch_jdk_run_current="$sdkman_switch_jdk_run_java_dir/current" +sdkman_switch_jdk_run_target="$sdkman_switch_jdk_run_java_dir/$sdkman_switch_jdk_run_identifier" + +if [[ ! -x "$sdkman_switch_jdk_run_target/bin/java" ]]; then + printf 'Java is not installed or is incomplete: %s\n' \ + "$sdkman_switch_jdk_run_identifier" >&2 + exit 1 +fi + +sdkman_switch_jdk_install_cleanup_traps +if ! sdkman_switch_jdk_acquire_lock run-java; then + exit 1 +fi + +sdkman_switch_jdk_run_default_before="$(sdkman_switch_jdk_default_state "$sdkman_switch_jdk_run_current")" +if [[ "$sdkman_switch_jdk_run_default_before" == "unsupported" ]]; then + printf 'Refusing to continue: SDKMAN Java current is not a symlink: %s\n' \ + "$sdkman_switch_jdk_run_current" >&2 + exit 1 +fi + +sdkman_switch_jdk_run_use_status=0 +sdkman_switch_jdk_run_owned_state='' +if [[ "$sdkman_switch_jdk_run_default_before" == link-hex:* ]]; then + sdkman_switch_jdk_run_owned_state="$(sdkman_switch_jdk_target_state "$sdkman_switch_jdk_run_identifier")" + if ! sdkman_switch_jdk_register_default_reconciliation \ + "$sdkman_switch_jdk_run_current" \ + "$sdkman_switch_jdk_run_default_before" \ + "$sdkman_switch_jdk_run_owned_state"; then + exit 1 + fi + set +e + sdk use java "$sdkman_switch_jdk_run_identifier" + sdkman_switch_jdk_run_use_status=$? + set -e +else + : +fi + +set +e +sdkman_switch_jdk_finish_operation "$sdkman_switch_jdk_run_use_status" +sdkman_switch_jdk_run_finish_status=$? +set -e + +if (( sdkman_switch_jdk_deferred_signal_status != 0 )); then + exit "$sdkman_switch_jdk_run_finish_status" +fi +if (( sdkman_switch_jdk_default_reconcile_failed != 0 )) || \ + (( sdkman_switch_jdk_run_finish_status != sdkman_switch_jdk_run_use_status )); then + exit 1 +fi + +if (( sdkman_switch_jdk_run_use_status != 0 )); then + printf 'SDKMAN failed to activate Java %s (status %d); the command was not run.\n' \ + "$sdkman_switch_jdk_run_identifier" "$sdkman_switch_jdk_run_use_status" >&2 + exit "$sdkman_switch_jdk_run_use_status" +fi + +if (( sdkman_switch_jdk_default_reconcile_changed != 0 )); then + printf 'SDKMAN changed the Java default unexpectedly; it was restored and the command was not run.\n' >&2 + exit 1 +fi + +# Make the requested SDK deterministic even when sdk use finds an earlier, +# non-SDKMAN java entry in PATH. This also handles an originally absent default. +export JAVA_HOME="$sdkman_switch_jdk_run_target" +export PATH="$JAVA_HOME/bin:$PATH" + +hash -r +sdkman_switch_jdk_run_java="$(command -v java)" +sdkman_switch_jdk_run_expected="$sdkman_switch_jdk_run_target/bin/java" +if [[ "$sdkman_switch_jdk_run_java" != "$sdkman_switch_jdk_run_expected" ]]; then + printf 'Active java does not match the requested SDKMAN identifier.\n' >&2 + printf 'Expected: %s\nActual: %s\n' \ + "$sdkman_switch_jdk_run_expected" "$sdkman_switch_jdk_run_java" >&2 + exit 1 +fi + +java -version >&2 +printf 'java: %s\n' "$sdkman_switch_jdk_run_java" >&2 +sdkman_switch_jdk_clear_cleanup_traps +exec "$@" diff --git a/plugins/dev/skills/sdkman-switch-jdk/scripts/run-sdkman-env.sh b/plugins/dev/skills/sdkman-switch-jdk/scripts/run-sdkman-env.sh new file mode 100755 index 0000000..6182d93 --- /dev/null +++ b/plugins/dev/skills/sdkman-switch-jdk/scripts/run-sdkman-env.sh @@ -0,0 +1,395 @@ +#!/usr/bin/env bash + +set -e -o pipefail + +sdkman_switch_jdk_env_usage() { + printf 'Usage: %s [--allow-default ]... -- [args...]\n' \ + "${0##*/}" >&2 +} + +sdkman_switch_jdk_env_allowed_candidates=() +while (( $# > 0 )); do + case "$1" in + --allow-default) + if (( $# < 2 )); then + sdkman_switch_jdk_env_usage + exit 2 + fi + sdkman_switch_jdk_env_allowed_candidates+=("$2") + shift 2 + ;; + --) + shift + break + ;; + *) + sdkman_switch_jdk_env_usage + exit 2 + ;; + esac +done +if (( $# == 0 )); then + sdkman_switch_jdk_env_usage + exit 2 +fi +sdkman_switch_jdk_env_payload=("$@") + +sdkman_switch_jdk_env_index=0 +while (( sdkman_switch_jdk_env_index < ${#sdkman_switch_jdk_env_allowed_candidates[@]} )); do + sdkman_switch_jdk_env_candidate="${sdkman_switch_jdk_env_allowed_candidates[$sdkman_switch_jdk_env_index]}" + if [[ ! "$sdkman_switch_jdk_env_candidate" =~ ^[a-z][a-z0-9-]*$ ]]; then + printf 'Invalid SDKMAN default authorization: %s\n' \ + "$sdkman_switch_jdk_env_candidate" >&2 + exit 2 + fi + sdkman_switch_jdk_env_other=$((sdkman_switch_jdk_env_index + 1)) + while (( sdkman_switch_jdk_env_other < ${#sdkman_switch_jdk_env_allowed_candidates[@]} )); do + if [[ "${sdkman_switch_jdk_env_allowed_candidates[$sdkman_switch_jdk_env_other]}" == \ + "$sdkman_switch_jdk_env_candidate" ]]; then + printf 'Duplicate SDKMAN default authorization: %s\n' \ + "$sdkman_switch_jdk_env_candidate" >&2 + exit 2 + fi + sdkman_switch_jdk_env_other=$((sdkman_switch_jdk_env_other + 1)) + done + sdkman_switch_jdk_env_index=$((sdkman_switch_jdk_env_index + 1)) +done + +sdkman_switch_jdk_env_script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +sdkman_switch_jdk_env_state_helper="$sdkman_switch_jdk_env_script_dir/sdkman-current-state.sh" +if [[ ! -r "$sdkman_switch_jdk_env_state_helper" ]]; then + printf 'SDKMAN state helper is not readable: %s\n' \ + "$sdkman_switch_jdk_env_state_helper" >&2 + exit 1 +fi +# shellcheck source=sdkman-current-state.sh +source "$sdkman_switch_jdk_env_state_helper" + +sdkman_switch_jdk_env_project_pwd="$PWD" +sdkman_switch_jdk_env_rc="$sdkman_switch_jdk_env_project_pwd/.sdkmanrc" + +if [[ ! -f "$sdkman_switch_jdk_env_rc" || -L "$sdkman_switch_jdk_env_rc" || \ + ! -O "$sdkman_switch_jdk_env_rc" || ! -r "$sdkman_switch_jdk_env_rc" ]]; then + printf 'SDKMAN environment file must be a readable, owned regular file: %s\n' \ + "$sdkman_switch_jdk_env_rc" >&2 + exit 2 +fi + +sdkman_switch_jdk_env_rc_fingerprint="$( + sdkman_switch_jdk_file_fingerprint "$sdkman_switch_jdk_env_rc" +)" || { + printf 'Could not fingerprint the project .sdkmanrc safely.\n' >&2 + exit 1 +} + +sdkman_switch_jdk_env_candidates=() +sdkman_switch_jdk_env_versions=() +sdkman_switch_jdk_env_line_number=0 +while IFS= read -r sdkman_switch_jdk_env_line || \ + [[ -n "$sdkman_switch_jdk_env_line" ]]; do + sdkman_switch_jdk_env_line_number=$((sdkman_switch_jdk_env_line_number + 1)) + sdkman_switch_jdk_env_line="${sdkman_switch_jdk_env_line%%#*}" + sdkman_switch_jdk_env_line="$( + printf '%s' "$sdkman_switch_jdk_env_line" | \ + LC_ALL=C "$sdkman_switch_jdk_cmd_tr" -d '[:space:]' + )" + if [[ -z "$sdkman_switch_jdk_env_line" ]]; then + continue + fi + if [[ "$sdkman_switch_jdk_env_line" != *=* ]]; then + printf 'Malformed .sdkmanrc entry at line %d.\n' \ + "$sdkman_switch_jdk_env_line_number" >&2 + exit 2 + fi + sdkman_switch_jdk_env_candidate="${sdkman_switch_jdk_env_line%%=*}" + sdkman_switch_jdk_env_version="${sdkman_switch_jdk_env_line#*=}" + if [[ ! "$sdkman_switch_jdk_env_candidate" =~ ^[a-z][a-z0-9-]*$ || \ + ! "$sdkman_switch_jdk_env_version" =~ ^[A-Za-z0-9][A-Za-z0-9._+-]*$ || \ + "$sdkman_switch_jdk_env_version" == "current" ]]; then + printf 'Malformed .sdkmanrc entry at line %d.\n' \ + "$sdkman_switch_jdk_env_line_number" >&2 + exit 2 + fi + sdkman_switch_jdk_env_index=0 + while (( sdkman_switch_jdk_env_index < ${#sdkman_switch_jdk_env_candidates[@]} )); do + if [[ "${sdkman_switch_jdk_env_candidates[$sdkman_switch_jdk_env_index]}" == \ + "$sdkman_switch_jdk_env_candidate" ]]; then + printf 'Duplicate SDKMAN candidate in .sdkmanrc: %s\n' \ + "$sdkman_switch_jdk_env_candidate" >&2 + exit 2 + fi + sdkman_switch_jdk_env_index=$((sdkman_switch_jdk_env_index + 1)) + done + sdkman_switch_jdk_env_candidates+=("$sdkman_switch_jdk_env_candidate") + sdkman_switch_jdk_env_versions+=("$sdkman_switch_jdk_env_version") +done < "$sdkman_switch_jdk_env_rc" + +if (( ${#sdkman_switch_jdk_env_candidates[@]} == 0 )); then + printf 'No SDKMAN candidates were found in .sdkmanrc.\n' >&2 + exit 2 +fi + +sdkman_switch_jdk_env_index=0 +while (( sdkman_switch_jdk_env_index < ${#sdkman_switch_jdk_env_allowed_candidates[@]} )); do + sdkman_switch_jdk_env_allowed="${sdkman_switch_jdk_env_allowed_candidates[$sdkman_switch_jdk_env_index]}" + sdkman_switch_jdk_env_found=0 + sdkman_switch_jdk_env_other=0 + while (( sdkman_switch_jdk_env_other < ${#sdkman_switch_jdk_env_candidates[@]} )); do + if [[ "${sdkman_switch_jdk_env_candidates[$sdkman_switch_jdk_env_other]}" == \ + "$sdkman_switch_jdk_env_allowed" ]]; then + sdkman_switch_jdk_env_found=1 + break + fi + sdkman_switch_jdk_env_other=$((sdkman_switch_jdk_env_other + 1)) + done + if (( sdkman_switch_jdk_env_found == 0 )); then + printf 'Authorized candidate is not present in .sdkmanrc: %s\n' \ + "$sdkman_switch_jdk_env_allowed" >&2 + exit 2 + fi + sdkman_switch_jdk_env_index=$((sdkman_switch_jdk_env_index + 1)) +done + +sdkman_switch_jdk_env_post_parse_fingerprint="$( + sdkman_switch_jdk_file_fingerprint "$sdkman_switch_jdk_env_rc" +)" || sdkman_switch_jdk_env_post_parse_fingerprint='' +if [[ -z "$sdkman_switch_jdk_env_post_parse_fingerprint" || \ + "$sdkman_switch_jdk_env_post_parse_fingerprint" != \ + "$sdkman_switch_jdk_env_rc_fingerprint" ]]; then + printf 'The project .sdkmanrc changed while it was being read.\n' >&2 + exit 1 +fi + +# SDKMAN must never re-read a pathname after validation. The runner applies +# these indexed, validated arrays directly below instead of calling +# `sdk env install`, whose implementation opens .sdkmanrc again. +sdkman_switch_jdk_install_cleanup_traps + +sdkman_switch_jdk_env_root="${SDKMAN_DIR:-${HOME:?HOME is not set}/.sdkman}" +sdkman_switch_jdk_env_init="$sdkman_switch_jdk_env_root/bin/sdkman-init.sh" +if [[ ! -r "$sdkman_switch_jdk_env_init" ]]; then + printf 'SDKMAN init script is not readable: %s\n' \ + "$sdkman_switch_jdk_env_init" >&2 + exit 1 +fi + +unset SDKMAN_ENV +export SDKMAN_OLD_PWD="$sdkman_switch_jdk_env_project_pwd" +# shellcheck source=/dev/null +source "$sdkman_switch_jdk_env_init" + +if ! type sdk >/dev/null 2>&1; then + printf 'SDKMAN did not define the sdk command.\n' >&2 + exit 1 +fi + +sdkman_switch_jdk_env_candidates_dir="${SDKMAN_CANDIDATES_DIR:?SDKMAN_CANDIDATES_DIR is not set}" +if ! sdkman_switch_jdk_acquire_lock run-sdkman-env; then + exit 1 +fi + +sdkman_switch_jdk_env_before_states=() +sdkman_switch_jdk_env_owned_states=() +sdkman_switch_jdk_env_authorized=() +sdkman_switch_jdk_env_index=0 +while (( sdkman_switch_jdk_env_index < ${#sdkman_switch_jdk_env_candidates[@]} )); do + sdkman_switch_jdk_env_candidate="${sdkman_switch_jdk_env_candidates[$sdkman_switch_jdk_env_index]}" + sdkman_switch_jdk_env_version="${sdkman_switch_jdk_env_versions[$sdkman_switch_jdk_env_index]}" + sdkman_switch_jdk_env_current="$sdkman_switch_jdk_env_candidates_dir/$sdkman_switch_jdk_env_candidate/current" + sdkman_switch_jdk_env_before="$( + sdkman_switch_jdk_default_state "$sdkman_switch_jdk_env_current" + )" + if [[ "$sdkman_switch_jdk_env_before" == "unsupported" ]]; then + printf 'Refusing to continue: SDKMAN %s current is not a symlink: %s\n' \ + "$sdkman_switch_jdk_env_candidate" "$sdkman_switch_jdk_env_current" >&2 + exit 1 + fi + sdkman_switch_jdk_env_before_states+=("$sdkman_switch_jdk_env_before") + sdkman_switch_jdk_env_owned_states+=("$( + sdkman_switch_jdk_target_state "$sdkman_switch_jdk_env_version" + )") + + sdkman_switch_jdk_env_is_authorized=0 + sdkman_switch_jdk_env_other=0 + while (( sdkman_switch_jdk_env_other < ${#sdkman_switch_jdk_env_allowed_candidates[@]} )); do + if [[ "${sdkman_switch_jdk_env_allowed_candidates[$sdkman_switch_jdk_env_other]}" == \ + "$sdkman_switch_jdk_env_candidate" ]]; then + sdkman_switch_jdk_env_is_authorized=1 + break + fi + sdkman_switch_jdk_env_other=$((sdkman_switch_jdk_env_other + 1)) + done + if (( sdkman_switch_jdk_env_is_authorized != 0 )) && \ + [[ "$sdkman_switch_jdk_env_before" != "absent" ]]; then + printf 'Default authorization requires an initially absent current symlink: %s\n' \ + "$sdkman_switch_jdk_env_candidate" >&2 + exit 2 + fi + sdkman_switch_jdk_env_authorized+=("$sdkman_switch_jdk_env_is_authorized") + sdkman_switch_jdk_env_index=$((sdkman_switch_jdk_env_index + 1)) +done + +sdkman_switch_jdk_env_reconcile_status=0 +sdkman_switch_jdk_reconcile_environment_defaults() { + local index=0 + local candidate + local current + local before + local owned + local after + local final + + while (( index < ${#sdkman_switch_jdk_env_candidates[@]} )); do + candidate="${sdkman_switch_jdk_env_candidates[$index]}" + current="$sdkman_switch_jdk_env_candidates_dir/$candidate/current" + before="${sdkman_switch_jdk_env_before_states[$index]}" + owned="${sdkman_switch_jdk_env_owned_states[$index]}" + after='' + if ! after="$(sdkman_switch_jdk_default_state "$current")"; then + sdkman_switch_jdk_env_reconcile_status=1 + elif [[ "$after" == "unsupported" ]]; then + printf 'SDKMAN %s current became a non-symlink; refusing payload execution.\n' \ + "$candidate" >&2 + sdkman_switch_jdk_env_reconcile_status=1 + elif [[ "${sdkman_switch_jdk_env_authorized[$index]}" == 1 ]]; then + if [[ "$after" != "$before" && "$after" != "$owned" ]]; then + printf 'SDKMAN %s default drifted after the SDKMAN operation.\n' \ + "$candidate" >&2 + sdkman_switch_jdk_env_reconcile_status=1 + fi + elif [[ "$after" != "$before" ]] && \ + ! sdkman_switch_jdk_restore_default "$current" "$before" "$owned"; then + printf 'Could not restore the SDKMAN %s default safely.\n' \ + "$candidate" >&2 + sdkman_switch_jdk_env_reconcile_status=1 + fi + index=$((index + 1)) + done + + index=0 + while (( index < ${#sdkman_switch_jdk_env_candidates[@]} )); do + candidate="${sdkman_switch_jdk_env_candidates[$index]}" + current="$sdkman_switch_jdk_env_candidates_dir/$candidate/current" + final='' + if ! final="$(sdkman_switch_jdk_default_state "$current")"; then + sdkman_switch_jdk_env_reconcile_status=1 + elif [[ "${sdkman_switch_jdk_env_authorized[$index]}" == 1 ]]; then + if [[ "$final" != "${sdkman_switch_jdk_env_before_states[$index]}" && \ + "$final" != "${sdkman_switch_jdk_env_owned_states[$index]}" ]]; then + sdkman_switch_jdk_env_reconcile_status=1 + fi + elif [[ "$final" != "${sdkman_switch_jdk_env_before_states[$index]}" ]]; then + sdkman_switch_jdk_env_reconcile_status=1 + fi + index=$((index + 1)) + done + + (( sdkman_switch_jdk_env_reconcile_status == 0 )) +} + +if ! sdkman_switch_jdk_register_reconcile_callback \ + sdkman_switch_jdk_reconcile_environment_defaults; then + exit 1 +fi + +# Apply only the entries that were parsed and validated above. In particular, +# do not call `sdk env install`: it opens .sdkmanrc itself and would create a +# second, mutable source of candidate names and versions. +sdkman_switch_jdk_env_sdk_status=0 +sdkman_switch_jdk_env_index=0 +while (( sdkman_switch_jdk_env_index < ${#sdkman_switch_jdk_env_candidates[@]} )); do + sdkman_switch_jdk_env_candidate="${sdkman_switch_jdk_env_candidates[$sdkman_switch_jdk_env_index]}" + sdkman_switch_jdk_env_version="${sdkman_switch_jdk_env_versions[$sdkman_switch_jdk_env_index]}" + set +e + USE=n sdk install "$sdkman_switch_jdk_env_candidate" "$sdkman_switch_jdk_env_version" <<< 'n' + sdkman_switch_jdk_env_sdk_status=$? + set -e + if (( sdkman_switch_jdk_env_sdk_status != 0 )); then + break + fi + sdkman_switch_jdk_env_index=$((sdkman_switch_jdk_env_index + 1)) +done + +if (( sdkman_switch_jdk_env_sdk_status == 0 )); then + sdkman_switch_jdk_env_index=0 + while (( sdkman_switch_jdk_env_index < ${#sdkman_switch_jdk_env_candidates[@]} )); do + sdkman_switch_jdk_env_candidate="${sdkman_switch_jdk_env_candidates[$sdkman_switch_jdk_env_index]}" + sdkman_switch_jdk_env_version="${sdkman_switch_jdk_env_versions[$sdkman_switch_jdk_env_index]}" + set +e + sdk use "$sdkman_switch_jdk_env_candidate" "$sdkman_switch_jdk_env_version" + sdkman_switch_jdk_env_sdk_status=$? + set -e + if (( sdkman_switch_jdk_env_sdk_status != 0 )); then + break + fi + sdkman_switch_jdk_env_index=$((sdkman_switch_jdk_env_index + 1)) + done +fi +unset SDKMAN_ENV + +sdkman_switch_jdk_env_rc_changed=0 +if [[ ! -f "$sdkman_switch_jdk_env_rc" || -L "$sdkman_switch_jdk_env_rc" || \ + ! -O "$sdkman_switch_jdk_env_rc" ]] || \ + ! sdkman_switch_jdk_env_post_operation_fingerprint="$( + sdkman_switch_jdk_file_fingerprint "$sdkman_switch_jdk_env_rc" + )" || \ + [[ "$sdkman_switch_jdk_env_post_operation_fingerprint" != \ + "$sdkman_switch_jdk_env_rc_fingerprint" ]]; then + printf 'The project .sdkmanrc changed while the SDKMAN environment was being activated.\n' >&2 + sdkman_switch_jdk_env_rc_changed=1 +fi + +sdkman_switch_jdk_env_requested_status="$sdkman_switch_jdk_env_sdk_status" +if (( sdkman_switch_jdk_env_rc_changed != 0 )); then + sdkman_switch_jdk_env_requested_status=1 +fi +set +e +sdkman_switch_jdk_finish_operation "$sdkman_switch_jdk_env_requested_status" +sdkman_switch_jdk_env_finish_status=$? +set -e + +if (( sdkman_switch_jdk_deferred_signal_status != 0 )); then + exit "$sdkman_switch_jdk_env_finish_status" +fi +if (( sdkman_switch_jdk_env_reconcile_status != 0 )); then + printf 'SDKMAN defaults could not be reconciled safely; the command was not run.\n' >&2 + exit 1 +fi +if (( sdkman_switch_jdk_env_finish_status != sdkman_switch_jdk_env_requested_status )); then + exit 1 +fi +if (( sdkman_switch_jdk_env_rc_changed != 0 )); then + exit 1 +fi +if (( sdkman_switch_jdk_env_sdk_status != 0 )); then + printf 'SDKMAN failed to activate the project environment (status %d); the command was not run.\n' \ + "$sdkman_switch_jdk_env_sdk_status" >&2 + exit "$sdkman_switch_jdk_env_sdk_status" +fi + +sdkman_switch_jdk_env_index=0 +while (( sdkman_switch_jdk_env_index < ${#sdkman_switch_jdk_env_candidates[@]} )); do + if [[ "${sdkman_switch_jdk_env_candidates[$sdkman_switch_jdk_env_index]}" == "java" ]]; then + sdkman_switch_jdk_env_java_home="$sdkman_switch_jdk_env_candidates_dir/java/${sdkman_switch_jdk_env_versions[$sdkman_switch_jdk_env_index]}" + sdkman_switch_jdk_env_expected_java="$sdkman_switch_jdk_env_java_home/bin/java" + sdkman_switch_jdk_env_actual_java="$(command -v java 2>/dev/null || true)" + if [[ "${JAVA_HOME-}" != "$sdkman_switch_jdk_env_java_home" || \ + "$sdkman_switch_jdk_env_actual_java" != "$sdkman_switch_jdk_env_expected_java" ]]; then + printf 'Active Java does not match the project SDKMAN environment.\n' >&2 + printf 'Expected: %s\nActual: %s\n' \ + "$sdkman_switch_jdk_env_expected_java" \ + "${sdkman_switch_jdk_env_actual_java:-absent}" >&2 + exit 1 + fi + if ! java -version >&2; then + printf 'Project Java could not report its version.\n' >&2 + exit 1 + fi + printf 'java: %s\n' "$sdkman_switch_jdk_env_actual_java" >&2 + break + fi + sdkman_switch_jdk_env_index=$((sdkman_switch_jdk_env_index + 1)) +done + +sdkman_switch_jdk_clear_cleanup_traps +exec "${sdkman_switch_jdk_env_payload[@]}" diff --git a/plugins/dev/skills/sdkman-switch-jdk/scripts/sdkman-current-state.sh b/plugins/dev/skills/sdkman-switch-jdk/scripts/sdkman-current-state.sh new file mode 100644 index 0000000..8a90fc0 --- /dev/null +++ b/plugins/dev/skills/sdkman-switch-jdk/scripts/sdkman-current-state.sh @@ -0,0 +1,602 @@ +#!/usr/bin/env bash + +# Shared SDKMAN default-state coordination for the sdkman-switch-jdk runners. +# This file is sourced by the executable scripts and must remain Bash 3.2 +# compatible. + +# Capture every external cleanup primitive before SDKMAN can prepend an +# activated candidate to PATH. The runners deliberately invoke SDKMAN after +# sourcing this helper, so later reconciliation does not accidentally execute +# a similarly named program supplied by a candidate. +sdkman_switch_jdk_capture_tool() { + local variable_name="$1" + local tool_name="$2" + local tool_path='' + + tool_path="$(command -v "$tool_name" 2>/dev/null)" || return 1 + if [[ "$tool_path" != /* || ! -f "$tool_path" || ! -x "$tool_path" ]]; then + return 1 + fi + printf -v "$variable_name" '%s' "$tool_path" +} + +sdkman_switch_jdk_capture_tool_paths() { + sdkman_switch_jdk_capture_tool sdkman_switch_jdk_cmd_readlink readlink && \ + sdkman_switch_jdk_capture_tool sdkman_switch_jdk_cmd_od od && \ + sdkman_switch_jdk_capture_tool sdkman_switch_jdk_cmd_tr tr && \ + sdkman_switch_jdk_capture_tool sdkman_switch_jdk_cmd_mkdir mkdir && \ + sdkman_switch_jdk_capture_tool sdkman_switch_jdk_cmd_rmdir rmdir && \ + sdkman_switch_jdk_capture_tool sdkman_switch_jdk_cmd_unlink unlink && \ + sdkman_switch_jdk_capture_tool sdkman_switch_jdk_cmd_mktemp mktemp && \ + sdkman_switch_jdk_capture_tool sdkman_switch_jdk_cmd_ln ln && \ + sdkman_switch_jdk_capture_tool sdkman_switch_jdk_cmd_mv mv && \ + sdkman_switch_jdk_capture_tool sdkman_switch_jdk_cmd_cksum cksum +} + +if ! sdkman_switch_jdk_capture_tool_paths; then + printf 'Could not resolve a required SDKMAN state-management tool.\n' >&2 + return 1 2>/dev/null || exit 1 +fi + +sdkman_switch_jdk_link_state() { + local link="$1" + + printf 'link-hex:' + LC_ALL=C "$sdkman_switch_jdk_cmd_readlink" -n "$link" | \ + LC_ALL=C "$sdkman_switch_jdk_cmd_od" -An -v -tx1 | \ + LC_ALL=C "$sdkman_switch_jdk_cmd_tr" -d '[:space:]' || return 1 + printf '\n' +} + +sdkman_switch_jdk_default_state() { + local current="$1" + + if [[ -L "$current" ]]; then + sdkman_switch_jdk_link_state "$current" + elif [[ -e "$current" ]]; then + printf 'unsupported\n' + else + printf 'absent\n' + fi +} + +sdkman_switch_jdk_file_fingerprint() { + local file="$1" + + [[ -f "$file" && ! -L "$file" && -O "$file" && -r "$file" ]] || return 1 + "$sdkman_switch_jdk_cmd_cksum" < "$file" +} + +sdkman_switch_jdk_target_state() { + local target="$1" + + printf 'link-hex:' + printf '%s' "$target" | LC_ALL=C "$sdkman_switch_jdk_cmd_od" -An -v -tx1 | \ + LC_ALL=C "$sdkman_switch_jdk_cmd_tr" -d '[:space:]' || return 1 + printf '\n' +} + +sdkman_switch_jdk_decode_link_state() { + local encoded="${1#link-hex:}" + local escaped='' + + if [[ "$1" != link-hex:* || -z "$encoded" || \ + $(( ${#encoded} % 2 )) -ne 0 || \ + ! "$encoded" =~ ^[0-9A-Fa-f]+$ ]]; then + return 1 + fi + while [[ -n "$encoded" ]]; do + escaped="${escaped}\\x${encoded:0:2}" + encoded="${encoded:2}" + done + printf -v sdkman_switch_jdk_decoded_target '%b' "$escaped" +} + +sdkman_switch_jdk_lock_dir='' +sdkman_switch_jdk_lock_owned=0 +sdkman_switch_jdk_lock_token='' +sdkman_switch_jdk_lock_label='' +sdkman_switch_jdk_lock_acquire_in_progress=0 +sdkman_switch_jdk_lock_initializing=0 +sdkman_switch_jdk_restore_temp_dir='' +sdkman_switch_jdk_restore_temp_link='' +sdkman_switch_jdk_reconcile_callback='' +sdkman_switch_jdk_cleanup_running=0 +sdkman_switch_jdk_deferred_signal_status=0 +sdkman_switch_jdk_default_reconcile_current='' +sdkman_switch_jdk_default_reconcile_before='' +sdkman_switch_jdk_default_reconcile_owned='' +sdkman_switch_jdk_default_reconcile_changed=0 +sdkman_switch_jdk_default_reconcile_failed=0 + +sdkman_switch_jdk_read_lock_field() { + local file="$1" + local value='' + local extra='' + + [[ -f "$file" && ! -L "$file" && -O "$file" ]] || return 1 + exec 9< "$file" || return 1 + if ! IFS= read -r value <&9; then + exec 9<&- + return 1 + fi + if IFS= read -r extra <&9 || [[ -n "$extra" ]]; then + exec 9<&- + return 1 + fi + exec 9<&- + sdkman_switch_jdk_lock_field_value="$value" +} + +sdkman_switch_jdk_snapshot_lock() { + local lock_dir="$1" + + [[ -d "$lock_dir" && ! -L "$lock_dir" && -O "$lock_dir" ]] || return 1 + sdkman_switch_jdk_read_lock_field "$lock_dir/pid" || return 1 + sdkman_switch_jdk_snapshot_pid="$sdkman_switch_jdk_lock_field_value" + sdkman_switch_jdk_read_lock_field "$lock_dir/euid" || return 1 + sdkman_switch_jdk_snapshot_euid="$sdkman_switch_jdk_lock_field_value" + sdkman_switch_jdk_read_lock_field "$lock_dir/token" || return 1 + sdkman_switch_jdk_snapshot_token="$sdkman_switch_jdk_lock_field_value" + sdkman_switch_jdk_read_lock_field "$lock_dir/label" || return 1 + sdkman_switch_jdk_snapshot_label="$sdkman_switch_jdk_lock_field_value" + + [[ "$sdkman_switch_jdk_snapshot_pid" =~ ^[1-9][0-9]*$ ]] || return 1 + [[ "$sdkman_switch_jdk_snapshot_euid" =~ ^[0-9]+$ ]] || return 1 + [[ "$sdkman_switch_jdk_snapshot_token" =~ ^${sdkman_switch_jdk_snapshot_pid}:${sdkman_switch_jdk_snapshot_euid}:[A-Za-z0-9._-]+:[A-Za-z0-9._-]+$ ]] || return 1 + [[ "$sdkman_switch_jdk_snapshot_label" =~ ^[A-Za-z0-9._-]+$ ]] || return 1 +} + +sdkman_switch_jdk_remove_owned_lock_files() { + local cleanup_status=0 + + if [[ -f "$sdkman_switch_jdk_lock_dir/pid" && ! -L "$sdkman_switch_jdk_lock_dir/pid" ]]; then + "$sdkman_switch_jdk_cmd_unlink" "$sdkman_switch_jdk_lock_dir/pid" || cleanup_status=1 + else + cleanup_status=1 + fi + if [[ -f "$sdkman_switch_jdk_lock_dir/euid" && ! -L "$sdkman_switch_jdk_lock_dir/euid" ]]; then + "$sdkman_switch_jdk_cmd_unlink" "$sdkman_switch_jdk_lock_dir/euid" || cleanup_status=1 + else + cleanup_status=1 + fi + if [[ -f "$sdkman_switch_jdk_lock_dir/token" && ! -L "$sdkman_switch_jdk_lock_dir/token" ]]; then + "$sdkman_switch_jdk_cmd_unlink" "$sdkman_switch_jdk_lock_dir/token" || cleanup_status=1 + else + cleanup_status=1 + fi + if [[ -f "$sdkman_switch_jdk_lock_dir/label" && ! -L "$sdkman_switch_jdk_lock_dir/label" ]]; then + "$sdkman_switch_jdk_cmd_unlink" "$sdkman_switch_jdk_lock_dir/label" || cleanup_status=1 + else + cleanup_status=1 + fi + return "$cleanup_status" +} + +sdkman_switch_jdk_cleanup_initializing_lock() { + local cleanup_status=0 + local field + local expected_value + + if (( sdkman_switch_jdk_lock_initializing == 0 )); then + return 0 + fi + if [[ ! -d "$sdkman_switch_jdk_lock_dir" || \ + -L "$sdkman_switch_jdk_lock_dir" || \ + ! -O "$sdkman_switch_jdk_lock_dir" ]]; then + return 1 + fi + + for field in pid euid token label; do + case "$field" in + pid) expected_value="$$" ;; + euid) expected_value="$EUID" ;; + token) expected_value="$sdkman_switch_jdk_lock_token" ;; + label) expected_value="$sdkman_switch_jdk_lock_label" ;; + esac + if [[ ! -e "$sdkman_switch_jdk_lock_dir/$field" && \ + ! -L "$sdkman_switch_jdk_lock_dir/$field" ]]; then + continue + fi + if [[ -z "$expected_value" ]] || \ + ! sdkman_switch_jdk_read_lock_field "$sdkman_switch_jdk_lock_dir/$field" || \ + [[ "$sdkman_switch_jdk_lock_field_value" != "$expected_value" ]] || \ + ! "$sdkman_switch_jdk_cmd_unlink" "$sdkman_switch_jdk_lock_dir/$field"; then + cleanup_status=1 + fi + done + if ! "$sdkman_switch_jdk_cmd_rmdir" "$sdkman_switch_jdk_lock_dir"; then + cleanup_status=1 + fi + if (( cleanup_status != 0 )); then + printf 'Failed to clean up an initializing SDKMAN default-state lock.\n' >&2 + return 1 + fi + sdkman_switch_jdk_lock_initializing=0 + sdkman_switch_jdk_lock_token='' + sdkman_switch_jdk_lock_label='' +} + +sdkman_switch_jdk_reap_stale_lock() { + local expected_pid="$sdkman_switch_jdk_snapshot_pid" + local expected_euid="$sdkman_switch_jdk_snapshot_euid" + local expected_token="$sdkman_switch_jdk_snapshot_token" + local expected_label="$sdkman_switch_jdk_snapshot_label" + local reaper_dir="$sdkman_switch_jdk_lock_dir/reap" + local cleanup_status=0 + local old_umask + + if [[ "$expected_euid" != "$EUID" ]]; then + printf 'Refusing to recover an SDKMAN lock owned by another user.\n' >&2 + return 1 + fi + if kill -0 "$expected_pid" 2>/dev/null; then + printf 'SDKMAN default-state lock is held by live process %s (%s).\n' \ + "$expected_pid" "$expected_label" >&2 + return 1 + fi + old_umask="$(umask)" + umask 077 + if ! "$sdkman_switch_jdk_cmd_mkdir" "$reaper_dir" 2>/dev/null; then + umask "$old_umask" + printf 'SDKMAN default-state lock is being recovered by another process.\n' >&2 + return 1 + fi + umask "$old_umask" + + if ! sdkman_switch_jdk_snapshot_lock "$sdkman_switch_jdk_lock_dir" || \ + [[ "$sdkman_switch_jdk_snapshot_pid" != "$expected_pid" ]] || \ + [[ "$sdkman_switch_jdk_snapshot_euid" != "$expected_euid" ]] || \ + [[ "$sdkman_switch_jdk_snapshot_token" != "$expected_token" ]] || \ + [[ "$sdkman_switch_jdk_snapshot_label" != "$expected_label" ]] || \ + kill -0 "$expected_pid" 2>/dev/null; then + "$sdkman_switch_jdk_cmd_rmdir" "$reaper_dir" 2>/dev/null || true + printf 'SDKMAN default-state lock changed during stale-lock recovery.\n' >&2 + return 1 + fi + + sdkman_switch_jdk_remove_owned_lock_files || cleanup_status=1 + "$sdkman_switch_jdk_cmd_rmdir" "$reaper_dir" || cleanup_status=1 + "$sdkman_switch_jdk_cmd_rmdir" "$sdkman_switch_jdk_lock_dir" || cleanup_status=1 + if (( cleanup_status != 0 )); then + printf 'Failed to clean up a stale SDKMAN default-state lock.\n' >&2 + return 1 + fi +} + +sdkman_switch_jdk_create_lock() { + local nonce + local old_umask + + sdkman_switch_jdk_lock_initializing=1 + old_umask="$(umask)" + umask 077 + nonce="$( + LC_ALL=C "$sdkman_switch_jdk_cmd_od" -An -N 16 -tx1 /dev/urandom | \ + LC_ALL=C "$sdkman_switch_jdk_cmd_tr" -d '[:space:]' + )" || nonce='' + if [[ ! "$nonce" =~ ^[0-9a-f]{32}$ ]]; then + umask "$old_umask" + sdkman_switch_jdk_cleanup_initializing_lock || true + printf 'Failed to create an SDKMAN default-state lock token.\n' >&2 + return 1 + fi + sdkman_switch_jdk_lock_token="$$:${EUID}:${nonce:0:16}:${nonce:16:16}" + if ! printf '%s\n' "$$" > "$sdkman_switch_jdk_lock_dir/pid" || \ + ! printf '%s\n' "$EUID" > "$sdkman_switch_jdk_lock_dir/euid" || \ + ! printf '%s\n' "$sdkman_switch_jdk_lock_token" > "$sdkman_switch_jdk_lock_dir/token" || \ + ! printf '%s\n' "$sdkman_switch_jdk_lock_label" > "$sdkman_switch_jdk_lock_dir/label"; then + umask "$old_umask" + sdkman_switch_jdk_cleanup_initializing_lock || true + printf 'Failed to initialize the SDKMAN default-state lock.\n' >&2 + return 1 + fi + umask "$old_umask" + sdkman_switch_jdk_lock_owned=1 + sdkman_switch_jdk_lock_initializing=0 +} + +sdkman_switch_jdk_acquire_lock() { + local label="$1" + local attempt=0 + local old_umask + local create_status=0 + + if [[ ! "$label" =~ ^[A-Za-z0-9._-]+$ ]]; then + printf 'Invalid SDKMAN default-state lock label.\n' >&2 + return 1 + fi + sdkman_switch_jdk_lock_dir="${SDKMAN_CANDIDATES_DIR:?SDKMAN_CANDIDATES_DIR is not set}/.sdkman-switch-jdk.lock" + sdkman_switch_jdk_lock_label="$label" + + while (( attempt < 2 )); do + old_umask="$(umask)" + umask 077 + sdkman_switch_jdk_lock_acquire_in_progress=1 + if "$sdkman_switch_jdk_cmd_mkdir" "$sdkman_switch_jdk_lock_dir" 2>/dev/null; then + umask "$old_umask" + sdkman_switch_jdk_create_lock + create_status=$? + sdkman_switch_jdk_lock_acquire_in_progress=0 + if (( create_status != 0 )); then + if (( sdkman_switch_jdk_deferred_signal_status != 0 )); then + sdkman_switch_jdk_exit_with_cleanup \ + "$sdkman_switch_jdk_deferred_signal_status" + fi + return 1 + fi + if (( sdkman_switch_jdk_deferred_signal_status != 0 )); then + sdkman_switch_jdk_exit_with_cleanup \ + "$sdkman_switch_jdk_deferred_signal_status" + fi + return 0 + fi + umask "$old_umask" + sdkman_switch_jdk_lock_acquire_in_progress=0 + if (( sdkman_switch_jdk_deferred_signal_status != 0 )); then + sdkman_switch_jdk_exit_with_cleanup \ + "$sdkman_switch_jdk_deferred_signal_status" + fi + if [[ ! -d "$sdkman_switch_jdk_lock_dir" || -L "$sdkman_switch_jdk_lock_dir" ]]; then + printf 'SDKMAN default-state lock path is not a directory: %s\n' \ + "$sdkman_switch_jdk_lock_dir" >&2 + return 1 + fi + if ! sdkman_switch_jdk_snapshot_lock "$sdkman_switch_jdk_lock_dir"; then + printf 'SDKMAN default-state lock metadata is incomplete or unsafe.\n' >&2 + return 1 + fi + if ! sdkman_switch_jdk_reap_stale_lock; then + return 1 + fi + attempt=$((attempt + 1)) + done + + printf 'Could not acquire the SDKMAN default-state lock.\n' >&2 + return 1 +} + +sdkman_switch_jdk_release_lock() { + local cleanup_status=0 + + if (( sdkman_switch_jdk_lock_owned == 0 )); then + return 0 + fi + if ! sdkman_switch_jdk_snapshot_lock "$sdkman_switch_jdk_lock_dir" || \ + [[ "$sdkman_switch_jdk_snapshot_pid" != "$$" ]] || \ + [[ "$sdkman_switch_jdk_snapshot_euid" != "$EUID" ]] || \ + [[ "$sdkman_switch_jdk_snapshot_token" != "$sdkman_switch_jdk_lock_token" ]] || \ + [[ "$sdkman_switch_jdk_snapshot_label" != "$sdkman_switch_jdk_lock_label" ]]; then + printf 'Refusing to release an SDKMAN lock whose ownership changed.\n' >&2 + return 1 + fi + + sdkman_switch_jdk_remove_owned_lock_files || cleanup_status=1 + "$sdkman_switch_jdk_cmd_rmdir" "$sdkman_switch_jdk_lock_dir" || cleanup_status=1 + if (( cleanup_status != 0 )); then + printf 'Failed to release the SDKMAN default-state lock.\n' >&2 + return 1 + fi + sdkman_switch_jdk_lock_owned=0 + sdkman_switch_jdk_lock_token='' + sdkman_switch_jdk_lock_label='' +} + +sdkman_switch_jdk_cleanup_restore_temp() { + local cleanup_status=0 + + if [[ -n "$sdkman_switch_jdk_restore_temp_link" && \ + -L "$sdkman_switch_jdk_restore_temp_link" ]]; then + "$sdkman_switch_jdk_cmd_unlink" "$sdkman_switch_jdk_restore_temp_link" || cleanup_status=1 + fi + if [[ -n "$sdkman_switch_jdk_restore_temp_dir" && \ + -d "$sdkman_switch_jdk_restore_temp_dir" ]]; then + "$sdkman_switch_jdk_cmd_rmdir" "$sdkman_switch_jdk_restore_temp_dir" || cleanup_status=1 + fi + sdkman_switch_jdk_restore_temp_link='' + sdkman_switch_jdk_restore_temp_dir='' + return "$cleanup_status" +} + +sdkman_switch_jdk_register_reconcile_callback() { + local callback="$1" + + if [[ ! "$callback" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || \ + ! type "$callback" >/dev/null 2>&1 || \ + [[ -n "$sdkman_switch_jdk_reconcile_callback" ]]; then + printf 'Could not register the SDKMAN reconciliation callback.\n' >&2 + return 1 + fi + sdkman_switch_jdk_reconcile_callback="$callback" +} + +sdkman_switch_jdk_reconcile_registered_default() { + local default_after + + default_after="$( + sdkman_switch_jdk_default_state "$sdkman_switch_jdk_default_reconcile_current" + )" || { + sdkman_switch_jdk_default_reconcile_failed=1 + return 1 + } + if [[ "$default_after" == "$sdkman_switch_jdk_default_reconcile_before" ]]; then + return 0 + fi + + sdkman_switch_jdk_default_reconcile_changed=1 + if ! sdkman_switch_jdk_restore_default \ + "$sdkman_switch_jdk_default_reconcile_current" \ + "$sdkman_switch_jdk_default_reconcile_before" \ + "$sdkman_switch_jdk_default_reconcile_owned"; then + sdkman_switch_jdk_default_reconcile_failed=1 + printf 'SDKMAN changed the Java default unexpectedly and automatic restoration failed.\n' >&2 + return 1 + fi +} + +sdkman_switch_jdk_register_default_reconciliation() { + sdkman_switch_jdk_default_reconcile_current="$1" + sdkman_switch_jdk_default_reconcile_before="$2" + sdkman_switch_jdk_default_reconcile_owned="$3" + sdkman_switch_jdk_default_reconcile_changed=0 + sdkman_switch_jdk_default_reconcile_failed=0 + sdkman_switch_jdk_register_reconcile_callback \ + sdkman_switch_jdk_reconcile_registered_default +} + +sdkman_switch_jdk_run_reconcile_callback() { + local callback="$sdkman_switch_jdk_reconcile_callback" + + sdkman_switch_jdk_reconcile_callback='' + if [[ -z "$callback" ]]; then + return 0 + fi + "$callback" +} + +sdkman_switch_jdk_finish_operation() { + local requested_status="$1" + local cleanup_status=0 + + if [[ ! "$requested_status" =~ ^[0-9]+$ ]] || \ + (( requested_status > 255 || sdkman_switch_jdk_cleanup_running != 0 )); then + return 1 + fi + + sdkman_switch_jdk_cleanup_running=1 + if ! sdkman_switch_jdk_run_reconcile_callback; then + cleanup_status=1 + fi + if ! sdkman_switch_jdk_cleanup_restore_temp; then + cleanup_status=1 + fi + if ! sdkman_switch_jdk_cleanup_initializing_lock; then + cleanup_status=1 + fi + if ! sdkman_switch_jdk_release_lock; then + cleanup_status=1 + fi + sdkman_switch_jdk_cleanup_running=0 + + if (( cleanup_status != 0 )); then + return 1 + fi + if (( sdkman_switch_jdk_deferred_signal_status != 0 )); then + return "$sdkman_switch_jdk_deferred_signal_status" + fi + return "$requested_status" +} + +sdkman_switch_jdk_exit_with_cleanup() { + local exit_status="$1" + + trap - EXIT + set +e + sdkman_switch_jdk_finish_operation "$exit_status" + exit_status=$? + trap - HUP INT TERM + exit "$exit_status" +} + +sdkman_switch_jdk_handle_signal() { + local signal_status="$1" + + if (( sdkman_switch_jdk_cleanup_running != 0 || \ + sdkman_switch_jdk_lock_acquire_in_progress != 0 || \ + sdkman_switch_jdk_lock_initializing != 0 )); then + if (( sdkman_switch_jdk_deferred_signal_status == 0 )); then + sdkman_switch_jdk_deferred_signal_status="$signal_status" + fi + return 0 + fi + exit "$signal_status" +} + +sdkman_switch_jdk_install_cleanup_traps() { + sdkman_switch_jdk_deferred_signal_status=0 + trap 'sdkman_switch_jdk_exit_with_cleanup "$?"' EXIT + trap 'sdkman_switch_jdk_handle_signal 129' HUP + trap 'sdkman_switch_jdk_handle_signal 130' INT + trap 'sdkman_switch_jdk_handle_signal 143' TERM +} + +sdkman_switch_jdk_clear_cleanup_traps() { + trap - EXIT HUP INT TERM +} + +sdkman_switch_jdk_restore_default() { + local current="$1" + local previous_state="$2" + local owned_state="$3" + local actual_state + local restore_status=0 + local move_status=0 + local cleanup_status=0 + local current_dir="${current%/*}" + + actual_state="$(sdkman_switch_jdk_default_state "$current")" || return 1 + if [[ "$actual_state" == "$previous_state" ]]; then + return 0 + fi + if [[ -z "$owned_state" || "$actual_state" != "$owned_state" ]]; then + printf 'Default drifted after the SDKMAN operation; refusing to restore.\n' >&2 + return 1 + fi + + if [[ "$previous_state" == link-hex:* ]] && \ + sdkman_switch_jdk_decode_link_state "$previous_state"; then + sdkman_switch_jdk_restore_temp_dir="$("$sdkman_switch_jdk_cmd_mktemp" -d "$current_dir/.sdkman-switch-jdk-restore.XXXXXX")" || return 1 + sdkman_switch_jdk_restore_temp_link="$sdkman_switch_jdk_restore_temp_dir/current" + if ! "$sdkman_switch_jdk_cmd_ln" -s -- "$sdkman_switch_jdk_decoded_target" "$sdkman_switch_jdk_restore_temp_link" || \ + [[ ! -L "$sdkman_switch_jdk_restore_temp_link" ]] || \ + [[ "$(sdkman_switch_jdk_link_state "$sdkman_switch_jdk_restore_temp_link")" != "$previous_state" ]]; then + sdkman_switch_jdk_cleanup_restore_temp || true + return 1 + fi + + actual_state="$(sdkman_switch_jdk_default_state "$current")" || restore_status=1 + if (( restore_status != 0 )) || [[ "$actual_state" != "$owned_state" ]]; then + printf 'Default rollback drift detected; refusing to overwrite a concurrent change.\n' >&2 + sdkman_switch_jdk_cleanup_restore_temp || true + return 1 + fi + + if "$sdkman_switch_jdk_cmd_mv" -fh "$sdkman_switch_jdk_restore_temp_link" "$current" 2>/dev/null; then + move_status=0 + else + move_status=$? + fi + if (( move_status != 0 )) && [[ -L "$sdkman_switch_jdk_restore_temp_link" ]]; then + actual_state="$(sdkman_switch_jdk_default_state "$current")" || restore_status=1 + if (( restore_status != 0 )) || [[ "$actual_state" != "$owned_state" ]]; then + printf 'Default rollback drift detected before the GNU fallback; refusing to overwrite a concurrent change.\n' >&2 + sdkman_switch_jdk_cleanup_restore_temp || true + return 1 + fi + if "$sdkman_switch_jdk_cmd_mv" -Tf "$sdkman_switch_jdk_restore_temp_link" "$current" 2>/dev/null; then + move_status=0 + else + move_status=$? + fi + fi + sdkman_switch_jdk_cleanup_restore_temp || cleanup_status=1 + if (( move_status != 0 || cleanup_status != 0 )); then + restore_status=1 + fi + elif [[ "$previous_state" == "absent" && -L "$current" ]]; then + actual_state="$(sdkman_switch_jdk_default_state "$current")" || restore_status=1 + if (( restore_status != 0 )) || [[ "$actual_state" != "$owned_state" ]]; then + printf 'Default rollback drift detected; refusing to remove a concurrent change.\n' >&2 + return 1 + fi + "$sdkman_switch_jdk_cmd_unlink" "$current" || restore_status=1 + else + restore_status=1 + fi + + actual_state="$(sdkman_switch_jdk_default_state "$current")" || restore_status=1 + if (( restore_status != 0 )) || [[ "$actual_state" != "$previous_state" ]]; then + printf 'Default restoration command did not restore the exact previous state.\n' >&2 + return 1 + fi +} diff --git a/test/sdkman-switch-jdk-contract.sh b/test/sdkman-switch-jdk-contract.sh new file mode 100644 index 0000000..7f661b0 --- /dev/null +++ b/test/sdkman-switch-jdk-contract.sh @@ -0,0 +1,1898 @@ +#!/usr/bin/env bash + +# Contract tests for the sdkman-switch-jdk shell scripts. The suite only uses +# temporary fake SDKMAN files and commands; it never sources a real SDKMAN +# installation or accesses the network. + +set -e -o pipefail + +test_root="$(cd "$(dirname "$0")/.." && pwd)" +default_scripts_dir="$test_root/plugins/dev/skills/sdkman-switch-jdk/scripts" +scripts_dir="${SDKMAN_SWITCH_JDK_SCRIPTS_DIR:-$default_scripts_dir}" +if [[ ! -d "$scripts_dir" ]]; then + printf 'SDKMAN_SWITCH_JDK_SCRIPTS_DIR is not a directory: %s\n' "$scripts_dir" >&2 + exit 1 +fi +scripts_dir="$(cd "$scripts_dir" && pwd)" +install_script="$scripts_dir/install-java.sh" +run_script="$scripts_dir/run-java.sh" +full_env_script="$scripts_dir/run-sdkman-env.sh" +if [[ ! -r "$install_script" || ! -r "$run_script" ]]; then + printf 'Missing SDKMAN switch scripts in: %s\n' "$scripts_dir" >&2 + exit 1 +fi + +original_path="$PATH" +real_mv="$(command -v mv)" +real_ln="$(command -v ln)" +real_mkdir="$(command -v mkdir)" +scenario_filter="${1:-}" +if [[ $# -gt 1 ]]; then + printf 'Usage: %s [scenario-name]\n' "${0##*/}" >&2 + exit 2 +fi +root_tmp="$(mktemp -d "${TMPDIR:-/tmp}/sdkman-switch-jdk-contract.XXXXXX")" +case_name='' +case_dir='' +case_java_dir='' +case_current='' +case_pwd='' +case_tmp_dir='' +fake_bin='' +shadow_bin='' +last_status=0 +scenario_count=0 +background_pids='' +live_lock_fixture_dir='' +live_lock_owner_pid='' + +register_background_pid() { + background_pids="${background_pids}${background_pids:+ }$1" +} + +stop_background_processes() { + local background_pid + + for background_pid in $background_pids; do + if kill -0 "$background_pid" 2>/dev/null; then + kill "$background_pid" 2>/dev/null || true + fi + done + for background_pid in $background_pids; do + wait "$background_pid" 2>/dev/null || true + done + background_pids='' +} + +remove_lock_fixture_dir() { + local lock_dir="$1" + + rm -f "$lock_dir/pid" "$lock_dir/euid" "$lock_dir/token" "$lock_dir/label" + rmdir "$lock_dir" 2>/dev/null || true +} + +cleanup() { + local cleanup_status="$?" + trap - EXIT HUP INT TERM + stop_background_processes + if [[ -n "$live_lock_fixture_dir" ]]; then + remove_lock_fixture_dir "$live_lock_fixture_dir" + fi + if [[ -n "$root_tmp" && -d "$root_tmp" ]]; then + find "$root_tmp" -depth -delete + fi + exit "$cleanup_status" +} +trap cleanup EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM + +fail() { + printf 'FAIL [%s]: %s\n' "${case_name:-setup}" "$*" >&2 + if [[ -n "$case_dir" && -s "$case_dir/stderr" ]]; then + printf 'captured script stderr:\n' >&2 + sed -n '1,20p' "$case_dir/stderr" >&2 + fi + if [[ -s "${FAKE_THIRD_WRITER_LOG:-}" ]]; then + printf 'captured third-writer evidence:\n' >&2 + sed -n '1,20p' "$FAKE_THIRD_WRITER_LOG" >&2 + fi + if [[ -s "${FAKE_PRE_CAS_WRITER_LOG:-}" ]]; then + printf 'captured pre-CAS writer evidence:\n' >&2 + sed -n '1,20p' "$FAKE_PRE_CAS_WRITER_LOG" >&2 + fi + if [[ -n "$case_dir" ]]; then + printf 'failed case directory (removed on exit): %s\n' "$case_dir" >&2 + fi + exit 1 +} + +assert_eq() { + local expected="$1" + local actual="$2" + local message="$3" + if [[ "$expected" != "$actual" ]]; then + fail "$message (expected <$expected>, got <$actual>)" + fi +} + +assert_status() { + assert_eq "$1" "$last_status" "$2" +} + +assert_file_contains() { + local file="$1" + local needle="$2" + local message="$3" + if [[ ! -f "$file" ]] || ! grep -F "$needle" "$file" >/dev/null 2>&1; then + fail "$message (missing <$needle> in $file)" + fi +} + +assert_file_not_contains() { + local file="$1" + local needle="$2" + local message="$3" + if [[ -f "$file" ]] && grep -F "$needle" "$file" >/dev/null 2>&1; then + fail "$message (unexpected <$needle> in $file)" + fi +} + +assert_file_empty() { + local file="$1" + local message="$2" + if [[ -s "$file" ]]; then + fail "$message (file is not empty: $file)" + fi +} + +assert_file_executable() { + local file="$1" + local message="$2" + if [[ ! -x "$file" ]]; then + fail "$message (not executable: $file)" + fi +} + +hex_stream() { + LC_ALL=C od -An -v -tx1 | LC_ALL=C tr -d '[:space:]' +} + +raw_target_hex() { + printf '%s' "$1" | hex_stream +} + +link_target_hex() { + readlink -n "$1" | hex_stream +} + +assert_link_target_raw() { + local expected="$1" + local link="$2" + local message="$3" + assert_eq "$(raw_target_hex "$expected")" "$(link_target_hex "$link")" "$message" +} + +assert_path_absent() { + local path="$1" + local message="$2" + if [[ -e "$path" || -L "$path" ]]; then + fail "$message (unexpected path: $path)" + fi +} + +default_state() { + if [[ -L "$case_current" ]]; then + printf 'link:%s\n' "$(readlink "$case_current")" + elif [[ -e "$case_current" ]]; then + printf 'unsupported\n' + else + printf 'absent\n' + fi +} + +candidate_current_path() { + local candidate="$1" + + printf '%s/%s/current\n' "$SDKMAN_CANDIDATES_DIR" "$candidate" +} + +candidate_default_state() { + local candidate="$1" + local current + + current="$(candidate_current_path "$candidate")" + if [[ -L "$current" ]]; then + printf 'link:%s\n' "$(readlink "$current")" + elif [[ -e "$current" ]]; then + printf 'unsupported\n' + else + printf 'absent\n' + fi +} + +assert_candidate_default_state() { + local candidate="$1" + local expected="$2" + local message="$3" + + assert_eq "$expected" "$(candidate_default_state "$candidate")" "$message" +} + +assert_default_state() { + local expected="$1" + local message="$2" + assert_eq "$expected" "$(default_state)" "$message" +} + +create_java_candidate() { + local candidate="$1" + mkdir -p "$candidate/bin" + { + printf '%s\n' '#!/usr/bin/env bash' + printf '%s\n' 'log="${FAKE_JAVA_LOG:?}"' + printf '%s\n' 'printf "java JAVA_HOME=<%s>\n" "${JAVA_HOME-}" >> "$log"' + printf '%s\n' 'printf "java argc=%s\n" "$#" >> "$log"' + printf '%s\n' 'java_index=0' + printf '%s\n' 'for java_arg in "$@"; do' + printf '%s\n' ' printf "java arg[%s]=<%s>\n" "$java_index" "$java_arg" >> "$log"' + printf '%s\n' ' java_index=$((java_index + 1))' + printf '%s\n' 'done' + printf '%s\n' 'if [[ "${1-}" == "-version" ]]; then' + printf '%s\n' ' printf "%s\n" "fake-java version" >&2' + printf '%s\n' 'fi' + printf '%s\n' 'exit "${FAKE_JAVA_STATUS:-0}"' + } > "$candidate/bin/java" + chmod +x "$candidate/bin/java" +} + +create_incomplete_candidate() { + local candidate="$1" + mkdir -p "$candidate/bin" +} + +write_sdkmanrc() { + # The environment runner explicitly applies each validated entry with + # `sdk use`; model SDKMAN's current-link mutation for those invocations. + export FAKE_SDK_USE_MUTATE=target_rel + export FAKE_SDK_ENV_DIRECT=yes + : > "$case_dir/.sdkmanrc" + while [[ $# -gt 0 ]]; do + printf '%s\n' "$1" >> "$case_dir/.sdkmanrc" + shift + done +} + +set_full_env_operation_raw_target() { + local candidate="$1" + local raw_target="$2" + + printf '%s %s\n' "$candidate" "$raw_target" >> "$FAKE_SDK_ENV_OPERATION_FILE" +} + +write_fake_init() { + local init_file="$SDKMAN_DIR/bin/sdkman-init.sh" + { + printf '%s\n' '# fake SDKMAN init used by sdkman-switch-jdk-contract.sh' + printf '%s\n' 'printf "init SDKMAN_ENV=<%s> SDKMAN_OLD_PWD=<%s>\n" "${SDKMAN_ENV-}" "${SDKMAN_OLD_PWD-}" >> "${FAKE_INIT_LOG:?}"' + printf '%s\n' 'fake_sdk_write_candidate() {' + printf '%s\n' ' local fake_candidate="$1"' + printf '%s\n' ' mkdir -p "$fake_candidate/bin"' + printf '%s\n' ' {' + printf '%s\n' ' printf "%s\n" "#!/usr/bin/env bash"' + printf '%s\n' ' printf "%s\n" '\''log="${FAKE_JAVA_LOG:?}"'\''' + printf '%s\n' ' printf "%s\n" '\''printf "java JAVA_HOME=<%s>\\n" "${JAVA_HOME-}" >> "$log"'\''' + printf '%s\n' ' printf "%s\n" '\''printf "java argc=%s\\n" "$#" >> "$log"'\''' + printf '%s\n' ' printf "%s\n" '\''java_index=0'\''' + printf '%s\n' ' printf "%s\n" '\''for java_arg in "$@"; do'\''' + printf '%s\n' ' printf "%s\n" '\'' printf "java arg[%s]=<%s>\\n" "$java_index" "$java_arg" >> "$log"'\''' + printf '%s\n' ' printf "%s\n" '\'' java_index=$((java_index + 1))'\''' + printf '%s\n' ' printf "%s\n" '\''done'\''' + printf '%s\n' ' printf "%s\n" '\''if [[ "${1-}" == "-version" ]]; then'\''' + printf '%s\n' ' printf "%s\n" '\'' printf "%s\\n" "fake-java version" >&2'\''' + printf '%s\n' ' printf "%s\n" '\''fi'\''' + printf '%s\n' ' printf "%s\n" '\''exit "${FAKE_JAVA_STATUS:-0}"'\''' + printf '%s\n' ' } > "$fake_candidate/bin/java"' + printf '%s\n' ' chmod +x "$fake_candidate/bin/java"' + printf '%s\n' '}' + printf '%s\n' 'fake_sdk_activate_candidate() {' + printf '%s\n' ' local fake_candidate_name="$1"' + printf '%s\n' ' local fake_identifier="$2"' + printf '%s\n' ' if [[ "$fake_candidate_name" != java ]]; then return 0; fi' + printf '%s\n' ' fake_java_home="${SDKMAN_CANDIDATES_DIR}/java/${fake_identifier}"' + printf '%s\n' ' [[ -x "$fake_java_home/bin/java" ]] || fake_sdk_write_candidate "$fake_java_home" || return $?' + printf '%s\n' ' export JAVA_HOME="$fake_java_home"' + printf '%s\n' ' export PATH="$JAVA_HOME/bin:$PATH"' + printf '%s\n' '}' + printf '%s\n' 'fake_sdk_set_candidate_current() {' + printf '%s\n' ' local fake_candidate="$1"' + printf '%s\n' ' local fake_target="$2"' + printf '%s\n' ' local fake_current="${SDKMAN_CANDIDATES_DIR}/${fake_candidate}/current"' + printf '%s\n' ' mkdir -p "${SDKMAN_CANDIDATES_DIR}/${fake_candidate}"' + printf '%s\n' ' if [[ -L "$fake_current" || -e "$fake_current" ]]; then unlink "$fake_current"; fi' + printf '%s\n' ' ln -s -- "$fake_target" "$fake_current"' + printf '%s\n' '}' + printf '%s\n' 'fake_sdk_set_current() {' + printf '%s\n' ' fake_sdk_set_candidate_current java "$1"' + printf '%s\n' '}' + printf '%s\n' 'fake_sdk_env_operation_target() {' + printf '%s\n' ' local fake_candidate="$1"' + printf '%s\n' ' local fake_identifier="$2"' + printf '%s\n' ' local fake_config_candidate' + printf '%s\n' ' local fake_config_target' + printf '%s\n' ' fake_sdk_env_target="$fake_identifier"' + printf '%s\n' ' [[ -n "${FAKE_SDK_ENV_OPERATION_FILE:-}" && -f "$FAKE_SDK_ENV_OPERATION_FILE" ]] || return 0' + printf '%s\n' ' while read -r fake_config_candidate fake_config_target; do' + printf '%s\n' ' if [[ "$fake_config_candidate" == "$fake_candidate" ]]; then fake_sdk_env_target="$fake_config_target"; return 0; fi' + printf '%s\n' ' done < "$FAKE_SDK_ENV_OPERATION_FILE"' + printf '%s\n' '}' + printf '%s\n' 'fake_sdk_maybe_replace_project_rc() {' + printf '%s\n' ' if [[ -z "${FAKE_SDK_ENV_REPLACEMENT:-}" || -n "${fake_sdk_replaced_project_rc:-}" ]]; then return 0; fi' + printf '%s\n' ' "${FAKE_REAL_MV:?}" -f "$FAKE_SDK_ENV_REPLACEMENT" "${FAKE_PROJECT_SDKMANRC:?}" || return $?' + printf '%s\n' ' fake_sdk_replaced_project_rc=1' + printf '%s\n' ' printf "sdkmanrc-replace pid=<%s> cwd=<%s> destination=<%s>\\n" "$$" "$PWD" "$FAKE_PROJECT_SDKMANRC" >> "${FAKE_SDK_ENV_REPLACE_LOG:?}"' + printf '%s\n' '}' + # This models the forbidden second-read behavior for the TOCTOU mutation + # proof. The production runner must never select this fake SDK command. + printf '%s\n' 'fake_sdk_env_install() {' + printf '%s\n' ' local fake_candidate' + printf '%s\n' ' local fake_identifier' + printf '%s\n' ' local fake_java_identifier=""' + printf '%s\n' ' [[ -f .sdkmanrc ]] || return 65' + printf '%s\n' ' if [[ -n "${FAKE_SDK_ENV_REPLACEMENT:-}" ]]; then' + printf '%s\n' ' "${FAKE_REAL_MV:?}" -f "$FAKE_SDK_ENV_REPLACEMENT" "${FAKE_PROJECT_SDKMANRC:?}" || return $?' + printf '%s\n' ' printf "sdkmanrc-replace pid=<%s> cwd=<%s> destination=<%s>\\n" "$$" "$PWD" "$FAKE_PROJECT_SDKMANRC" >> "${FAKE_SDK_ENV_REPLACE_LOG:?}"' + printf '%s\n' ' fi' + printf '%s\n' ' while IFS="=" read -r fake_candidate fake_identifier; do' + printf '%s\n' ' [[ -n "$fake_candidate" && -n "$fake_identifier" ]] || continue' + printf '%s\n' ' printf "sdk env candidate=<%s> version=<%s>\\n" "$fake_candidate" "$fake_identifier" >> "${FAKE_SDK_LOG:?}"' + printf '%s\n' ' fake_sdk_env_operation_target "$fake_candidate" "$fake_identifier"' + printf '%s\n' ' fake_sdk_set_candidate_current "$fake_candidate" "$fake_sdk_env_target" || return $?' + printf '%s\n' ' if [[ "$fake_candidate" == java ]]; then fake_java_identifier="$fake_identifier"; fi' + printf '%s\n' ' done < .sdkmanrc' + printf '%s\n' ' if [[ -n "$fake_java_identifier" ]]; then' + printf '%s\n' ' fake_java_home="${SDKMAN_CANDIDATES_DIR}/java/${fake_java_identifier}"' + printf '%s\n' ' [[ -x "$fake_java_home/bin/java" ]] || fake_sdk_write_candidate "$fake_java_home"' + printf '%s\n' ' export JAVA_HOME="$fake_java_home"' + printf '%s\n' ' export PATH="$JAVA_HOME/bin:$PATH"' + printf '%s\n' ' fi' + printf '%s\n' ' fake_sdk_signal_after_mutation || return $?' + printf '%s\n' ' fake_sdk_run_third_writer || return $?' + printf '%s\n' ' return "${FAKE_SDK_ENV_INSTALL_STATUS:-0}"' + printf '%s\n' '}' + printf '%s\n' 'fake_sdk_run_third_writer() {' + printf '%s\n' ' local fake_operation_candidate="${1-}"' + printf '%s\n' ' local fake_operation_action="${2-}"' + printf '%s\n' ' local fake_writer_pid' + printf '%s\n' ' local fake_writer_candidate="${FAKE_SDK_THIRD_WRITER_CANDIDATE:-java}"' + printf '%s\n' ' local fake_writer_current="${SDKMAN_CANDIDATES_DIR}/${fake_writer_candidate}/current"' + printf '%s\n' ' if [[ -z "${FAKE_SDK_THIRD_WRITER_TARGET:-}" ]]; then return 0; fi' + printf '%s\n' ' if [[ -n "${FAKE_SDK_THIRD_WRITER_AFTER_CANDIDATE:-}" && "$fake_operation_candidate" != "$FAKE_SDK_THIRD_WRITER_AFTER_CANDIDATE" ]]; then return 0; fi' + printf '%s\n' ' if [[ -n "${FAKE_SDK_THIRD_WRITER_AFTER_ACTION:-}" && "$fake_operation_action" != "$FAKE_SDK_THIRD_WRITER_AFTER_ACTION" ]]; then return 0; fi' + printf '%s\n' ' "$BASH" -c '\''set -e' + printf '%s\n' ' fake_writer_current="${SDKMAN_CANDIDATES_DIR}/${FAKE_SDK_THIRD_WRITER_CANDIDATE:-java}/current"' + printf '%s\n' ' mkdir -p "$(dirname "$fake_writer_current")"' + printf '%s\n' ' if [[ -L "$fake_writer_current" || -e "$fake_writer_current" ]]; then unlink "$fake_writer_current"; fi' + printf '%s\n' ' ln -s -- "$FAKE_SDK_THIRD_WRITER_TARGET" "$fake_writer_current"' + printf '%s\n' ' printf "third-writer pid=<%s> candidate=<%s> target=<%s>\\n" "$$" "${FAKE_SDK_THIRD_WRITER_CANDIDATE:-java}" "$FAKE_SDK_THIRD_WRITER_TARGET" >> "${FAKE_THIRD_WRITER_LOG:?}"'\'' &' + printf '%s\n' ' fake_writer_pid=$!' + printf '%s\n' ' wait "$fake_writer_pid" || return $?' + printf '%s\n' ' printf "sdk third-writer waited-pid=<%s> candidate=<%s> target=<%s>\\n" "$fake_writer_pid" "$fake_writer_candidate" "$FAKE_SDK_THIRD_WRITER_TARGET" >> "${FAKE_SDK_LOG:?}"' + printf '%s\n' '}' + printf '%s\n' 'fake_sdk_signal_after_mutation() {' + printf '%s\n' ' local fake_operation_candidate="${1-}"' + printf '%s\n' ' local fake_operation_action="${2-}"' + printf '%s\n' ' if [[ -z "${FAKE_SDK_SIGNAL_AFTER_MUTATION:-}" ]]; then return 0; fi' + printf '%s\n' ' if [[ -n "${FAKE_SDK_SIGNAL_AFTER_CANDIDATE:-}" && "$fake_operation_candidate" != "$FAKE_SDK_SIGNAL_AFTER_CANDIDATE" ]]; then return 0; fi' + printf '%s\n' ' if [[ -n "${FAKE_SDK_SIGNAL_AFTER_ACTION:-}" && "$fake_operation_action" != "$FAKE_SDK_SIGNAL_AFTER_ACTION" ]]; then return 0; fi' + printf '%s\n' ' printf "sdk-signal pid=<%s> signal=<%s>\\n" "$$" "$FAKE_SDK_SIGNAL_AFTER_MUTATION" >> "${FAKE_SDK_SIGNAL_LOG:?}"' + printf '%s\n' ' case "$FAKE_SDK_SIGNAL_AFTER_MUTATION" in' + printf '%s\n' ' TERM) kill -TERM "$$" ;;' + printf '%s\n' ' *) return 97 ;;' + printf '%s\n' ' esac' + printf '%s\n' '}' + printf '%s\n' 'sdk() {' + printf '%s\n' ' {' + printf '%s\n' ' printf "sdk pid=<%s>\\n" "$$"' + printf '%s\n' ' printf "sdk USE=<%s>\\n" "${USE-}"' + printf '%s\n' ' printf "sdk argc=%s\\n" "$#"' + printf '%s\n' ' sdk_index=0' + printf '%s\n' ' for sdk_arg in "$@"; do' + printf '%s\n' ' printf "sdk arg[%s]=<%s>\\n" "$sdk_index" "$sdk_arg"' + printf '%s\n' ' sdk_index=$((sdk_index + 1))' + printf '%s\n' ' done' + printf '%s\n' ' } >> "${FAKE_SDK_LOG:?}"' + printf '%s\n' ' case "${1-}" in' + printf '%s\n' ' install)' + printf '%s\n' ' fake_candidate_name="${2-}"' + printf '%s\n' ' fake_identifier="${3-}"' + printf '%s\n' ' fake_candidate="${SDKMAN_CANDIDATES_DIR}/${fake_candidate_name}/${fake_identifier}"' + printf '%s\n' ' fake_install_answer=""' + printf '%s\n' ' if IFS= read -r fake_install_answer; then :; fi' + printf '%s\n' ' printf "sdk stdin=<%s>\\n" "$fake_install_answer" >> "${FAKE_SDK_LOG:?}"' + printf '%s\n' ' fake_sdk_maybe_replace_project_rc || return $?' + printf '%s\n' ' if [[ "${FAKE_SDK_INSTALL_CREATE:-yes}" == yes ]]; then fake_sdk_write_candidate "$fake_candidate"; fi' + printf '%s\n' ' case "${FAKE_SDK_INSTALL_MUTATE:-none}" in' + printf '%s\n' ' target_abs) fake_sdk_set_candidate_current "$fake_candidate_name" "$fake_candidate" ;;' + printf '%s\n' ' target_rel) fake_sdk_set_candidate_current "$fake_candidate_name" "$fake_identifier" ;;' + printf '%s\n' ' none) : ;;' + printf '%s\n' ' *) printf "unknown install mutation: %s\\n" "${FAKE_SDK_INSTALL_MUTATE}" >&2; return 99 ;;' + printf '%s\n' ' esac' + printf '%s\n' ' fake_sdk_signal_after_mutation "$fake_candidate_name" install || return $?' + printf '%s\n' ' fake_sdk_run_third_writer "$fake_candidate_name" install || return $?' + printf '%s\n' ' if [[ -n "${FAKE_SDK_ENV_INSTALL_STATUS:-}" && "${FAKE_SDK_ENV_INSTALL_STATUS}" != 0 ]]; then return "$FAKE_SDK_ENV_INSTALL_STATUS"; fi' + printf '%s\n' ' return "${FAKE_SDK_INSTALL_STATUS:-0}"' + printf '%s\n' ' ;;' + printf '%s\n' ' use)' + printf '%s\n' ' fake_candidate_name="${2-}"' + printf '%s\n' ' fake_identifier="${3-}"' + printf '%s\n' ' fake_candidate="${SDKMAN_CANDIDATES_DIR}/${fake_candidate_name}/${fake_identifier}"' + printf '%s\n' ' fake_sdk_maybe_replace_project_rc || return $?' + printf '%s\n' ' case "${FAKE_SDK_USE_MUTATE:-none}" in' + printf '%s\n' ' target_abs) fake_sdk_set_candidate_current "$fake_candidate_name" "$fake_candidate" ;;' + printf '%s\n' ' target_rel) fake_sdk_env_operation_target "$fake_candidate_name" "$fake_identifier"; fake_sdk_set_candidate_current "$fake_candidate_name" "$fake_sdk_env_target" ;;' + printf '%s\n' ' none) : ;;' + printf '%s\n' ' *) printf "unknown use mutation: %s\\n" "${FAKE_SDK_USE_MUTATE}" >&2; return 98 ;;' + printf '%s\n' ' esac' + printf '%s\n' ' if [[ "${FAKE_SDK_ENV_DIRECT:-no}" == yes ]]; then fake_sdk_activate_candidate "$fake_candidate_name" "$fake_identifier" || return $?; fi' + printf '%s\n' ' fake_sdk_signal_after_mutation "$fake_candidate_name" use || return $?' + printf '%s\n' ' fake_sdk_run_third_writer "$fake_candidate_name" use || return $?' + printf '%s\n' ' return "${FAKE_SDK_USE_STATUS:-0}"' + printf '%s\n' ' ;;' + printf '%s\n' ' env)' + printf '%s\n' ' [[ "${2-}" == install ]] || return 64' + printf '%s\n' ' fake_sdk_env_install' + printf '%s\n' ' return $?' + printf '%s\n' ' ;;' + printf '%s\n' ' *) return 64 ;;' + printf '%s\n' ' esac' + printf '%s\n' '}' + } > "$init_file" +} + +write_fake_mv() { + { + printf '%s\n' '#!/usr/bin/env bash' + printf '%s\n' 'log="${FAKE_MV_LOG:?}"' + printf '%s\n' 'printf "mv argc=%s\\n" "$#" >> "$log"' + printf '%s\n' 'mv_index=0' + printf '%s\n' 'for mv_arg in "$@"; do' + printf '%s\n' ' printf "mv arg[%s]=<%s>\\n" "$mv_index" "$mv_arg" >> "$log"' + printf '%s\n' ' mv_index=$((mv_index + 1))' + printf '%s\n' 'done' + printf '%s\n' 'if [[ "${1-}" == "-fh" && -n "${FAKE_MV_GNU_FALLBACK_WRITER_TARGET:-}" ]]; then' + printf '%s\n' ' "$BASH" -c '\''set -e' + printf '%s\n' ' fake_writer_candidate="${FAKE_MV_GNU_FALLBACK_WRITER_CANDIDATE:-java}"' + printf '%s\n' ' fake_writer_current="${SDKMAN_CANDIDATES_DIR}/${fake_writer_candidate}/current"' + printf '%s\n' ' printf "gnu-fallback-writer-ready pid=<%s> candidate=<%s>\\n" "$$" "$fake_writer_candidate" > "${FAKE_MV_GNU_FALLBACK_WRITER_MARKER:?}"' + printf '%s\n' ' if [[ -L "$fake_writer_current" || -e "$fake_writer_current" ]]; then unlink "$fake_writer_current"; fi' + printf '%s\n' ' ln -s -- "$FAKE_MV_GNU_FALLBACK_WRITER_TARGET" "$fake_writer_current"' + printf '%s\n' ' printf "gnu-fallback-writer-complete pid=<%s> candidate=<%s> target=<%s>\\n" "$$" "$fake_writer_candidate" "$FAKE_MV_GNU_FALLBACK_WRITER_TARGET" >> "${FAKE_MV_GNU_FALLBACK_WRITER_LOG:?}"'\'' &' + printf '%s\n' ' fake_mv_writer_pid=$!' + printf '%s\n' ' wait "$fake_mv_writer_pid" || exit $?' + printf '%s\n' ' printf "mv gnu-fallback parent-pid=<%s> waited-writer-pid=<%s> target=<%s>\\n" "$$" "$fake_mv_writer_pid" "$FAKE_MV_GNU_FALLBACK_WRITER_TARGET" >> "$log"' + printf '%s\n' ' exit 64' + printf '%s\n' 'fi' + printf '%s\n' 'case "${FAKE_MV_MODE:-bsd}" in' + printf '%s\n' ' bsd) [[ "${1-}" == "-fh" ]] || exit 66 ;;' + printf '%s\n' ' gnu) if [[ "${1-}" == "-fh" ]]; then exit 64; fi; [[ "${1-}" == "-Tf" ]] || exit 65 ;;' + printf '%s\n' ' fail) exit 75 ;;' + printf '%s\n' ' *) exit 76 ;;' + printf '%s\n' 'esac' + printf '%s\n' 'mv_source="${2-}"' + printf '%s\n' 'mv_destination="${3-}"' + printf '%s\n' 'if [[ -n "${FAKE_MV_FAIL_DESTINATION:-}" && "$mv_destination" == "$FAKE_MV_FAIL_DESTINATION" ]]; then exit 75; fi' + printf '%s\n' 'if "${FAKE_REAL_MV:?}" -fh "$mv_source" "$mv_destination" 2>/dev/null; then' + printf '%s\n' ' :' + printf '%s\n' 'elif [[ -L "$mv_source" ]] && "${FAKE_REAL_MV:?}" -Tf "$mv_source" "$mv_destination" 2>/dev/null; then' + printf '%s\n' ' :' + printf '%s\n' 'else' + printf '%s\n' ' exit 77' + printf '%s\n' 'fi' + } > "$fake_bin/mv" + chmod +x "$fake_bin/mv" +} + +write_fake_ln() { + { + printf '%s\n' '#!/usr/bin/env bash' + printf '%s\n' 'fake_ln_destination=""' + printf '%s\n' 'for fake_ln_arg in "$@"; do fake_ln_destination="$fake_ln_arg"; done' + printf '%s\n' 'if "${FAKE_REAL_LN:?}" "$@"; then :; else exit $?; fi' + printf '%s\n' 'if [[ -z "${FAKE_LN_PRE_CAS_WRITER_TARGET:-}" ]]; then exit 0; fi' + printf '%s\n' 'case "$fake_ln_destination" in' + printf '%s\n' ' "${FAKE_RESTORE_TEMP_PREFIX:?}"*/current)' + printf '%s\n' ' "$BASH" -c '\''set -e' + printf '%s\n' ' if [[ -L "$FAKE_CURRENT_PATH" || -e "$FAKE_CURRENT_PATH" ]]; then unlink "$FAKE_CURRENT_PATH"; fi' + printf '%s\n' ' "$FAKE_REAL_LN" -s -- "$FAKE_LN_PRE_CAS_WRITER_TARGET" "$FAKE_CURRENT_PATH"' + printf '%s\n' ' printf "pre-cas writer pid=<%s> ppid=<%s> target=<%s> destination=<%s>\\n" "$$" "$PPID" "$FAKE_LN_PRE_CAS_WRITER_TARGET" "$1" >> "${FAKE_PRE_CAS_WRITER_LOG:?}"'\'' bash "$fake_ln_destination" &' + printf '%s\n' ' fake_ln_writer_pid=$!' + printf '%s\n' ' wait "$fake_ln_writer_pid" || exit $?' + printf '%s\n' ' printf "ln wrapper pid=<%s> waited pre-cas writer pid=<%s> destination=<%s>\\n" "$$" "$fake_ln_writer_pid" "$fake_ln_destination" >> "${FAKE_PRE_CAS_WRITER_LOG:?}"' + printf '%s\n' ' ;;' + printf '%s\n' 'esac' + } > "$fake_bin/ln" + chmod +x "$fake_bin/ln" +} + +write_fake_mkdir() { + { + printf '%s\n' '#!/usr/bin/env bash' + printf '%s\n' '"${FAKE_REAL_MKDIR:?}" "$@" || exit $?' + printf '%s\n' 'fake_mkdir_destination=""' + printf '%s\n' 'for fake_mkdir_arg in "$@"; do fake_mkdir_destination="$fake_mkdir_arg"; done' + printf '%s\n' 'if [[ "${FAKE_MKDIR_SIGNAL_LOCK_INIT:-}" != TERM || "$fake_mkdir_destination" != "${FAKE_LOCK_PATH:-}" ]]; then exit 0; fi' + printf '%s\n' 'if [[ -e "${FAKE_MKDIR_SIGNAL_MARKER:?}" ]]; then exit 0; fi' + printf '%s\n' 'set -C' + printf '%s\n' ': > "${FAKE_MKDIR_SIGNAL_MARKER:?}" 2>/dev/null || exit 0' + printf '%s\n' 'set +C' + printf '%s\n' 'printf "lock-init-signal pid=<%s> parent=<%s> destination=<%s>\\n" "$$" "$PPID" "$fake_mkdir_destination" >> "${FAKE_MKDIR_SIGNAL_LOG:?}"' + printf '%s\n' 'kill -TERM "$PPID"' + } > "$fake_bin/mkdir" + chmod +x "$fake_bin/mkdir" +} + +write_payload() { + { + printf '%s\n' '#!/usr/bin/env bash' + printf '%s\n' 'log="${FAKE_PAYLOAD_LOG:?}"' + printf '%s\n' 'printf "payload JAVA_HOME=<%s>\\n" "${JAVA_HOME-}" > "$log"' + printf '%s\n' 'printf "payload PATH=<%s>\\n" "${PATH-}" >> "$log"' + printf '%s\n' 'printf "payload java=<%s>\\n" "$(command -v java)" >> "$log"' + printf '%s\n' 'printf "payload argc=%s\\n" "$#" >> "$log"' + printf '%s\n' 'payload_index=0' + printf '%s\n' 'for payload_arg in "$@"; do' + printf '%s\n' ' printf "payload arg[%s]=<%s>\\n" "$payload_index" "$payload_arg" >> "$log"' + printf '%s\n' ' payload_index=$((payload_index + 1))' + printf '%s\n' 'done' + printf '%s\n' 'exit "${FAKE_PAYLOAD_STATUS:-0}"' + } > "$case_dir/payload" + chmod +x "$case_dir/payload" +} + +write_shadow_java() { + { + printf '%s\n' '#!/usr/bin/env bash' + printf '%s\n' 'printf "%s\\n" shadow-java-called >> "${FAKE_SHADOW_LOG:?}"' + printf '%s\n' 'exit 0' + } > "$shadow_bin/java" + chmod +x "$shadow_bin/java" +} + +write_candidate_path_hijack() { + local candidate="$1" + + { + printf '%s\n' '#!/usr/bin/env bash' + printf '%s\n' 'printf "%s\\n" candidate-path-mv-called >> "${FAKE_PATH_HIJACK_LOG:?}"' + printf '%s\n' 'exit 96' + } > "$candidate/bin/mv" + chmod +x "$candidate/bin/mv" +} + +begin_case() { + case_name="$1" + case_dir="$root_tmp/$case_name" + case_java_dir="$case_dir/candidates/java" + case_current="$case_java_dir/current" + case_tmp_dir="$case_dir/tmp" + fake_bin="$case_dir/fake-bin" + shadow_bin="$case_dir/shadow-bin" + mkdir -p "$case_dir/sdkman/bin" "$case_java_dir" "$fake_bin" "$shadow_bin" "$case_dir/home" "$case_tmp_dir" + case_pwd="$(cd "$case_dir" && pwd)" + + export HOME="$case_dir/home" + export TMPDIR="$case_tmp_dir" + export SDKMAN_DIR="$case_dir/sdkman" + export SDKMAN_CANDIDATES_DIR="$case_dir/candidates" + export FAKE_CURRENT_PATH="$case_current" + export FAKE_INIT_LOG="$case_dir/init.log" + export FAKE_SDK_LOG="$case_dir/sdk.log" + export FAKE_MV_LOG="$case_dir/mv.log" + export FAKE_JAVA_LOG="$case_dir/java.log" + export FAKE_PAYLOAD_LOG="$case_dir/payload.log" + export FAKE_SHADOW_LOG="$case_dir/shadow.log" + export FAKE_THIRD_WRITER_LOG="$case_dir/third-writer.log" + export FAKE_PRE_CAS_WRITER_LOG="$case_dir/pre-cas-writer.log" + export FAKE_MV_GNU_FALLBACK_WRITER_LOG="$case_dir/gnu-fallback-writer.log" + export FAKE_MV_GNU_FALLBACK_WRITER_MARKER="$case_dir/gnu-fallback-writer.ready" + export FAKE_SDK_ENV_REPLACE_LOG="$case_dir/sdkmanrc-replace.log" + export FAKE_SDK_SIGNAL_LOG="$case_dir/sdk-signal.log" + export FAKE_PATH_HIJACK_LOG="$case_dir/path-hijack.log" + export FAKE_SDK_ENV_OPERATION_FILE="$case_dir/env-operation-targets" + export FAKE_REAL_MV="$real_mv" + export FAKE_REAL_LN="$real_ln" + export FAKE_REAL_MKDIR="$real_mkdir" + export FAKE_MKDIR_SIGNAL_LOG="$case_dir/lock-init-signal.log" + export FAKE_MKDIR_SIGNAL_MARKER="$case_dir/lock-init-signal.marker" + export FAKE_LOCK_PATH="$SDKMAN_CANDIDATES_DIR/.sdkman-switch-jdk.lock" + export FAKE_RESTORE_TEMP_PREFIX="$case_java_dir/.sdkman-switch-jdk-restore." + export FAKE_SDK_INSTALL_STATUS=0 + export FAKE_SDK_INSTALL_CREATE=yes + export FAKE_SDK_INSTALL_MUTATE=none + unset FAKE_SDK_THIRD_WRITER_TARGET + unset FAKE_SDK_THIRD_WRITER_CANDIDATE + unset FAKE_SDK_THIRD_WRITER_AFTER_CANDIDATE + unset FAKE_SDK_THIRD_WRITER_AFTER_ACTION + unset FAKE_LN_PRE_CAS_WRITER_TARGET + unset FAKE_MV_GNU_FALLBACK_WRITER_TARGET + unset FAKE_MV_GNU_FALLBACK_WRITER_CANDIDATE + unset FAKE_SDK_ENV_REPLACEMENT + unset FAKE_PROJECT_SDKMANRC + unset FAKE_SDK_ENV_DIRECT + unset FAKE_MKDIR_SIGNAL_LOCK_INIT + unset FAKE_SDK_SIGNAL_AFTER_MUTATION + unset FAKE_SDK_SIGNAL_AFTER_CANDIDATE + unset FAKE_SDK_SIGNAL_AFTER_ACTION + export FAKE_SDK_USE_STATUS=0 + export FAKE_SDK_USE_MUTATE=none + export FAKE_MV_MODE=bsd + unset FAKE_MV_FAIL_DESTINATION + export FAKE_SDK_ENV_INSTALL_STATUS=0 + export FAKE_JAVA_STATUS=0 + export FAKE_PAYLOAD_STATUS=0 + export PATH="$fake_bin:$original_path" + + : > "$FAKE_INIT_LOG" + : > "$FAKE_SDK_LOG" + : > "$FAKE_MV_LOG" + : > "$FAKE_JAVA_LOG" + : > "$FAKE_PAYLOAD_LOG" + : > "$FAKE_SHADOW_LOG" + : > "$FAKE_THIRD_WRITER_LOG" + : > "$FAKE_PRE_CAS_WRITER_LOG" + : > "$FAKE_MV_GNU_FALLBACK_WRITER_LOG" + : > "$FAKE_SDK_ENV_REPLACE_LOG" + : > "$FAKE_SDK_SIGNAL_LOG" + : > "$FAKE_PATH_HIJACK_LOG" + : > "$FAKE_MKDIR_SIGNAL_LOG" + : > "$FAKE_SDK_ENV_OPERATION_FILE" + write_fake_init + write_fake_mv + write_fake_ln + write_fake_mkdir + write_payload +} + +set_default() { + local mode="$1" + local identifier="$2" + local raw_target="${3:-$identifier}" + case "$mode" in + absolute) + ln -s -- "$case_java_dir/$identifier" "$case_current" + ;; + relative) + ln -s -- "$raw_target" "$case_current" + ;; + absent) + ;; + *) + fail "unknown default mode: $mode" + ;; + esac +} + +set_candidate_default() { + local candidate="$1" + local mode="$2" + local identifier="$3" + local raw_target="${4:-$identifier}" + local current + + current="$(candidate_current_path "$candidate")" + mkdir -p "$(dirname "$current")" + case "$mode" in + absolute) + ln -s -- "$SDKMAN_CANDIDATES_DIR/$candidate/$identifier" "$current" + ;; + relative) + ln -s -- "$raw_target" "$current" + ;; + absent) + ;; + *) + fail "unknown candidate default mode: $mode" + ;; + esac +} + +run_capture() { + set +e + (cd "$case_dir" && "$@") > "$case_dir/stdout" 2> "$case_dir/stderr" + last_status=$? + set -e +} + +assert_no_restore_temp_dirs() { + local leftover + for leftover in "$case_java_dir"/.sdkman-switch-jdk-restore.*; do + if [[ -e "$leftover" ]]; then + fail "restore temporary directory remains: $leftover" + fi + done +} + +assert_third_writer_ran() { + local expected_target="$1" + local expected_candidate="${2:-java}" + local sdk_pid + local writer_pid + + assert_file_contains "$FAKE_THIRD_WRITER_LOG" 'third-writer pid=<' \ + 'third writer records its subprocess PID' + assert_file_contains "$FAKE_THIRD_WRITER_LOG" "candidate=<$expected_candidate>" \ + 'third writer records the candidate it changed' + assert_file_contains "$FAKE_THIRD_WRITER_LOG" "target=<$expected_target>" \ + 'third writer records its target' + assert_file_contains "$FAKE_SDK_LOG" 'sdk third-writer waited-pid=<' \ + 'fake SDK waits for the third writer before returning' + sdk_pid="$(sed -n 's/^sdk pid=<\([0-9][0-9]*\)>$/\1/p' "$FAKE_SDK_LOG")" + writer_pid="$(sed -n 's/^third-writer pid=<\([0-9][0-9]*\)> candidate=<.*> target=<.*>$/\1/p' "$FAKE_THIRD_WRITER_LOG")" + if [[ -z "$sdk_pid" || -z "$writer_pid" ]]; then + fail 'third writer PID evidence is missing or malformed' + fi + if [[ "$sdk_pid" == "$writer_pid" ]]; then + fail 'third writer did not run in a distinct subprocess' + fi +} + +wait_for_marker() { + local marker="$1" + local owner_pid="$2" + local attempt=0 + + while [[ ! -s "$marker" ]]; do + if ! kill -0 "$owner_pid" 2>/dev/null; then + fail "marker owner stopped before signalling readiness: $owner_pid" + fi + attempt=$((attempt + 1)) + if [[ "$attempt" -gt 100 ]]; then + fail "timed out waiting for readiness marker: $marker" + fi + sleep 0.01 + done +} + +write_complete_lock_fixture() { + local owner_pid="$1" + local lock_label="$2" + local owner_euid="$EUID" + local lock_dir="$SDKMAN_CANDIDATES_DIR/.sdkman-switch-jdk.lock" + + mkdir "$lock_dir" + printf '%s\n' "$owner_pid" > "$lock_dir/pid" + printf '%s\n' "$owner_euid" > "$lock_dir/euid" + printf '%s\n' "${owner_pid}:${owner_euid}:fixture-nonce-a:fixture-nonce-b" > "$lock_dir/token" + printf '%s\n' "$lock_label" > "$lock_dir/label" + live_lock_fixture_dir="$lock_dir" +} + +snapshot_lock_fixture() { + local lock_dir="$1" + local field + + for field in pid euid token label; do + if [[ ! -f "$lock_dir/$field" ]]; then + fail "lock metadata is not a regular file: $lock_dir/$field" + fi + cp "$lock_dir/$field" "$case_dir/lock-before-$field" + done +} + +assert_lock_fixture_unchanged() { + local lock_dir="$1" + local field + + for field in pid euid token label; do + if [[ ! -f "$lock_dir/$field" ]]; then + fail "live lock metadata is missing or not regular: $lock_dir/$field" + fi + if ! cmp "$case_dir/lock-before-$field" "$lock_dir/$field" >/dev/null 2>&1; then + fail "live lock metadata changed unexpectedly: $lock_dir/$field" + fi + done +} + +start_live_lock_owner() { + local marker="$case_dir/live-lock-owner.ready" + + "$BASH" -c ' + marker="$1" + printf "live-lock-owner-ready pid=<%s>\\n" "$$" > "$marker" + trap "exit 0" HUP INT TERM + while :; do + read -r -t 1 ignored || : + done + ' bash "$marker" & + live_lock_owner_pid=$! + register_background_pid "$live_lock_owner_pid" + wait_for_marker "$marker" "$live_lock_owner_pid" + if [[ "$live_lock_owner_pid" == "$$" ]] || ! kill -0 "$live_lock_owner_pid" 2>/dev/null; then + fail 'live lock owner is not a separate live process' + fi +} + +release_live_lock_fixture() { + local lock_dir="$live_lock_fixture_dir" + + if [[ -n "$live_lock_owner_pid" ]] && kill -0 "$live_lock_owner_pid" 2>/dev/null; then + kill "$live_lock_owner_pid" + fi + if [[ -n "$live_lock_owner_pid" ]]; then + wait "$live_lock_owner_pid" 2>/dev/null || true + if kill -0 "$live_lock_owner_pid" 2>/dev/null; then + fail "live lock owner remained after cleanup: $live_lock_owner_pid" + fi + fi + background_pids='' + live_lock_owner_pid='' + remove_lock_fixture_dir "$lock_dir" + live_lock_fixture_dir='' + assert_path_absent "$lock_dir" 'live lock fixture is removed after the scenario' +} + +assert_pre_cas_writer_ran() { + local expected_target="$1" + local wrapper_pid + local waited_pid + local writer_pid + local writer_ppid + local wrapper_destination + local writer_destination + + assert_file_contains "$FAKE_PRE_CAS_WRITER_LOG" 'pre-cas writer pid=<' \ + 'pre-CAS writer records its subprocess PID' + assert_file_contains "$FAKE_PRE_CAS_WRITER_LOG" "target=<$expected_target>" \ + 'pre-CAS writer records the competing target' + assert_file_contains "$FAKE_PRE_CAS_WRITER_LOG" 'ln wrapper pid=<' \ + 'ln wrapper waits for the pre-CAS writer before returning' + writer_pid="$(sed -n 's/^pre-cas writer pid=<\([0-9][0-9]*\)> ppid=<.*$/\1/p' "$FAKE_PRE_CAS_WRITER_LOG")" + writer_ppid="$(sed -n 's/^pre-cas writer pid=<[0-9][0-9]*> ppid=<\([0-9][0-9]*\)> .*$/\1/p' "$FAKE_PRE_CAS_WRITER_LOG")" + writer_destination="$(sed -n 's/^pre-cas writer .* destination=<\(.*\)>$/\1/p' "$FAKE_PRE_CAS_WRITER_LOG")" + wrapper_pid="$(sed -n 's/^ln wrapper pid=<\([0-9][0-9]*\)> .*$/\1/p' "$FAKE_PRE_CAS_WRITER_LOG")" + waited_pid="$(sed -n 's/^ln wrapper .* waited pre-cas writer pid=<\([0-9][0-9]*\)> .*$/\1/p' "$FAKE_PRE_CAS_WRITER_LOG")" + wrapper_destination="$(sed -n 's/^ln wrapper .* destination=<\(.*\)>$/\1/p' "$FAKE_PRE_CAS_WRITER_LOG")" + if [[ -z "$writer_pid" || -z "$writer_ppid" || -z "$wrapper_pid" || \ + -z "$waited_pid" || -z "$writer_destination" || -z "$wrapper_destination" ]]; then + fail 'pre-CAS writer PID or destination evidence is missing or malformed' + fi + if [[ "$writer_pid" != "$waited_pid" || "$writer_ppid" != "$wrapper_pid" || \ + "$writer_pid" == "$wrapper_pid" || "$wrapper_pid" == "$$" ]]; then + fail 'pre-CAS writer did not run as the distinct child waited by the ln wrapper' + fi + if [[ "$writer_destination" != "$wrapper_destination" ]]; then + fail 'pre-CAS writer and ln wrapper did not record the same restore destination' + fi + case "$wrapper_destination" in + "$FAKE_RESTORE_TEMP_PREFIX"*/current) ;; + *) fail "pre-CAS writer destination is outside the restore temp directory: $wrapper_destination" ;; + esac +} + +assert_sdk_self_signal_ran() { + local sdk_pid + local signal_pid + + assert_file_contains "$FAKE_SDK_SIGNAL_LOG" 'signal=' \ + 'fake SDK records the TERM injection' + sdk_pid="$(sed -n 's/^sdk pid=<\([0-9][0-9]*\)>$/\1/p' "$FAKE_SDK_LOG" | sed -n '1p')" + signal_pid="$(sed -n 's/^sdk-signal pid=<\([0-9][0-9]*\)> signal=$/\1/p' "$FAKE_SDK_SIGNAL_LOG")" + if [[ -z "$sdk_pid" || -z "$signal_pid" || "$sdk_pid" != "$signal_pid" ]]; then + fail 'TERM was not sent by the SDK function running in the runner process' + fi +} + +assert_lock_initialization_signal_ran() { + local signal_pid + local runner_pid + + assert_file_contains "$FAKE_MKDIR_SIGNAL_LOG" 'lock-init-signal pid=<' \ + 'fake mkdir records the lock-initialization TERM injection' + signal_pid="$(sed -n 's/^lock-init-signal pid=<\([0-9][0-9]*\)> parent=<.*$/\1/p' "$FAKE_MKDIR_SIGNAL_LOG")" + runner_pid="$(sed -n 's/^lock-init-signal pid=<[0-9][0-9]*> parent=<\([0-9][0-9]*\)> .*$/\1/p' "$FAKE_MKDIR_SIGNAL_LOG")" + if [[ -z "$signal_pid" || -z "$runner_pid" || "$signal_pid" == "$runner_pid" ]]; then + fail 'lock-initialization TERM injection did not identify distinct wrapper and runner PIDs' + fi +} + +assert_no_env_snapshot_dirs() { + local leftover + + for leftover in "$case_tmp_dir"/sdkman-switch-jdk-env.*; do + if [[ -e "$leftover" ]]; then + fail "SDKMAN environment snapshot directory remains: $leftover" + fi + done +} + +assert_gnu_fallback_writer_ran() { + local expected_target="$1" + local wrapper_pid + local writer_pid + + assert_file_contains "$FAKE_MV_GNU_FALLBACK_WRITER_MARKER" \ + 'gnu-fallback-writer-ready pid=<' \ + 'GNU fallback writer records its readiness marker and PID' + assert_file_contains "$FAKE_MV_GNU_FALLBACK_WRITER_LOG" \ + "target=<$expected_target>" \ + 'GNU fallback writer records the competing target' + assert_file_contains "$FAKE_MV_LOG" 'mv gnu-fallback parent-pid=<' \ + 'fake mv waits for the GNU fallback writer' + wrapper_pid="$(sed -n 's/^mv gnu-fallback parent-pid=<\([0-9][0-9]*\)> waited-writer-pid=<.*> target=<.*>$/\1/p' "$FAKE_MV_LOG")" + writer_pid="$(sed -n 's/^gnu-fallback-writer-complete pid=<\([0-9][0-9]*\)> candidate=<.*> target=<.*>$/\1/p' "$FAKE_MV_GNU_FALLBACK_WRITER_LOG")" + if [[ -z "$wrapper_pid" || -z "$writer_pid" ]]; then + fail 'GNU fallback writer PID evidence is missing or malformed' + fi + if [[ "$wrapper_pid" == "$writer_pid" ]]; then + fail 'GNU fallback writer did not run in a distinct subprocess' + fi +} + +scenario_install_success_absolute_unchanged() { + begin_case install_success_absolute_unchanged + local old_identifier='17.0.9-tem' + local target_identifier='21.0.9-tem' + create_java_candidate "$case_java_dir/$old_identifier" + set_default absolute "$old_identifier" + export FAKE_SDK_INSTALL_MUTATE=none + export FAKE_MV_MODE=bsd + + run_capture bash "$install_script" "$target_identifier" + assert_status 0 'install success returns zero' + assert_default_state "link:$case_java_dir/$old_identifier" 'absolute default is restored' + assert_file_executable "$case_java_dir/$target_identifier/bin/java" 'successful install creates executable java' + assert_file_contains "$FAKE_INIT_LOG" 'SDKMAN_ENV=<>' 'install clears SDKMAN_ENV before init' + assert_file_contains "$FAKE_INIT_LOG" "SDKMAN_OLD_PWD=<$case_pwd>" 'install pins SDKMAN_OLD_PWD to the invoking directory' + assert_file_contains "$FAKE_SDK_LOG" 'sdk USE=' 'install pre-seeds the default answer with USE=n' + assert_file_contains "$FAKE_SDK_LOG" 'sdk arg[0]=' 'install calls sdk install' + assert_file_contains "$FAKE_SDK_LOG" "sdk arg[2]=<$target_identifier>" 'install passes exact identifier' + assert_file_contains "$FAKE_SDK_LOG" 'sdk stdin=' 'install answers the default prompt with n' + assert_file_empty "$FAKE_MV_LOG" 'unchanged absolute default does not call mv' + assert_file_contains "$case_dir/stdout" "Java $target_identifier is installed" 'install reports success' + assert_no_restore_temp_dirs + scenario_count=$((scenario_count + 1)) + printf 'PASS %s\n' "$case_name" +} + +scenario_install_success_relative_unchanged() { + begin_case install_success_relative_unchanged + local old_identifier='17.0.9-tem' + local target_identifier='21.0.9-tem' + create_java_candidate "$case_java_dir/$old_identifier" + set_default relative "$old_identifier" "../java/$old_identifier" + export FAKE_SDK_INSTALL_MUTATE=none + export FAKE_MV_MODE=gnu + + run_capture bash "$install_script" "$target_identifier" + assert_status 0 'install success with relative default returns zero' + assert_default_state "link:../java/$old_identifier" 'relative default is restored exactly' + assert_file_executable "$case_java_dir/$target_identifier/bin/java" 'relative-default install creates executable java' + assert_file_empty "$FAKE_MV_LOG" 'unchanged relative default does not call mv' + assert_no_restore_temp_dirs + scenario_count=$((scenario_count + 1)) + printf 'PASS %s\n' "$case_name" +} + +scenario_install_success_absent() { + begin_case install_success_absent + local target_identifier='21.0.9-tem' + set_default absent "$target_identifier" + export FAKE_SDK_INSTALL_MUTATE=none + + run_capture bash "$install_script" "$target_identifier" + assert_status 0 'install success with absent default returns zero' + assert_default_state absent 'absent default remains absent' + assert_file_executable "$case_java_dir/$target_identifier/bin/java" 'absent-default install creates executable java' + assert_file_empty "$FAKE_MV_LOG" 'absent-default restoration does not call mv' + assert_file_contains "$case_dir/stdout" 'default is unchanged' 'absent-default install reports unchanged default' + scenario_count=$((scenario_count + 1)) + printf 'PASS %s\n' "$case_name" +} + +scenario_install_failure_preserves_status() { + begin_case install_failure_preserves_status + local old_identifier='17.0.9-tem' + local target_identifier='21.0.9-tem' + create_java_candidate "$case_java_dir/$old_identifier" + set_default relative "$old_identifier" "../java/$old_identifier" + export FAKE_SDK_INSTALL_STATUS=17 + export FAKE_SDK_INSTALL_MUTATE=target_rel + + run_capture bash "$install_script" "$target_identifier" + assert_status 17 'install failure preserves sdk status' + assert_default_state "link:../java/$old_identifier" 'install failure restores original relative default' + assert_file_contains "$case_dir/stderr" 'status 17' 'install failure reports sdk status' + assert_file_contains "$FAKE_MV_LOG" 'mv arg[0]=<-fh>' 'install failure restores through mv' + assert_no_restore_temp_dirs + scenario_count=$((scenario_count + 1)) + printf 'PASS %s\n' "$case_name" +} + +scenario_install_failure_option_like_default() { + begin_case install_failure_option_like_default + local target_identifier='21.0.9-tem' + local raw_target='-f' + set_default relative unused "$raw_target" + export FAKE_SDK_INSTALL_STATUS=17 + export FAKE_SDK_INSTALL_MUTATE=target_rel + + run_capture bash "$install_script" "$target_identifier" + assert_status 17 'install failure preserves status with option-like default target' + assert_link_target_raw "$raw_target" "$case_current" 'option-like default target is restored byte-for-byte' + assert_path_absent "$case_dir/current" 'rollback does not create a CWD current symlink' + assert_no_restore_temp_dirs + scenario_count=$((scenario_count + 1)) + printf 'PASS %s\n' "$case_name" +} + +scenario_install_failure_trailing_newline_default() { + begin_case install_failure_trailing_newline_default + local target_identifier='21.0.9-tem' + local raw_target=$'../java/legacy-java\n' + set_default relative unused "$raw_target" + export FAKE_SDK_INSTALL_STATUS=17 + export FAKE_SDK_INSTALL_MUTATE=target_rel + + run_capture bash "$install_script" "$target_identifier" + assert_status 17 'install failure preserves status with newline-terminated default target' + assert_link_target_raw "$raw_target" "$case_current" 'trailing-newline default target is restored byte-for-byte' + assert_no_restore_temp_dirs + scenario_count=$((scenario_count + 1)) + printf 'PASS %s\n' "$case_name" +} + +scenario_install_unexpected_default_from_absent() { + begin_case install_unexpected_default_from_absent + local target_identifier='21.0.9-tem' + set_default absent unused + export FAKE_SDK_INSTALL_MUTATE=target_rel + + run_capture bash "$install_script" "$target_identifier" + assert_status 1 'unexpected default created from absent is rejected after restore' + assert_default_state absent 'unexpected default created from absent is removed' + assert_file_empty "$FAKE_MV_LOG" 'absent restoration uses unlink instead of mv' + assert_file_contains "$case_dir/stderr" 'previous state was restored' 'absent restoration is reported' + assert_no_restore_temp_dirs + scenario_count=$((scenario_count + 1)) + printf 'PASS %s\n' "$case_name" +} + +scenario_install_unexpected_default_gnu_fallback() { + begin_case install_unexpected_default_gnu_fallback + local old_identifier='17.0.9-tem' + local target_identifier='21.0.9-tem' + create_java_candidate "$case_java_dir/$old_identifier" + set_default relative "$old_identifier" "../java/$old_identifier" + export FAKE_SDK_INSTALL_MUTATE=target_rel + export FAKE_MV_MODE=gnu + + run_capture bash "$install_script" "$target_identifier" + assert_status 1 'unexpected install default change is rejected after restore' + assert_default_state "link:../java/$old_identifier" 'GNU fallback restores the original relative default' + assert_file_contains "$FAKE_MV_LOG" 'mv arg[0]=<-fh>' 'GNU restore first probes BSD mv' + assert_file_contains "$FAKE_MV_LOG" 'mv arg[0]=<-Tf>' 'GNU restore uses -Tf fallback' + assert_file_contains "$case_dir/stderr" 'previous state was restored' 'unexpected install default change is reported' + assert_no_restore_temp_dirs + scenario_count=$((scenario_count + 1)) + printf 'PASS %s\n' "$case_name" +} + +scenario_install_restore_failure() { + begin_case install_restore_failure + local old_identifier='17.0.9-tem' + local target_identifier='21.0.9-tem' + create_java_candidate "$case_java_dir/$old_identifier" + set_default absolute "$old_identifier" + export FAKE_SDK_INSTALL_MUTATE=target_rel + export FAKE_MV_MODE=fail + + run_capture bash "$install_script" "$target_identifier" + assert_status 1 'install restoration failure returns one' + assert_default_state "link:$target_identifier" 'failed install restoration leaves observed changed default' + assert_file_contains "$FAKE_MV_LOG" 'mv arg[0]=<-fh>' 'restore failure records BSD mv attempt' + assert_file_contains "$FAKE_MV_LOG" 'mv arg[0]=<-Tf>' 'restore failure records GNU fallback attempt' + assert_file_contains "$case_dir/stderr" 'automatic restoration failed' 'restore failure is reported' + assert_no_restore_temp_dirs + scenario_count=$((scenario_count + 1)) + printf 'PASS %s\n' "$case_name" +} + +scenario_install_incomplete_candidate() { + begin_case install_incomplete_candidate + local old_identifier='17.0.9-tem' + local target_identifier='21.0.9-tem' + create_java_candidate "$case_java_dir/$old_identifier" + create_incomplete_candidate "$case_java_dir/$target_identifier" + set_default absolute "$old_identifier" + + run_capture bash "$install_script" "$target_identifier" + assert_status 1 'incomplete install candidate is rejected' + assert_default_state "link:$case_java_dir/$old_identifier" 'incomplete candidate does not alter default' + assert_file_empty "$FAKE_SDK_LOG" 'incomplete candidate skips sdk install' + assert_file_contains "$case_dir/stderr" 'incomplete SDKMAN Java candidate' 'incomplete candidate is reported' + scenario_count=$((scenario_count + 1)) + printf 'PASS %s\n' "$case_name" +} + +scenario_install_reserved_current() { + begin_case install_reserved_current + + run_capture bash "$install_script" current + assert_status 2 'reserved current identifier is rejected' + assert_file_empty "$FAKE_INIT_LOG" 'reserved install identifier is rejected before SDKMAN init' + assert_file_contains "$case_dir/stderr" 'reserved name' 'reserved install identifier is reported' + scenario_count=$((scenario_count + 1)) + printf 'PASS %s\n' "$case_name" +} + +scenario_install_concurrent_third_writer() { + begin_case install_concurrent_third_writer + local old_identifier='17.0.9-tem' + local target_identifier='21.0.9-tem' + local third_identifier='22.0.1-tem' + local third_target="$case_java_dir/$third_identifier" + create_java_candidate "$case_java_dir/$old_identifier" + create_java_candidate "$third_target" + set_default absolute "$old_identifier" + export FAKE_SDK_INSTALL_MUTATE=target_rel + export FAKE_SDK_THIRD_WRITER_TARGET="$third_target" + + run_capture bash "$install_script" "$target_identifier" + assert_status 1 'install refuses a concurrent third-writer default change' + assert_third_writer_ran "$third_target" + assert_default_state "link:$third_target" \ + 'install preserves the third writer default instead of restoring the stale original default' + assert_file_contains "$case_dir/stderr" 'drifted after the SDKMAN operation' \ + 'install reports the concurrent drift refusal' + scenario_count=$((scenario_count + 1)) + printf 'PASS %s\n' "$case_name" +} + +scenario_install_term_reconciles_default() { + begin_case install_term_reconciles_default + local old_identifier='17.0.9-tem' + local target_identifier='21.0.9-tem' + + create_java_candidate "$case_java_dir/$old_identifier" + set_default absolute "$old_identifier" + export FAKE_SDK_INSTALL_MUTATE=target_rel + export FAKE_SDK_SIGNAL_AFTER_MUTATION=TERM + + run_capture bash "$install_script" "$target_identifier" + assert_status 143 'install preserves TERM status after safe cleanup' + assert_sdk_self_signal_ran + assert_default_state "link:$case_java_dir/$old_identifier" \ + 'install TERM cleanup restores the original Java default' + assert_path_absent "$SDKMAN_CANDIDATES_DIR/.sdkman-switch-jdk.lock" \ + 'install TERM cleanup releases the owned lock' + assert_no_restore_temp_dirs + scenario_count=$((scenario_count + 1)) + printf 'PASS %s\n' "$case_name" +} + +scenario_lock_live_owner_refusal() { + begin_case lock_live_owner_refusal + local old_identifier='17.0.9-tem' + local target_identifier='21.0.9-tem' + local lock_dir + + create_java_candidate "$case_java_dir/$old_identifier" + set_default absolute "$old_identifier" + start_live_lock_owner + write_complete_lock_fixture "$live_lock_owner_pid" 'live-owner-fixture' + lock_dir="$live_lock_fixture_dir" + snapshot_lock_fixture "$lock_dir" + + run_capture bash "$install_script" "$target_identifier" + assert_default_state "link:$case_java_dir/$old_identifier" 'live lock refusal leaves the default unchanged' + assert_lock_fixture_unchanged "$lock_dir" + release_live_lock_fixture + assert_file_empty "$FAKE_SDK_LOG" 'live lock blocks SDKMAN invocation' + assert_status 1 'live lock owner refusal returns one' + assert_no_restore_temp_dirs + scenario_count=$((scenario_count + 1)) + printf 'PASS %s\n' "$case_name" +} + +scenario_lock_stale_owner_recovery() { + begin_case lock_stale_owner_recovery + local target_identifier='21.0.9-tem' + local stale_owner_pid + local lock_dir + + "$BASH" -c 'exit 0' & + stale_owner_pid=$! + wait "$stale_owner_pid" + if kill -0 "$stale_owner_pid" 2>/dev/null; then + fail "stale lock owner PID is unexpectedly live: $stale_owner_pid" + fi + write_complete_lock_fixture "$stale_owner_pid" 'stale-owner-fixture' + lock_dir="$live_lock_fixture_dir" + + run_capture bash "$install_script" "$target_identifier" + assert_status 0 'stale lock is reaped before a successful install' + assert_file_contains "$FAKE_SDK_LOG" 'sdk arg[0]=' 'stale lock recovery proceeds with SDKMAN' + assert_path_absent "$lock_dir" 'stale lock is absent after the operation' + live_lock_fixture_dir='' + assert_no_restore_temp_dirs + scenario_count=$((scenario_count + 1)) + printf 'PASS %s\n' "$case_name" +} + +scenario_lock_initialization_term_cleans_partial_lock() { + begin_case lock_initialization_term_cleans_partial_lock + local existing_identifier='21.0.9-tem' + + create_java_candidate "$case_java_dir/$existing_identifier" + set_default absolute "$existing_identifier" + export FAKE_MKDIR_SIGNAL_LOCK_INIT=TERM + + run_capture bash "$install_script" "$existing_identifier" + assert_status 143 'TERM during lock initialization preserves the signal status' + assert_lock_initialization_signal_ran + assert_default_state "link:$case_java_dir/$existing_identifier" \ + 'TERM before SDKMAN invocation leaves the Java default unchanged' + assert_path_absent "$SDKMAN_CANDIDATES_DIR/.sdkman-switch-jdk.lock" \ + 'TERM during lock initialization removes the partial owned lock' + assert_file_empty "$FAKE_SDK_LOG" \ + 'TERM during lock initialization exits before SDKMAN invocation' + assert_no_restore_temp_dirs + scenario_count=$((scenario_count + 1)) + printf 'PASS %s\n' "$case_name" +} + +scenario_restore_pre_cas_third_writer() { + begin_case restore_pre_cas_third_writer + local old_identifier='17.0.9-tem' + local target_identifier='21.0.9-tem' + local third_identifier='22.0.1-tem' + local third_target="$case_java_dir/$third_identifier" + + create_java_candidate "$case_java_dir/$old_identifier" + create_java_candidate "$third_target" + set_default absolute "$old_identifier" + export FAKE_SDK_INSTALL_MUTATE=target_rel + export FAKE_LN_PRE_CAS_WRITER_TARGET="$third_target" + + run_capture bash "$install_script" "$target_identifier" + assert_status 1 'rollback pre-CAS third-writer refusal returns one' + assert_pre_cas_writer_ran "$third_target" + assert_default_state "link:$third_target" 'rollback preserves the pre-CAS third-writer target instead of overwriting it with A' + assert_file_contains "$case_dir/stderr" 'rollback drift' 'rollback reports its pre-CAS drift refusal' + assert_file_empty "$FAKE_MV_LOG" 'rollback drift refusal does not call mv' + assert_no_restore_temp_dirs + scenario_count=$((scenario_count + 1)) + printf 'PASS %s\n' "$case_name" +} + +scenario_restore_gnu_fallback_third_writer() { + begin_case restore_gnu_fallback_third_writer + local old_identifier='17.0.9-tem' + local target_identifier='21.0.9-tem' + local third_identifier='22.0.1-tem' + local third_target="$case_java_dir/$third_identifier" + + create_java_candidate "$case_java_dir/$old_identifier" + create_java_candidate "$third_target" + set_default absolute "$old_identifier" + export FAKE_SDK_INSTALL_MUTATE=target_rel + export FAKE_MV_MODE=gnu + export FAKE_MV_GNU_FALLBACK_WRITER_TARGET="$third_target" + + run_capture bash "$install_script" "$target_identifier" + assert_status 1 'GNU fallback third-writer refusal returns one' + assert_gnu_fallback_writer_ran "$third_target" + assert_default_state "link:$third_target" \ + 'GNU fallback second read preserves the competing default instead of overwriting it with A' + assert_file_contains "$case_dir/stderr" 'rollback drift' \ + 'GNU fallback race reports rollback drift' + assert_file_not_contains "$FAKE_MV_LOG" 'mv arg[0]=<-Tf>' \ + 'GNU fallback race refusal never attempts the GNU mv replacement' + assert_no_restore_temp_dirs + scenario_count=$((scenario_count + 1)) + printf 'PASS %s\n' "$case_name" +} + +scenario_run_success_absolute_path_shadow_payload() { + begin_case run_success_absolute_path_shadow_payload + local old_identifier='17.0.9-tem' + local target_identifier='21.0.9-tem' + local payload_path="$case_dir/payload" + create_java_candidate "$case_java_dir/$old_identifier" + create_java_candidate "$case_java_dir/$target_identifier" + set_default absolute "$old_identifier" + write_shadow_java + export PATH="$fake_bin:$shadow_bin:$original_path" + export FAKE_SDK_USE_MUTATE=none + export FAKE_PAYLOAD_STATUS=23 + + run_capture bash "$run_script" "$target_identifier" -- "$payload_path" \ + 'arg with spaces' '' 'glob*value?' '--' '--flag=value' + assert_status 23 'payload exit status is preserved' + assert_default_state "link:$case_java_dir/$old_identifier" 'absolute run default is restored' + assert_file_empty "$FAKE_MV_LOG" 'unchanged absolute run does not call mv' + assert_file_not_contains "$FAKE_SHADOW_LOG" 'shadow-java-called' 'candidate java wins over PATH shadow' + assert_file_contains "$FAKE_JAVA_LOG" 'java arg[0]=<-version>' 'candidate java performs version probe' + assert_file_contains "$FAKE_SDK_LOG" 'sdk arg[0]=' 'existing default invokes sdk use' + assert_file_contains "$case_dir/stderr" "java: $case_java_dir/$target_identifier/bin/java" 'run reports exact candidate java path' + { + printf 'payload JAVA_HOME=<%s>\n' "$case_java_dir/$target_identifier" + printf 'payload PATH=<%s>\n' "$case_java_dir/$target_identifier/bin:$fake_bin:$shadow_bin:$original_path" + printf 'payload java=<%s>\n' "$case_java_dir/$target_identifier/bin/java" + printf 'payload argc=5\n' + printf 'payload arg[0]=\n' + printf 'payload arg[1]=<>\n' + printf 'payload arg[2]=\n' + printf 'payload arg[3]=<-->\n' + printf 'payload arg[4]=<--flag=value>\n' + } > "$case_dir/expected-payload.log" + if ! cmp "$case_dir/expected-payload.log" "$FAKE_PAYLOAD_LOG" >/dev/null 2>&1; then + fail 'payload argv or environment was not preserved exactly' + fi + scenario_count=$((scenario_count + 1)) + printf 'PASS %s\n' "$case_name" +} + +scenario_run_success_relative_unchanged() { + begin_case run_success_relative_unchanged + local old_identifier='17.0.9-tem' + local target_identifier='21.0.9-tem' + create_java_candidate "$case_java_dir/$old_identifier" + create_java_candidate "$case_java_dir/$target_identifier" + set_default relative "$old_identifier" "../java/$old_identifier" + export FAKE_SDK_USE_MUTATE=none + export FAKE_MV_MODE=gnu + + run_capture bash "$run_script" "$target_identifier" -- "$case_dir/payload" simple + assert_status 0 'relative run succeeds' + assert_default_state "link:../java/$old_identifier" 'relative run default is restored exactly' + assert_file_empty "$FAKE_MV_LOG" 'unchanged relative run does not call mv' + assert_file_contains "$FAKE_PAYLOAD_LOG" 'payload arg[0]=' 'relative run invokes payload' + assert_no_restore_temp_dirs + scenario_count=$((scenario_count + 1)) + printf 'PASS %s\n' "$case_name" +} + +scenario_run_unexpected_default_gnu_fallback() { + begin_case run_unexpected_default_gnu_fallback + local old_identifier='17.0.9-tem' + local target_identifier='21.0.9-tem' + create_java_candidate "$case_java_dir/$old_identifier" + create_java_candidate "$case_java_dir/$target_identifier" + set_default relative "$old_identifier" "../java/$old_identifier" + export FAKE_SDK_USE_MUTATE=target_rel + export FAKE_MV_MODE=gnu + + run_capture bash "$run_script" "$target_identifier" -- "$case_dir/payload" should-not-run + assert_status 1 'unexpected run default change is rejected after restore' + assert_default_state "link:../java/$old_identifier" 'GNU run fallback restores the original relative default' + assert_file_contains "$FAKE_MV_LOG" 'mv arg[0]=<-fh>' 'GNU run restore first probes BSD mv' + assert_file_contains "$FAKE_MV_LOG" 'mv arg[0]=<-Tf>' 'GNU run restore uses -Tf fallback' + assert_file_empty "$FAKE_PAYLOAD_LOG" 'payload does not run after unexpected default change' + assert_file_contains "$case_dir/stderr" 'it was restored' 'unexpected run default change is reported' + assert_no_restore_temp_dirs + scenario_count=$((scenario_count + 1)) + printf 'PASS %s\n' "$case_name" +} + +scenario_run_success_absent() { + begin_case run_success_absent + local target_identifier='21.0.9-tem' + create_java_candidate "$case_java_dir/$target_identifier" + set_default absent "$target_identifier" + + run_capture bash "$run_script" "$target_identifier" -- "$case_dir/payload" absent + assert_status 0 'run succeeds with absent default' + assert_default_state absent 'absent run default remains absent' + assert_file_empty "$FAKE_SDK_LOG" 'absent run skips sdk use' + assert_file_empty "$FAKE_MV_LOG" 'absent run does not restore through mv' + assert_file_contains "$FAKE_PAYLOAD_LOG" 'payload arg[0]=' 'absent run invokes payload' + scenario_count=$((scenario_count + 1)) + printf 'PASS %s\n' "$case_name" +} + +scenario_run_use_failure_preserves_status() { + begin_case run_use_failure_preserves_status + local old_identifier='17.0.9-tem' + local target_identifier='21.0.9-tem' + create_java_candidate "$case_java_dir/$old_identifier" + create_java_candidate "$case_java_dir/$target_identifier" + set_default absolute "$old_identifier" + export FAKE_SDK_USE_STATUS=19 + export FAKE_SDK_USE_MUTATE=target_rel + + run_capture bash "$run_script" "$target_identifier" -- "$case_dir/payload" should-not-run + assert_status 19 'run failure preserves sdk use status' + assert_default_state "link:$case_java_dir/$old_identifier" 'run failure restores original default' + assert_file_empty "$FAKE_PAYLOAD_LOG" 'payload does not run after sdk use failure' + assert_file_contains "$case_dir/stderr" 'status 19' 'run failure reports sdk use status' + assert_file_contains "$FAKE_MV_LOG" 'mv arg[0]=<-fh>' 'run failure restores through BSD mv' + assert_no_restore_temp_dirs + scenario_count=$((scenario_count + 1)) + printf 'PASS %s\n' "$case_name" +} + +scenario_run_use_failure_byte_exact_default() { + begin_case run_use_failure_byte_exact_default + local target_identifier='21.0.9-tem' + local raw_target=$'-f\n' + create_java_candidate "$case_java_dir/$target_identifier" + set_default relative unused "$raw_target" + export FAKE_SDK_USE_STATUS=19 + export FAKE_SDK_USE_MUTATE=target_rel + + run_capture bash "$run_script" "$target_identifier" -- "$case_dir/payload" should-not-run + assert_status 19 'run failure preserves status with option-like newline default target' + assert_link_target_raw "$raw_target" "$case_current" 'run restores option-like newline target byte-for-byte' + assert_path_absent "$case_dir/current" 'run rollback does not create a CWD current symlink' + assert_file_empty "$FAKE_PAYLOAD_LOG" 'payload does not run after byte-exact sdk use failure' + assert_file_contains "$FAKE_MV_LOG" 'mv arg[0]=<-fh>' 'byte-exact run restore uses BSD mv path' + assert_no_restore_temp_dirs + scenario_count=$((scenario_count + 1)) + printf 'PASS %s\n' "$case_name" +} + +scenario_run_restore_failure() { + begin_case run_restore_failure + local old_identifier='17.0.9-tem' + local target_identifier='21.0.9-tem' + create_java_candidate "$case_java_dir/$old_identifier" + create_java_candidate "$case_java_dir/$target_identifier" + set_default absolute "$old_identifier" + export FAKE_SDK_USE_MUTATE=target_rel + export FAKE_MV_MODE=fail + + run_capture bash "$run_script" "$target_identifier" -- "$case_dir/payload" should-not-run + assert_status 1 'run restoration failure returns one' + assert_default_state "link:$target_identifier" 'failed run restoration leaves observed changed default' + assert_file_empty "$FAKE_PAYLOAD_LOG" 'payload does not run after restore failure' + assert_file_contains "$case_dir/stderr" 'automatic restoration failed' 'run restore failure is reported' + assert_no_restore_temp_dirs + scenario_count=$((scenario_count + 1)) + printf 'PASS %s\n' "$case_name" +} + +scenario_run_incomplete_candidate() { + begin_case run_incomplete_candidate + local old_identifier='17.0.9-tem' + local target_identifier='21.0.9-tem' + create_java_candidate "$case_java_dir/$old_identifier" + create_incomplete_candidate "$case_java_dir/$target_identifier" + set_default relative "$old_identifier" "../java/$old_identifier" + + run_capture bash "$run_script" "$target_identifier" -- "$case_dir/payload" should-not-run + assert_status 1 'incomplete run candidate is rejected' + assert_default_state "link:../java/$old_identifier" 'incomplete run candidate does not alter default' + assert_file_empty "$FAKE_SDK_LOG" 'incomplete run candidate skips sdk use' + assert_file_empty "$FAKE_PAYLOAD_LOG" 'payload does not run for incomplete candidate' + assert_file_contains "$case_dir/stderr" 'not installed or is incomplete' 'incomplete run candidate is reported' + scenario_count=$((scenario_count + 1)) + printf 'PASS %s\n' "$case_name" +} + +scenario_run_reserved_current() { + begin_case run_reserved_current + + run_capture bash "$run_script" current -- "$case_dir/payload" + assert_status 2 'reserved current run identifier is rejected' + assert_file_empty "$FAKE_INIT_LOG" 'reserved run identifier is rejected before SDKMAN init' + assert_file_empty "$FAKE_PAYLOAD_LOG" 'payload does not run for reserved identifier' + assert_file_contains "$case_dir/stderr" 'reserved name' 'reserved run identifier is reported' + scenario_count=$((scenario_count + 1)) + printf 'PASS %s\n' "$case_name" +} + +scenario_run_concurrent_third_writer() { + begin_case run_concurrent_third_writer + local old_identifier='17.0.9-tem' + local target_identifier='21.0.9-tem' + local third_identifier='22.0.1-tem' + local third_target="$case_java_dir/$third_identifier" + create_java_candidate "$case_java_dir/$old_identifier" + create_java_candidate "$case_java_dir/$target_identifier" + create_java_candidate "$third_target" + set_default absolute "$old_identifier" + export FAKE_SDK_USE_MUTATE=target_rel + export FAKE_SDK_THIRD_WRITER_TARGET="$third_target" + + run_capture bash "$run_script" "$target_identifier" -- "$case_dir/payload" should-not-run + assert_status 1 'run refuses a concurrent third-writer default change' + assert_third_writer_ran "$third_target" + assert_file_empty "$FAKE_PAYLOAD_LOG" 'payload does not run after a concurrent default change' + assert_default_state "link:$third_target" \ + 'run preserves the third writer default instead of restoring the stale original default' + assert_file_contains "$case_dir/stderr" 'drifted after the SDKMAN operation' \ + 'run reports the concurrent drift refusal' + scenario_count=$((scenario_count + 1)) + printf 'PASS %s\n' "$case_name" +} + +scenario_run_term_reconciles_default() { + begin_case run_term_reconciles_default + local old_identifier='17.0.9-tem' + local target_identifier='21.0.9-tem' + + create_java_candidate "$case_java_dir/$old_identifier" + create_java_candidate "$case_java_dir/$target_identifier" + set_default absolute "$old_identifier" + export FAKE_SDK_USE_MUTATE=target_rel + export FAKE_SDK_SIGNAL_AFTER_MUTATION=TERM + + run_capture bash "$run_script" "$target_identifier" -- "$case_dir/payload" should-not-run + assert_status 143 'run preserves TERM status after safe cleanup' + assert_sdk_self_signal_ran + assert_default_state "link:$case_java_dir/$old_identifier" \ + 'run TERM cleanup restores the original Java default' + assert_file_empty "$FAKE_PAYLOAD_LOG" 'run TERM cleanup blocks the payload' + assert_path_absent "$SDKMAN_CANDIDATES_DIR/.sdkman-switch-jdk.lock" \ + 'run TERM cleanup releases the owned lock' + assert_no_restore_temp_dirs + scenario_count=$((scenario_count + 1)) + printf 'PASS %s\n' "$case_name" +} + +scenario_full_env_atomic_sdkmanrc_replace_is_rejected_and_reconciled() { + begin_case full_env_atomic_sdkmanrc_replace_is_rejected_and_reconciled + local old_java_identifier='17.0.9-tem' + local java_identifier='21.0.9-tem' + local old_maven_identifier='3.8.6' + local maven_identifier='3.9.8' + + create_java_candidate "$case_java_dir/$java_identifier" + set_candidate_default java relative "$old_java_identifier" "../java/$old_java_identifier" + set_candidate_default maven relative "$old_maven_identifier" "../maven/$old_maven_identifier" + write_sdkmanrc "java=$java_identifier" + printf 'java=%s\nmaven=%s\n' "$java_identifier" "$maven_identifier" > \ + "$case_dir/.sdkmanrc.replacement" + export FAKE_SDK_ENV_REPLACEMENT="$case_dir/.sdkmanrc.replacement" + export FAKE_PROJECT_SDKMANRC="$case_dir/.sdkmanrc" + + run_capture bash "$full_env_script" -- "$case_dir/payload" should-not-run + assert_file_contains "$FAKE_SDK_ENV_REPLACE_LOG" 'sdkmanrc-replace pid=<' \ + 'fake SDK atomically replaces the project .sdkmanrc after runner parsing' + assert_path_absent "$case_dir/.sdkmanrc.replacement" \ + 'the replacement file was atomically renamed onto the project .sdkmanrc' + assert_file_contains "$case_dir/.sdkmanrc" "maven=$maven_identifier" \ + 'the concurrent project .sdkmanrc replacement remains untouched' + assert_file_contains "$FAKE_SDK_LOG" 'sdk arg[0]=' \ + 'runner explicitly installs the validated Java entry' + assert_file_not_contains "$FAKE_SDK_LOG" 'sdk arg[1]=' \ + 'the atomically replaced .sdkmanrc cannot add an SDKMAN operation' + assert_file_not_contains "$FAKE_SDK_LOG" 'sdk arg[0]=' \ + 'runner never delegates a second .sdkmanrc read to sdk env' + assert_status 1 'project .sdkmanrc drift blocks payload execution' + assert_candidate_default_state java "link:../java/$old_java_identifier" \ + 'Java is reconciled after project .sdkmanrc drift' + assert_candidate_default_state maven "link:../maven/$old_maven_identifier" \ + 'an unparsed replacement candidate cannot change the Maven default' + assert_file_empty "$FAKE_PAYLOAD_LOG" \ + 'project .sdkmanrc drift blocks the payload' + assert_file_contains "$case_dir/stderr" 'changed while the SDKMAN environment was being activated' \ + 'project .sdkmanrc drift is reported' + assert_path_absent "$SDKMAN_CANDIDATES_DIR/.sdkman-switch-jdk.lock" \ + 'project .sdkmanrc drift cleanup releases the owned lock' + assert_no_env_snapshot_dirs + scenario_count=$((scenario_count + 1)) + printf 'PASS %s\n' "$case_name" +} + +scenario_full_env_term_reconciles_operation_owned_defaults() { + begin_case full_env_term_reconciles_operation_owned_defaults + local old_java_identifier='17.0.9-tem' + local java_identifier='21.0.9-tem' + local old_maven_identifier='3.8.6' + local maven_identifier='3.9.8' + + create_java_candidate "$case_java_dir/$java_identifier" + set_candidate_default java relative "$old_java_identifier" "../java/$old_java_identifier" + set_candidate_default maven relative "$old_maven_identifier" "../maven/$old_maven_identifier" + write_sdkmanrc "java=$java_identifier" "maven=$maven_identifier" + export FAKE_SDK_SIGNAL_AFTER_MUTATION=TERM + export FAKE_SDK_SIGNAL_AFTER_CANDIDATE=maven + export FAKE_SDK_SIGNAL_AFTER_ACTION=use + + run_capture bash "$full_env_script" -- "$case_dir/payload" should-not-run + assert_status 143 'full environment preserves TERM status after safe reconciliation' + assert_sdk_self_signal_ran + assert_candidate_default_state java "link:../java/$old_java_identifier" \ + 'full-environment TERM cleanup restores the Java default' + assert_candidate_default_state maven "link:../maven/$old_maven_identifier" \ + 'full-environment TERM cleanup restores the Maven default' + assert_file_empty "$FAKE_PAYLOAD_LOG" \ + 'full-environment TERM cleanup blocks the payload' + assert_path_absent "$SDKMAN_CANDIDATES_DIR/.sdkman-switch-jdk.lock" \ + 'full-environment TERM cleanup releases the owned lock' + assert_no_restore_temp_dirs + assert_no_env_snapshot_dirs + scenario_count=$((scenario_count + 1)) + printf 'PASS %s\n' "$case_name" +} + +scenario_full_env_success_allows_authorized_default() { + begin_case full_env_success_allows_authorized_default + local old_java_identifier='17.0.9-tem' + local java_identifier='21.0.9-tem' + local maven_identifier='3.9.8' + local old_java_raw="../java/$old_java_identifier" + + create_java_candidate "$case_java_dir/$java_identifier" + set_candidate_default java relative "$old_java_identifier" "$old_java_raw" + set_candidate_default maven absent unused + write_sdkmanrc "java=$java_identifier" "maven=$maven_identifier" + set_full_env_operation_raw_target java "$java_identifier" + set_full_env_operation_raw_target maven "$maven_identifier" + export FAKE_PAYLOAD_STATUS=23 + + run_capture bash "$full_env_script" --allow-default maven -- "$case_dir/payload" \ + 'arg with spaces' '' 'glob*value?' '--flag=value' + assert_status 23 'full environment preserves the payload status' + assert_link_target_raw "$old_java_raw" "$case_current" \ + 'unapproved Java default is restored byte-for-byte' + assert_candidate_default_state maven "link:$maven_identifier" \ + 'authorized absent Maven default remains the requested raw identifier' + assert_file_contains "$FAKE_SDK_LOG" 'sdk arg[0]=' \ + 'full environment explicitly installs each validated entry' + assert_file_contains "$FAKE_SDK_LOG" 'sdk arg[0]=' \ + 'full environment explicitly activates each validated entry' + assert_file_contains "$FAKE_SDK_LOG" 'sdk arg[1]=' \ + 'full environment applies the validated Maven entry directly' + { + printf 'payload JAVA_HOME=<%s>\n' "$case_java_dir/$java_identifier" + printf 'payload PATH=<%s>\n' "$case_java_dir/$java_identifier/bin:$fake_bin:$original_path" + printf 'payload java=<%s>\n' "$case_java_dir/$java_identifier/bin/java" + printf 'payload argc=4\n' + printf 'payload arg[0]=\n' + printf 'payload arg[1]=<>\n' + printf 'payload arg[2]=\n' + printf 'payload arg[3]=<--flag=value>\n' + } > "$case_dir/expected-full-env-payload.log" + if ! cmp "$case_dir/expected-full-env-payload.log" "$FAKE_PAYLOAD_LOG" >/dev/null 2>&1; then + fail 'full environment did not preserve payload argv or SDKMAN environment' + fi + scenario_count=$((scenario_count + 1)) + printf 'PASS %s\n' "$case_name" +} + +scenario_full_env_restores_unapproved_defaults() { + begin_case full_env_restores_unapproved_defaults + local old_java_identifier='17.0.9-tem' + local java_identifier='21.0.9-tem' + local old_maven_raw=$'../maven/3.8.6\n' + local maven_identifier='3.9.8' + + create_java_candidate "$case_java_dir/$java_identifier" + set_candidate_default java relative "$old_java_identifier" "../java/$old_java_identifier" + set_candidate_default maven relative unused "$old_maven_raw" + write_sdkmanrc "java=$java_identifier" "maven=$maven_identifier" + set_full_env_operation_raw_target java "$java_identifier" + set_full_env_operation_raw_target maven "$maven_identifier" + + run_capture bash "$full_env_script" -- "$case_dir/payload" restored + assert_status 0 'full environment with no authorization restores all changed defaults' + assert_candidate_default_state java "link:../java/$old_java_identifier" \ + 'unapproved Java default is restored' + assert_link_target_raw "$old_maven_raw" "$(candidate_current_path maven)" \ + 'unapproved Maven default is restored byte-for-byte' + assert_file_contains "$FAKE_PAYLOAD_LOG" 'payload arg[0]=' \ + 'payload runs after successful full-environment reconciliation' + scenario_count=$((scenario_count + 1)) + printf 'PASS %s\n' "$case_name" +} + +scenario_full_env_cleanup_uses_pre_sdk_tools() { + begin_case full_env_cleanup_uses_pre_sdk_tools + local old_java_identifier='17.0.9-tem' + local java_identifier='21.0.9-tem' + + create_java_candidate "$case_java_dir/$java_identifier" + write_candidate_path_hijack "$case_java_dir/$java_identifier" + set_candidate_default java relative "$old_java_identifier" "../java/$old_java_identifier" + write_sdkmanrc "java=$java_identifier" + + run_capture bash "$full_env_script" -- "$case_dir/payload" safe-cleanup + assert_status 0 'full environment succeeds when its activated candidate shadows mv' + assert_candidate_default_state java "link:../java/$old_java_identifier" \ + 'full environment restores the unapproved Java default with its captured tool' + assert_file_empty "$FAKE_PATH_HIJACK_LOG" \ + 'candidate PATH cannot replace the runner cleanup mv command' + assert_file_contains "$FAKE_PAYLOAD_LOG" 'payload arg[0]=' \ + 'payload runs after cleanup uses the pre-SDK tool path' + scenario_count=$((scenario_count + 1)) + printf 'PASS %s\n' "$case_name" +} + +scenario_full_env_concurrent_third_writer() { + begin_case full_env_concurrent_third_writer + local old_java_identifier='17.0.9-tem' + local java_identifier='21.0.9-tem' + local old_maven_identifier='3.8.6' + local maven_identifier='3.9.8' + local third_identifier='3.9.9' + local third_target="$SDKMAN_CANDIDATES_DIR/maven/$third_identifier" + + create_java_candidate "$case_java_dir/$java_identifier" + set_candidate_default java relative "$old_java_identifier" "../java/$old_java_identifier" + set_candidate_default maven relative "$old_maven_identifier" "../maven/$old_maven_identifier" + write_sdkmanrc "java=$java_identifier" "maven=$maven_identifier" + export FAKE_SDK_THIRD_WRITER_CANDIDATE=maven + export FAKE_SDK_THIRD_WRITER_TARGET="$third_target" + export FAKE_SDK_THIRD_WRITER_AFTER_CANDIDATE=maven + export FAKE_SDK_THIRD_WRITER_AFTER_ACTION=use + + run_capture bash "$full_env_script" -- "$case_dir/payload" should-not-run + assert_status 1 'full environment refuses a concurrent third-writer default change' + assert_third_writer_ran "$third_target" maven + assert_candidate_default_state maven "link:$third_target" \ + 'full environment preserves the third-writer Maven default' + assert_candidate_default_state java "link:../java/$old_java_identifier" \ + 'other candidates are reconciled after third-writer detection' + assert_file_empty "$FAKE_PAYLOAD_LOG" \ + 'payload does not run after a concurrent full-environment default change' + scenario_count=$((scenario_count + 1)) + printf 'PASS %s\n' "$case_name" +} + +scenario_full_env_malformed_sdkmanrc() { + begin_case full_env_malformed_sdkmanrc + write_sdkmanrc 'java=21.0.9-tem' 'this line is malformed' + + run_capture bash "$full_env_script" -- "$case_dir/payload" should-not-run + assert_status 2 'malformed .sdkmanrc is rejected as command-line misuse' + assert_file_empty "$FAKE_SDK_LOG" 'malformed .sdkmanrc blocks sdk invocation' + assert_file_empty "$FAKE_PAYLOAD_LOG" 'malformed .sdkmanrc blocks payload invocation' + scenario_count=$((scenario_count + 1)) + printf 'PASS %s\n' "$case_name" +} + +scenario_full_env_unsupported_current() { + begin_case full_env_unsupported_current + local java_identifier='21.0.9-tem' + local maven_identifier='3.9.8' + + create_java_candidate "$case_java_dir/$java_identifier" + mkdir -p "$(candidate_current_path maven)" + write_sdkmanrc "java=$java_identifier" "maven=$maven_identifier" + + run_capture bash "$full_env_script" -- "$case_dir/payload" should-not-run + assert_status 1 'unsupported SDKMAN current path blocks full environment activation' + assert_file_empty "$FAKE_SDK_LOG" 'unsupported current path blocks sdk invocation' + assert_file_empty "$FAKE_PAYLOAD_LOG" 'unsupported current path blocks payload invocation' + scenario_count=$((scenario_count + 1)) + printf 'PASS %s\n' "$case_name" +} + +scenario_full_env_restore_failure() { + begin_case full_env_restore_failure + local old_java_identifier='17.0.9-tem' + local java_identifier='21.0.9-tem' + local old_maven_raw='../maven/3.8.6' + local maven_identifier='3.9.8' + + create_java_candidate "$case_java_dir/$java_identifier" + set_candidate_default java relative "$old_java_identifier" "../java/$old_java_identifier" + set_candidate_default maven relative unused "$old_maven_raw" + write_sdkmanrc "java=$java_identifier" "maven=$maven_identifier" + export FAKE_MV_FAIL_DESTINATION="$case_current" + + run_capture bash "$full_env_script" -- "$case_dir/payload" should-not-run + assert_status 1 'full environment returns one when a default cannot be restored' + assert_candidate_default_state java "link:$java_identifier" \ + 'failed Java restoration leaves the observed post-SDKMAN default' + assert_link_target_raw "$old_maven_raw" "$(candidate_current_path maven)" \ + 'full environment continues best-effort reconciliation after Java restoration fails' + assert_file_empty "$FAKE_PAYLOAD_LOG" 'payload does not run after restoration failure' + scenario_count=$((scenario_count + 1)) + printf 'PASS %s\n' "$case_name" +} + +scenario_full_env_sdk_failure_blocks_payload() { + begin_case full_env_sdk_failure_blocks_payload + local old_java_identifier='17.0.9-tem' + local java_identifier='21.0.9-tem' + local old_maven_identifier='3.8.6' + local maven_identifier='3.9.8' + + create_java_candidate "$case_java_dir/$java_identifier" + set_candidate_default java relative "$old_java_identifier" "../java/$old_java_identifier" + set_candidate_default maven relative "$old_maven_identifier" "../maven/$old_maven_identifier" + write_sdkmanrc "java=$java_identifier" "maven=$maven_identifier" + export FAKE_SDK_ENV_INSTALL_STATUS=17 + + run_capture bash "$full_env_script" -- "$case_dir/payload" should-not-run + assert_status 17 'full environment preserves sdk env install failure status after reconciliation' + assert_candidate_default_state java "link:../java/$old_java_identifier" \ + 'sdk failure still reconciles Java default' + assert_candidate_default_state maven "link:../maven/$old_maven_identifier" \ + 'sdk failure still reconciles Maven default' + assert_file_empty "$FAKE_PAYLOAD_LOG" 'payload does not run after sdk env install failure' + scenario_count=$((scenario_count + 1)) + printf 'PASS %s\n' "$case_name" +} + +scenario_full_env_invalid_authorization() { + begin_case full_env_invalid_authorization + local java_identifier='21.0.9-tem' + local maven_identifier='3.9.8' + + create_java_candidate "$case_java_dir/$java_identifier" + write_sdkmanrc "java=$java_identifier" "maven=$maven_identifier" + + run_capture bash "$full_env_script" --allow-default 'maven/invalid' -- "$case_dir/payload" + assert_status 2 'invalid authorization candidate is rejected' + assert_file_empty "$FAKE_SDK_LOG" 'invalid authorization blocks sdk invocation' + assert_file_empty "$FAKE_PAYLOAD_LOG" 'invalid authorization blocks payload invocation' + + : > "$FAKE_SDK_LOG" + : > "$FAKE_PAYLOAD_LOG" + run_capture bash "$full_env_script" --allow-default maven --allow-default maven -- "$case_dir/payload" + assert_status 2 'duplicate authorization candidate is rejected' + assert_file_empty "$FAKE_SDK_LOG" 'duplicate authorization blocks sdk invocation' + assert_file_empty "$FAKE_PAYLOAD_LOG" 'duplicate authorization blocks payload invocation' + + : > "$FAKE_SDK_LOG" + : > "$FAKE_PAYLOAD_LOG" + run_capture bash "$full_env_script" --allow-default gradle -- "$case_dir/payload" + assert_status 2 'authorization for a candidate absent from .sdkmanrc is rejected' + assert_file_empty "$FAKE_SDK_LOG" 'out-of-scope authorization blocks sdk invocation' + assert_file_empty "$FAKE_PAYLOAD_LOG" 'out-of-scope authorization blocks payload invocation' + scenario_count=$((scenario_count + 1)) + printf 'PASS %s\n' "$case_name" +} + +run_scenario() { + local scenario_function="$1" + local scenario_name="${scenario_function#scenario_}" + if [[ -z "$scenario_filter" || "$scenario_filter" == "$scenario_name" ]]; then + "$scenario_function" + fi +} + +run_scenario scenario_install_success_absolute_unchanged +run_scenario scenario_install_success_relative_unchanged +run_scenario scenario_install_success_absent +run_scenario scenario_install_failure_preserves_status +run_scenario scenario_install_failure_trailing_newline_default +run_scenario scenario_install_failure_option_like_default +run_scenario scenario_install_unexpected_default_from_absent +run_scenario scenario_install_unexpected_default_gnu_fallback +run_scenario scenario_install_restore_failure +run_scenario scenario_install_incomplete_candidate +run_scenario scenario_install_reserved_current +run_scenario scenario_install_concurrent_third_writer +run_scenario scenario_install_term_reconciles_default +run_scenario scenario_lock_live_owner_refusal +run_scenario scenario_lock_stale_owner_recovery +run_scenario scenario_lock_initialization_term_cleans_partial_lock +run_scenario scenario_restore_pre_cas_third_writer +run_scenario scenario_restore_gnu_fallback_third_writer +run_scenario scenario_run_success_absolute_path_shadow_payload +run_scenario scenario_run_success_relative_unchanged +run_scenario scenario_run_unexpected_default_gnu_fallback +run_scenario scenario_run_success_absent +run_scenario scenario_run_use_failure_preserves_status +run_scenario scenario_run_use_failure_byte_exact_default +run_scenario scenario_run_restore_failure +run_scenario scenario_run_incomplete_candidate +run_scenario scenario_run_reserved_current +run_scenario scenario_run_concurrent_third_writer +run_scenario scenario_run_term_reconciles_default +run_scenario scenario_full_env_atomic_sdkmanrc_replace_is_rejected_and_reconciled +run_scenario scenario_full_env_term_reconciles_operation_owned_defaults +run_scenario scenario_full_env_success_allows_authorized_default +run_scenario scenario_full_env_restores_unapproved_defaults +run_scenario scenario_full_env_cleanup_uses_pre_sdk_tools +run_scenario scenario_full_env_concurrent_third_writer +run_scenario scenario_full_env_malformed_sdkmanrc +run_scenario scenario_full_env_unsupported_current +run_scenario scenario_full_env_restore_failure +run_scenario scenario_full_env_sdk_failure_blocks_payload +run_scenario scenario_full_env_invalid_authorization + +if [[ "$scenario_count" -eq 0 ]]; then + printf 'Unknown scenario: %s\n' "$scenario_filter" >&2 + exit 2 +fi + +printf 'All %s sdkman-switch-jdk contract scenarios passed (scripts: %s)\n' "$scenario_count" "$scripts_dir"