diff --git a/VERSION b/VERSION index 1180819..699c6c6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.7 +0.1.8 diff --git a/plugins/cmd/.codex-plugin/plugin.json b/plugins/cmd/.codex-plugin/plugin.json index 6df0ca2..6c0e673 100644 --- a/plugins/cmd/.codex-plugin/plugin.json +++ b/plugins/cmd/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "cmd", - "version": "0.1.7", + "version": "0.1.8", "description": "Prompt-based wrapper commands for explicit agent workflows in Codex.", "author": { "name": "Sam Wang", diff --git a/plugins/dev/.codex-plugin/plugin.json b/plugins/dev/.codex-plugin/plugin.json index a51abb4..617aec4 100644 --- a/plugins/dev/.codex-plugin/plugin.json +++ b/plugins/dev/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "dev", - "version": "0.1.7", + "version": "0.1.8", "description": "Software development utilities for Codex.", "author": { "name": "Sam Wang", diff --git a/plugins/dev/skills/sdkman-switch-jdk/SKILL.md b/plugins/dev/skills/sdkman-switch-jdk/SKILL.md index e5ac0b1..e301888 100644 --- a/plugins/dev/skills/sdkman-switch-jdk/SKILL.md +++ b/plugins/dev/skills/sdkman-switch-jdk/SKILL.md @@ -1,19 +1,20 @@ --- name: sdkman-switch-jdk -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. +description: Always use for any Java/JDK version issue, including selecting, finding, installing, or switching a JDK; Java version mismatches or compatibility errors; JAVA_HOME or toolchain conflicts; and Maven, Gradle, project, CI, or local-runtime JDK differences. Resolve the required JDK safely with SDKMAN without unintentionally changing the default, even when SDKMAN availability is not yet confirmed. --- # SDKMAN 切換 JDK -採用最小範圍:預設只在執行實際命令的 shell 暫時切換 Java。只有使用者明確要求時,才變更 SDKMAN default、`.sdkmanrc` 或完整專案環境。 +採用最小範圍:已安裝的 exact JDK 預設只在實際命令的 process 暫時生效,不初始化 SDKMAN、不呼叫 `sdk use`、不讀寫 default,也不取得 default-state lock。只有需要安裝、永久 default 或完整專案環境時,才進入有狀態流程。 ## 不變條件 -- 先將需求解析成唯一的完整 SDKMAN identifier,再變更環境。 -- 將使用者明確指定的版本置於專案推論之前;衝突時揭露兩者。 -- 將 Java-only 要求限制在 Java,不連帶安裝或切換其他 SDK。 -- 在變更前記錄 default 狀態;除非使用者明確要求,完成後必須完全相同。 -- 將初始化、切換、驗證與實際命令放在同一個 shell。 +- 先將需求解析成唯一的完整 SDKMAN identifier,再套用環境。 +- 使用者明確指定的版本優先於專案推論;衝突時揭露兩者。 +- Java-only 要求只處理 Java,不連帶安裝或切換其他 SDK。 +- 已安裝的 exact candidate 一律使用 bundled `run-java.sh`;不要 source SDKMAN、裸用 `sdk use` 或手動設定 `JAVA_HOME`。 +- 會改變 SDKMAN 狀態的流程必須保護並驗證 default;除非使用者明確要求,完成後必須與執行前完全相同。 +- 使用 `bash -c` 執行 compound command;不要使用會重新讀取 profile 的 login shell。 - 以 `java -version`、`command -v java` 與實際命令結果作為完成證據。 ## 工作流程 @@ -27,63 +28,32 @@ description: Safely resolve, install, and switch SDKMAN-managed Java/JDK version 3. Gradle Java toolchain、Maven Toolchains 或 Maven Enforcer `requireJavaVersion`。 4. 專案文件、CI 設定與可重現的 build/test 錯誤。 -當目標需要從 `.sdkmanrc` 解析、需要修改該檔,或需要套用完整 SDKMAN 環境時,先計算其中有效的 `java=` 項目。沒有時繼續找其他證據;只有一個時使用它;超過一個時列出每個衝突並停止,要求使用者選定或授權修正,不得任選其中一筆。若使用者已明確指定一次性的完整 identifier,則回報 `.sdkmanrc` 衝突後仍可使用隔離 runner 繼續,因為該路徑不會套用 auto-env。 +當目標需要從 `.sdkmanrc` 解析、修改該檔或套用完整 SDKMAN 環境時,先計算有效的 `java=` 項目。沒有時繼續找其他證據;只有一個時使用它;超過一個時列出衝突並停止,不得任選。若使用者明確指定一次性的完整 identifier,回報 `.sdkmanrc` 衝突後仍可使用隔離 runner,因為該路徑不套用 auto-env。 -將 Maven `source`、`target`、`release` 與 Gradle `sourceCompatibility`、`targetCompatibility` 視為編譯相容性,不直接當作執行 Maven/Gradle 所需的 JDK。將一般性的 `` 視為線索,並用 toolchain、文件或錯誤訊息確認其語意。 +Maven `source`、`target`、`release` 與 Gradle `sourceCompatibility`、`targetCompatibility` 是編譯相容性,不直接等同於執行 Maven/Gradle 所需的 JDK。一般性的 `` 只是線索,需由 toolchain、文件或實際錯誤確認。 -若使用者要求一次性 JDK 與專案 metadata 衝突,採用使用者指定值並回報衝突。若要求持久化變更,先說明將修改哪個狀態。 +若使用者要求的一次性 JDK 與專案 metadata 衝突,採用使用者指定值並回報衝突。若要求持久化,先說明將修改哪個狀態。 -**完成條件:**取得一個有來源證據的完整 identifier,或取得仍需解析的唯一主版本與 distribution 約束。 +**完成條件:**取得一個有來源證據的完整 identifier,或取得唯一的主版本與 distribution 約束。 -### 2. 初始化並記錄原始狀態 +### 2. 不初始化 SDKMAN,先找已安裝版本 -在隔離的 Bash process 中載入 SDKMAN、停用本次初始化的 auto-env、檢查目前 Java,並記錄 `java/current`: +直接檢查 SDKMAN candidate tree;不要為了列目錄先 source `sdkman-init.sh`: ```bash 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" + sdkman_switch_jdk_root="${SDKMAN_DIR:-${HOME:?HOME is not set}/.sdkman}" + case "$sdkman_switch_jdk_root" in + /*) ;; + *) printf "SDKMAN_DIR must be an absolute path: %s\n" "$sdkman_switch_jdk_root" >&2; exit 1 ;; + esac + if [[ ! -d "$sdkman_switch_jdk_root" ]]; then + printf "Installed SDKMAN Java candidates: none (SDKMAN directory unavailable)\n" + exit 0 fi -' -``` - -將 `SDKMAN Java default state:` 後的完整值逐字保存為 ``;值只會是 `link-hex:` 或 `absent`。編碼避免 shell command substitution 遺失 target 尾端 newline,也讓 state 能安全地作為單一 argv 傳遞。若初始化失敗,停止並說明 SDKMAN 尚未安裝或不可用;取得同意前不安裝 SDKMAN。 - -**完成條件:**SDKMAN 可用、目前 Java 已觀察、default 已記為完整 symlink target 或 `absent`。 - -### 3. 解析完整 identifier - -列出已安裝版本: - -```bash -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_root="$(cd "$sdkman_switch_jdk_root" && pwd -P)" + sdkman_switch_jdk_java_dir="$sdkman_switch_jdk_root/candidates/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 {} \; | \ @@ -94,220 +64,78 @@ bash -c ' ' ``` -依以下規則解析: +依下列規則收斂 identifier: - metadata 或使用者已給完整 identifier 時,使用該值。 - 只有主版本時,先沿用使用者、專案 metadata 或目前 default 明確指出的 distribution。 - 只有一個符合主版本與 distribution 的已安裝版本時,使用它。 -- 多個版本仍符合時,列出它們並要求選定 distribution 或 patch;不得取清單第一筆。 -- 沒有已安裝版本時,查看 `sdk list java` 並選定一個完整可用 identifier;僅在使用者接受 SDKMAN 預設 distribution 時代為選擇 Temurin。 - -將 `sdk list java` 當作顯示資料;不要用 ANSI 輸出判斷目前生效的 Java。 +- 多個版本仍符合時,列出並要求選定 distribution 或 patch;不得取清單第一筆。 +- 沒有已安裝版本時,進入步驟 4;不要從 ANSI 格式的 `sdk list java` 推論目前生效版本。 **完成條件:**只剩一個完整 identifier,例如 `21.0.9-tem`。 -### 4. 必要時安全安裝 +### 3. 已安裝 exact candidate:直接執行 -若 identifier 尚未安裝,執行此 skill 目錄中的安裝 script;將 `` 解析為包含本檔案的目錄: +將 `` 解析為包含本檔案的目錄,使用唯一的暫時執行入口: ```bash -bash /scripts/install-java.sh +bash /scripts/run-java.sh -- [args...] ``` -script 會對 SDKMAN 的 default 提示明確回答 `n`,並驗證安裝前後的 `java/current` 完全相同。不要改寫成 `SDKMAN_AUTO_ANSWER=true sdk install ...`。 - -**完成條件:**`${SDKMAN_CANDIDATES_DIR}/java/` 存在,且 default 狀態與步驟 2 完全相同。 - -### 5. 套用最小範圍並執行 - -#### 暫時切換,預設分支 +runner 只使用 `${SDKMAN_DIR:-$HOME/.sdkman}/candidates/java/`,忽略外部的 `SDKMAN_CANDIDATES_DIR`。它會驗證 absolute SDKMAN root、candidate 與 `bin/java`,再設定 exact `JAVA_HOME`/`PATH`、執行版本探測並 `exec` payload;它不讀取 `java/current`,因此 malformed 或不存在的 default 不影響此分支。 -使用此 skill 的 runner 在同一個 Bash process 完成安全初始化、切換、驗證與實際命令。runner 在既有 default 時使用 `sdk use`,接著在所有情況下將精確 candidate 的 `bin` 放到 `PATH` 最前方;default 為 `absent` 時不建立 `java/current`: +需要 compound command 時,明確傳給 non-login Bash,例如: ```bash -bash /scripts/run-java.sh -- +bash /scripts/run-java.sh -- \ + bash -c 'mvn test && mvn package' ``` -暫時分支必須一律呼叫這個 runner;這是唯一允許的入口。不得以裸 `sdk use`、自行 source 的 subshell 或手動設定 `JAVA_HOME` 取代,因為 runner 同時負責阻止 auto-env、保留 default、處理原本沒有 default 的狀態,以及維持 command argv 邊界。 +不要改用 `bash -lc`;login shell 會重新讀取 profile,可能覆寫 runner 選定的 `JAVA_HOME` 與 `PATH`。不要拼接或 `eval` 使用者輸入。 -需要 compound command 時,將它明確交給 Bash,例如 `-- bash -lc 'mvn test && mvn package'`。不要自行拼接或 `eval` 使用者輸入。 +這條 fast path 刻意不載入 SDKMAN extensions/native override,也不和同一使用者對 candidate tree 的替換序列化。只對已完整安裝、受信任且穩定的 exact candidate 使用;若同一 identifier 正在安裝或被替換,等它完成後再執行。 -#### 永久 default,僅限明確要求 +**完成條件:**runner 回報的版本與路徑符合 identifier,且實際命令成功。若已完成使用者要求,直接進入步驟 6。 -使用步驟 2 保存的 ``。將 identifier 與 state 分別作為單一 argv 傳入,不得把 state 當作 SDKMAN identifier,也不得用 `eval`。設定前再次確認 default 沒有漂移,然後保存輸出的 ``: +### 4. 必要時解析並安全安裝 -```bash -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 +只有已安裝清單無法收斂或 exact candidate 尚未安裝時,才載入 SDKMAN。若 init 不存在,停止並在安裝 SDKMAN 前取得同意: +```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 + sdk list java ' ``` -只在使用者要求回復時執行以下命令。它先要求目前 state 逐字等於 ``,避免覆蓋後續變更;原本有 default 時,以 `java` 目錄內的暫存 symlink 原子還原 raw target,原本為 `absent` 時則移除本次建立的 symlink。兩個 state 都必須分別作為單一 argv 傳入: +從顯示結果選定完整 identifier;只有使用者接受 SDKMAN 預設 distribution 時,才代為選擇 Temurin。不要用 `head -1` 猜版本。 + +執行 bundled 安裝 script: ```bash -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 +bash /scripts/install-java.sh ``` -#### 專案或完整環境 +script 會取得 state lock、對 default 提示明確回答 `n`,並驗證安裝前後的 `java/current` 完全相同。不要改寫成 `SDKMAN_AUTO_ANSWER=true sdk install ...`。安裝成功後回到步驟 3,以無狀態 runner 執行實際命令。 + +**完成條件:**exact candidate 的 `bin/java` 存在且可執行,SDKMAN default 未改變,實際命令由步驟 3 驗證成功。 + +### 5. 只有明確要求時才擴大範圍 -當使用者明確要求修改 `.sdkmanrc`,或要求套用其中所有 SDK 時,讀取 [project-scope.md](references/project-scope.md)。一般 Java-only 要求留在暫時分支。 +- 永久變更或回復 SDKMAN Java default:讀取 [default-scope.md](references/default-scope.md)。 +- 修改 `.sdkmanrc` 或套用其中所有 SDK:讀取 [project-scope.md](references/project-scope.md)。 -**完成條件:**同一 shell 中的版本、路徑與完整 identifier 相符,且使用者要求的 build/test/command 成功;任何持久化狀態都有具體回復方式。 +不要因一般 Java build/test 需求載入這兩份參考。 ### 6. 回報結果 回報以下證據: 1. 選用的完整 identifier 與來源。 -2. 套用範圍:暫時、default、`.sdkmanrc` 或完整專案環境。 +2. 套用範圍:無狀態暫時執行、安裝、default、`.sdkmanrc` 或完整專案環境。 3. `java -version`、`command -v java` 與實際命令結果。 4. 任何持久化變更及其具體回復命令。 diff --git a/plugins/dev/skills/sdkman-switch-jdk/references/default-scope.md b/plugins/dev/skills/sdkman-switch-jdk/references/default-scope.md new file mode 100644 index 0000000..6a493f0 --- /dev/null +++ b/plugins/dev/skills/sdkman-switch-jdk/references/default-scope.md @@ -0,0 +1,201 @@ +# SDKMAN 永久 Java default 變更 + +只有在使用者明確要求變更或回復 SDKMAN Java default 時,才讀取本參考。一般 build、test 或單次命令使用 `SKILL.md` 的無狀態 `run-java.sh`。 + +## 記錄原始 default + +在隔離的 Bash process 中載入 SDKMAN、停用本次初始化的 auto-env,並記錄 `java/current`: + +```bash +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 + 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 +' +``` + +將 `SDKMAN Java default state:` 後的完整值逐字保存為 ``;值只會是 `link-hex:` 或 `absent`。編碼避免 shell command substitution 遺失 target 尾端 newline,也讓 state 能安全地作為單一 argv 傳遞。若初始化失敗,停止並說明 SDKMAN 尚未安裝或不可用;取得同意前不安裝 SDKMAN。 + +## 設定永久 default + +將 identifier 與保存的 state 分別作為單一 argv 傳入。設定前再次確認 default 沒有漂移,然後保存輸出的 ``: + +```bash +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 +' +``` + +不要將 state 當作 SDKMAN identifier,也不要用 `eval`。永久變更期間不得並行執行其他會改寫 `java/current` 的 SDKMAN 操作。 + +## 依要求回復 + +只在使用者要求回復時執行以下命令。它先要求目前 state 逐字等於 ``,避免覆蓋後續變更;原本有 default 時,以 `java` 目錄內的暫存 symlink 原子還原 raw target,原本為 `absent` 時則移除本次建立的 symlink: + +```bash +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 +``` + +**完成條件:**永久 default 的建立與驗證成功,並回報 ``、`` 及可逐 byte 回復的命令。若 default 在任一比較點漂移,保留外部狀態並停止。 diff --git a/plugins/dev/skills/sdkman-switch-jdk/references/project-scope.md b/plugins/dev/skills/sdkman-switch-jdk/references/project-scope.md index 0020fd1..9b9f87e 100644 --- a/plugins/dev/skills/sdkman-switch-jdk/references/project-scope.md +++ b/plugins/dev/skills/sdkman-switch-jdk/references/project-scope.md @@ -8,7 +8,7 @@ 1. 從 `.sdkmanrc` 讀出完整 `java=` identifier。 2. 必要時用 `scripts/install-java.sh` 只安裝該 Java。 -3. 回到 `SKILL.md` 步驟 5 的暫時分支;default 不存在時使用其中指向的 SDK home 分支。 +3. 回到 `SKILL.md` 步驟 3 的無狀態暫時分支。 此分支不執行 `sdk env install`,因為它會安裝並切換 `.sdkmanrc` 中的每一個 candidate。 @@ -28,7 +28,7 @@ 2. 修改前計算有效的 `java=` 項目。沒有時新增 `java=`;只有一個時更新該項並保留註解與其他 candidate。若超過一個,停止並列出衝突;只有在使用者明確要求修正重複項目時,才能更新第一項並移除其餘項目。 3. 修改後驗證恰好只有一個有效的 `java=` 項目,再檢查 diff,確認只包含預期的 Java 項目變更。 -4. 必要時用 `scripts/install-java.sh` 安裝該 Java,再回到 `SKILL.md` 步驟 5 的暫時分支執行與驗證。 +4. 必要時用 `scripts/install-java.sh` 安裝該 Java,再回到 `SKILL.md` 步驟 3 的無狀態暫時分支執行與驗證。 回復時,既有檔案應還原為保存的完整內容;若本次建立了新檔,先確認它仍是本次產物,再依使用者要求執行 `unlink .sdkmanrc`。不要使用會覆寫既有內容的固定 `.sdkmanrc.bak`。 @@ -52,7 +52,7 @@ bash /scripts/run-sdkman-env.sh \ `--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` 程式本文。 +需要 compound command 時,將 `bash -c 'mvn test && mvn package'` 當成 ` [args...]` 傳入。不要使用 `bash -lc`;login shell 可能重新讀取 profile 並覆寫 runner 選定的 SDKMAN 環境。不要拼接或 `eval` 使用者輸入。 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`。 diff --git a/plugins/dev/skills/sdkman-switch-jdk/references/troubleshooting.md b/plugins/dev/skills/sdkman-switch-jdk/references/troubleshooting.md index 850b3ba..bb89584 100644 --- a/plugins/dev/skills/sdkman-switch-jdk/references/troubleshooting.md +++ b/plugins/dev/skills/sdkman-switch-jdk/references/troubleshooting.md @@ -10,21 +10,26 @@ 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 保護。 +`scripts/run-java.sh` 直接使用 `${SDKMAN_DIR:-$HOME/.sdkman}/candidates/java/`,不載入 SDKMAN、不呼叫 `sdk use`、不讀寫 `java/current`,也不取得 default-state lock。它會將 exact candidate 的 `bin` 放到 `PATH` 最前方;caller 的 `PATH` 為空時不加入會代表目前目錄的空 segment。這條路徑刻意略過 SDKMAN extensions/native override,且不與同一 candidate 的並行替換序列化。 -三個 runner 共用 `${SDKMAN_CANDIDATES_DIR}/.sdkman-switch-jdk.lock`,讓 SDKMAN 操作與 reconciliation 不會彼此交錯。live owner 會使新呼叫 fail fast;metadata 完整、同一 EUID 且 PID 已不存在的 stale lock 會在重新核對後回收。metadata 不完整、ownership 不符或 PID 仍存活時一律保留 lock 並停止;先確認沒有相關 runner 後再人工檢查,不要直接遞迴刪除。 +`scripts/install-java.sh` 會預先設定 `USE=n` 並對提示送入 `n`,同時比較安裝前後的 `java/current`。`scripts/run-sdkman-env.sh` 會一次解析並驗證 `.sdkmanrc`,再以記憶體 candidate 清單逐項執行 `sdk install` 與 `sdk use`;它不讓 SDKMAN 第二次讀取檔案,並對每個 candidate 執行 raw-state 保護。 + +這兩個會改變 SDKMAN 狀態的 bundled 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。 +`run-java.sh` 已同時設定 `JAVA_HOME` 與 `PATH`。若它回報 active Java 不符,檢查 `${SDKMAN_DIR:-$HOME/.sdkman}/candidates/java//bin/java` 是否為可執行的 regular file。不要將 `JAVA_HOME` 指向 `java/current`;該 symlink 代表 default,不一定是要求的 identifier。compound command 使用 `bash -c`;`bash -lc` 可能載入 profile 並覆寫這兩個值。 ## 失敗處理 | 觀察 | 動作 | | --- | --- | -| SDKMAN init script 不存在 | 停止並在安裝 SDKMAN 前取得同意。 | +| exact candidate 已安裝但 SDKMAN init script 不存在 | 直接使用 `run-java.sh`;無狀態分支不需要 init。 | +| 需要安裝/完整環境但 SDKMAN init script 不存在 | 停止並在安裝 SDKMAN 前取得同意。 | | 沒有唯一 identifier 符合主版本 | 列出候選並要求選定 distribution;不要取清單第一筆。 | | `Stop! is not available.` | 重新查看 `sdk list java` 並選定完整可用 identifier。 | | runner 回報 active Java 不符 | 檢查 candidate 的 `bin/java` 與 shell 執行環境;不要繞過 runner,修正原因後重跑。 | +| stateful runner 回報無法建立 lock | 確認 lock parent 存在且可寫;不要把 absent path 當成 stale lock。 | +| stateful runner 回報 unsafe lock path | 保留該 regular file 或 symlink,確認來源後人工處理;runner 不會覆寫或刪除。 | | runner 回報 project `.sdkmanrc` 在 activation 期間改變 | 保留並檢查並行修改;runner 只會執行已驗證的記憶體 entries,不會覆寫或第二次讀取 project 檔案,確認內容穩定後再重跑。 | | 專案命令連帶影響 Maven、Gradle 或其他 SDK | 回到 Java-only 分支;只在明確要求完整環境時使用專用 runner。 | diff --git a/plugins/dev/skills/sdkman-switch-jdk/scripts/run-java.sh b/plugins/dev/skills/sdkman-switch-jdk/scripts/run-java.sh index 6519a70..9b0e95e 100755 --- a/plugins/dev/skills/sdkman-switch-jdk/scripts/run-java.sh +++ b/plugins/dev/skills/sdkman-switch-jdk/scripts/run-java.sh @@ -25,108 +25,52 @@ if [[ "$sdkman_switch_jdk_run_identifier" == "current" ]]; then 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 +if [[ -n "${SDKMAN_DIR:-}" ]]; then + sdkman_switch_jdk_run_root="$SDKMAN_DIR" +elif [[ -n "${HOME:-}" ]]; then + sdkman_switch_jdk_run_root="$HOME/.sdkman" +else + printf 'SDKMAN_DIR and HOME are both unset.\n' >&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 +case "$sdkman_switch_jdk_run_root" in + /*) ;; + *) + printf 'SDKMAN_DIR must be an absolute path: %s\n' \ + "$sdkman_switch_jdk_run_root" >&2 + exit 1 + ;; +esac +if ! sdkman_switch_jdk_run_root="$( + cd "$sdkman_switch_jdk_run_root" 2>/dev/null && pwd -P +)"; then + printf 'SDKMAN directory is not accessible: %s\n' \ + "$sdkman_switch_jdk_run_root" >&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_java_dir="$sdkman_switch_jdk_run_root/candidates/java" sdkman_switch_jdk_run_target="$sdkman_switch_jdk_run_java_dir/$sdkman_switch_jdk_run_identifier" +sdkman_switch_jdk_run_expected="$sdkman_switch_jdk_run_target/bin/java" -if [[ ! -x "$sdkman_switch_jdk_run_target/bin/java" ]]; then +if [[ ! -d "$sdkman_switch_jdk_run_target" || \ + ! -f "$sdkman_switch_jdk_run_expected" || \ + ! -x "$sdkman_switch_jdk_run_expected" ]]; 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 +export JAVA_HOME="$sdkman_switch_jdk_run_target" +sdkman_switch_jdk_run_original_path="${PATH-}" +if [[ -n "$sdkman_switch_jdk_run_original_path" ]]; then + export PATH="$JAVA_HOME/bin:$sdkman_switch_jdk_run_original_path" 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 + export PATH="$JAVA_HOME/bin" 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' \ @@ -134,7 +78,6 @@ if [[ "$sdkman_switch_jdk_run_java" != "$sdkman_switch_jdk_run_expected" ]]; the exit 1 fi -java -version >&2 +"$sdkman_switch_jdk_run_expected" -version >&2 printf 'java: %s\n' "$sdkman_switch_jdk_run_java" >&2 -sdkman_switch_jdk_clear_cleanup_traps -exec "$@" +exec -- "$@" 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 index 8a90fc0..461c1c4 100644 --- a/plugins/dev/skills/sdkman-switch-jdk/scripts/sdkman-current-state.sh +++ b/plugins/dev/skills/sdkman-switch-jdk/scripts/sdkman-current-state.sh @@ -337,11 +337,19 @@ sdkman_switch_jdk_acquire_lock() { 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' \ + if [[ -L "$sdkman_switch_jdk_lock_dir" || \ + ( -e "$sdkman_switch_jdk_lock_dir" && \ + ! -d "$sdkman_switch_jdk_lock_dir" ) ]]; then + printf 'SDKMAN default-state lock path is unsafe: %s\n' \ "$sdkman_switch_jdk_lock_dir" >&2 return 1 fi + if [[ ! -d "$sdkman_switch_jdk_lock_dir" ]]; then + printf 'Could not create the SDKMAN default-state lock: %s\n' \ + "$sdkman_switch_jdk_lock_dir" >&2 + printf 'The path was absent when checked; verify that its parent exists and is writable.\n' >&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 diff --git a/test/sdkman-switch-jdk-contract.sh b/test/sdkman-switch-jdk-contract.sh index 7f661b0..8e84f66 100644 --- a/test/sdkman-switch-jdk-contract.sh +++ b/test/sdkman-switch-jdk-contract.sh @@ -228,9 +228,10 @@ assert_default_state() { create_java_candidate() { local candidate="$1" + local interpreter="${2:-/usr/bin/env bash}" mkdir -p "$candidate/bin" { - printf '%s\n' '#!/usr/bin/env bash' + printf '#!%s\n' "$interpreter" 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"' @@ -519,9 +520,11 @@ write_fake_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' 'printf "mkdir destination=<%s>\\n" "$fake_mkdir_destination" >> "${FAKE_MKDIR_LOG:?}"' + printf '%s\n' 'if [[ -n "${FAKE_MKDIR_FAIL_STATUS:-}" && "$fake_mkdir_destination" == "${FAKE_MKDIR_FAIL_PATH:-}" ]]; then exit "$FAKE_MKDIR_FAIL_STATUS"; fi' + printf '%s\n' '"${FAKE_REAL_MKDIR:?}" "$@" || exit $?' 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' @@ -606,6 +609,7 @@ begin_case() { 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_MKDIR_LOG="$case_dir/mkdir.log" 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 @@ -622,6 +626,8 @@ begin_case() { unset FAKE_PROJECT_SDKMANRC unset FAKE_SDK_ENV_DIRECT unset FAKE_MKDIR_SIGNAL_LOCK_INIT + unset FAKE_MKDIR_FAIL_PATH + unset FAKE_MKDIR_FAIL_STATUS unset FAKE_SDK_SIGNAL_AFTER_MUTATION unset FAKE_SDK_SIGNAL_AFTER_CANDIDATE unset FAKE_SDK_SIGNAL_AFTER_ACTION @@ -647,6 +653,7 @@ begin_case() { : > "$FAKE_SDK_SIGNAL_LOG" : > "$FAKE_PATH_HIJACK_LOG" : > "$FAKE_MKDIR_SIGNAL_LOG" + : > "$FAKE_MKDIR_LOG" : > "$FAKE_SDK_ENV_OPERATION_FILE" write_fake_init write_fake_mv @@ -1295,176 +1302,209 @@ scenario_restore_gnu_fallback_third_writer() { 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' +scenario_run_existing_candidate_without_init() { + begin_case run_existing_candidate_without_init 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 + local stateless_root="$case_dir/sdkman stateless root" + local stateless_candidates + local target + local malformed_current + local attacker_candidates="$case_dir/attacker candidates;literal" + + mkdir -p "$stateless_root/bin" "$attacker_candidates/java" + stateless_root="$(cd "$stateless_root" && pwd -P)" + stateless_candidates="$stateless_root/candidates" + target="$stateless_candidates/java/$target_identifier" + malformed_current="$stateless_candidates/java/current" + create_java_candidate "$target" + create_incomplete_candidate "$attacker_candidates/java/$target_identifier" + printf '%s\n' 'not-a-symlink' > "$malformed_current" + cp "$malformed_current" "$case_dir/current-before" + export SDKMAN_DIR="$stateless_root" + export SDKMAN_CANDIDATES_DIR="$attacker_candidates" 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' + run_capture bash "$run_script" "$target_identifier" -- "$case_dir/payload" \ + 'arg with spaces' '' 'glob*value?' '--flag=value' + assert_status 23 'installed candidate runs without SDKMAN init' + assert_file_empty "$FAKE_INIT_LOG" 'stateless runner does not source SDKMAN init' + assert_file_empty "$FAKE_SDK_LOG" 'stateless runner does not invoke sdk' + assert_file_not_contains "$FAKE_MKDIR_LOG" \ + "mkdir destination=<$stateless_candidates/.sdkman-switch-jdk.lock>" \ + 'stateless runner does not attempt a lock mkdir' + assert_path_absent "$stateless_candidates/.sdkman-switch-jdk.lock" \ + 'stateless runner does not create a lock beside its candidate' + assert_path_absent "$attacker_candidates/.sdkman-switch-jdk.lock" \ + 'stateless runner ignores injected SDKMAN_CANDIDATES_DIR' + if ! cmp "$case_dir/current-before" "$malformed_current" >/dev/null 2>&1; then + fail 'stateless runner changed a malformed current path' + fi { - 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 JAVA_HOME=<%s>\n' "$target" + printf 'payload PATH=<%s>\n' "$target/bin:$fake_bin:$original_path" + printf 'payload java=<%s>\n' "$target/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]=<-->\n' - printf 'payload arg[4]=<--flag=value>\n' + printf 'payload arg[3]=<--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' + fail 'stateless runner did not preserve payload argv or environment 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' +scenario_run_empty_path_is_safe() { + begin_case run_empty_path_is_safe 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 + local stateless_root="$case_dir/sdkman-empty-path" + local target + + mkdir -p "$stateless_root/bin" + stateless_root="$(cd "$stateless_root" && pwd -P)" + target="$stateless_root/candidates/java/$target_identifier" + create_java_candidate "$target" /bin/bash + export SDKMAN_DIR="$stateless_root" + unset SDKMAN_CANDIDATES_DIR + export FAKE_PAYLOAD_STATUS=29 + export PATH='' + run_capture /bin/bash "$run_script" "$target_identifier" -- /bin/bash "$case_dir/payload" empty-path + export PATH="$fake_bin:$original_path" - 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 + assert_status 29 'empty caller PATH preserves the payload status' + assert_file_empty "$FAKE_INIT_LOG" 'empty PATH fast path does not source SDKMAN init' + assert_file_empty "$FAKE_SDK_LOG" 'empty PATH fast path does not invoke sdk' + assert_file_not_contains "$FAKE_MKDIR_LOG" \ + "mkdir destination=<$stateless_root/candidates/.sdkman-switch-jdk.lock>" \ + 'empty PATH fast path does not attempt a lock mkdir' + assert_file_contains "$FAKE_JAVA_LOG" 'java arg[0]=<-version>' \ + 'absolute-shebang candidate java performs the version probe' + { + printf 'payload JAVA_HOME=<%s>\n' "$target" + printf 'payload PATH=<%s>\n' "$target/bin" + printf 'payload java=<%s>\n' "$target/bin/java" + printf 'payload argc=1\n' + printf 'payload arg[0]=\n' + } > "$case_dir/expected-payload.log" + if ! cmp "$case_dir/expected-payload.log" "$FAKE_PAYLOAD_LOG" >/dev/null 2>&1; then + fail 'empty PATH runner did not preserve the exact isolated payload environment' + fi scenario_count=$((scenario_count + 1)) printf 'PASS %s\n' "$case_name" } -scenario_run_success_absent() { - begin_case run_success_absent +scenario_run_compound_bash_c_keeps_runner_jdk() { + begin_case run_compound_bash_c_keeps_runner_jdk local target_identifier='21.0.9-tem' - create_java_candidate "$case_java_dir/$target_identifier" - set_default absent "$target_identifier" + local stateless_root="$case_dir/sdkman-compound" + local target + local profile_java="$case_dir/profile-java" + + mkdir -p "$stateless_root" + stateless_root="$(cd "$stateless_root" && pwd -P)" + target="$stateless_root/candidates/java/$target_identifier" + create_java_candidate "$target" + create_java_candidate "$profile_java" + export SDKMAN_DIR="$stateless_root" + unset SDKMAN_CANDIDATES_DIR + { + printf 'export JAVA_HOME=%q\n' "$profile_java" + printf 'export PATH=%q/bin:$PATH\n' "$profile_java" + } > "$HOME/.bash_profile" + export FAKE_PAYLOAD_STATUS=31 + + run_capture bash "$run_script" "$target_identifier" -- \ + bash -lc 'exec "$@"' bash "$case_dir/payload" login-control + assert_status 31 'login-shell control preserves the payload status' + assert_file_contains "$FAKE_PAYLOAD_LOG" "payload JAVA_HOME=<$profile_java>" \ + 'bash -lc control proves the profile can overwrite JAVA_HOME' + assert_file_contains "$FAKE_PAYLOAD_LOG" "payload java=<$profile_java/bin/java>" \ + 'bash -lc control proves the profile can overwrite PATH' - 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' + : > "$FAKE_PAYLOAD_LOG" + run_capture bash "$run_script" "$target_identifier" -- \ + bash -c 'exec "$@"' bash "$case_dir/payload" non-login + assert_status 31 'non-login compound command preserves the payload status' + assert_file_contains "$FAKE_PAYLOAD_LOG" "payload JAVA_HOME=<$target>" \ + 'bash -c keeps the runner JAVA_HOME' + assert_file_contains "$FAKE_PAYLOAD_LOG" "payload java=<$target/bin/java>" \ + 'bash -c keeps the runner java path' + assert_file_contains "$FAKE_PAYLOAD_LOG" 'payload arg[0]=' \ + 'bash -c preserves compound-command argv' 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' +scenario_lock_create_failure_is_not_type_error() { + begin_case lock_create_failure_is_not_type_error 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 + local lock_path="$SDKMAN_CANDIDATES_DIR/.sdkman-switch-jdk.lock" - 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 + export FAKE_MKDIR_FAIL_PATH="$lock_path" + export FAKE_MKDIR_FAIL_STATUS=13 + run_capture bash "$install_script" "$target_identifier" + assert_status 1 'lock creation failure returns one' + assert_path_absent "$lock_path" 'failed lock creation leaves no lock path' + assert_file_contains "$FAKE_MKDIR_LOG" "mkdir destination=<$lock_path>" \ + 'fake mkdir received the lock creation request' + assert_file_empty "$FAKE_SDK_LOG" 'lock creation failure blocks sdk invocation' + assert_file_not_contains "$case_dir/stderr" 'not a directory' \ + 'absent lock path is not misreported as a type error' + assert_file_contains "$case_dir/stderr" 'Could not create the SDKMAN default-state lock' \ + 'lock creation failure is reported as a creation failure' 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 +scenario_lock_regular_file_is_unsafe() { + begin_case lock_regular_file_is_unsafe 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 + local lock_path="$SDKMAN_CANDIDATES_DIR/.sdkman-switch-jdk.lock" - 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 + printf '%s\n' 'untrusted regular file' > "$lock_path" + cp "$lock_path" "$case_dir/lock-before" + run_capture bash "$install_script" "$target_identifier" + assert_status 1 'regular-file lock path returns one' + if ! cmp "$case_dir/lock-before" "$lock_path" >/dev/null 2>&1; then + fail 'regular-file lock path was modified' + fi + assert_file_empty "$FAKE_SDK_LOG" 'regular-file lock path blocks sdk invocation' + assert_file_contains "$case_dir/stderr" 'SDKMAN default-state lock path is unsafe' \ + 'regular-file lock path is reported as unsafe' 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' +scenario_lock_symlink_is_unsafe() { + begin_case lock_symlink_is_unsafe 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 + local lock_path="$SDKMAN_CANDIDATES_DIR/.sdkman-switch-jdk.lock" + local raw_target='../untrusted-lock-target' - 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 + ln -s -- "$raw_target" "$lock_path" + run_capture bash "$install_script" "$target_identifier" + assert_status 1 'symlink lock path returns one' + assert_link_target_raw "$raw_target" "$lock_path" 'symlink lock path was not modified' + assert_file_empty "$FAKE_SDK_LOG" 'symlink lock path blocks sdk invocation' + assert_file_contains "$case_dir/stderr" 'SDKMAN default-state lock path is unsafe' \ + 'symlink lock path is reported as unsafe' 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" + local stateless_root="$case_dir/sdkman-incomplete" + + create_incomplete_candidate "$stateless_root/candidates/java/$target_identifier" + export SDKMAN_DIR="$stateless_root" + unset SDKMAN_CANDIDATES_DIR 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' @@ -1472,63 +1512,72 @@ scenario_run_incomplete_candidate() { printf 'PASS %s\n' "$case_name" } -scenario_run_reserved_current() { - begin_case run_reserved_current +scenario_run_relative_sdkman_dir_is_rejected() { + begin_case run_relative_sdkman_dir_is_rejected + local target_identifier='21.0.9-tem' - 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' + create_java_candidate "$case_dir/relative-sdkman/candidates/java/$target_identifier" + export SDKMAN_DIR='relative-sdkman' + unset SDKMAN_CANDIDATES_DIR + + run_capture /bin/bash "$run_script" "$target_identifier" -- "$case_dir/payload" should-not-run + assert_status 1 'relative SDKMAN_DIR is rejected' + assert_file_empty "$FAKE_JAVA_LOG" 'relative SDKMAN_DIR is rejected before probing Java' + assert_file_empty "$FAKE_PAYLOAD_LOG" 'relative SDKMAN_DIR is rejected before the payload' + assert_file_contains "$case_dir/stderr" 'SDKMAN_DIR must be an absolute path' \ + 'relative SDKMAN_DIR reports the unsafe configuration' 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_run_invalid_identifiers_are_rejected() { + begin_case run_invalid_identifiers_are_rejected + local invalid_identifier + + export SDKMAN_DIR="$case_dir/sdkman-invalid-identifiers" + unset SDKMAN_CANDIDATES_DIR + for invalid_identifier in '../x' 'a/b' '/absolute' '.hidden' $'bad\nidentifier'; do + : > "$FAKE_JAVA_LOG" + : > "$FAKE_PAYLOAD_LOG" + run_capture /bin/bash "$run_script" "$invalid_identifier" -- "$case_dir/payload" should-not-run + assert_status 2 "invalid identifier is rejected: $invalid_identifier" + assert_file_empty "$FAKE_JAVA_LOG" 'invalid identifier is rejected before probing Java' + assert_file_empty "$FAKE_PAYLOAD_LOG" 'invalid identifier is rejected before the payload' + assert_file_contains "$case_dir/stderr" 'Invalid SDKMAN Java identifier' \ + 'invalid identifier reports the validation failure' + done 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' +scenario_run_java_probe_failure_blocks_payload() { + begin_case run_java_probe_failure_blocks_payload local target_identifier='21.0.9-tem' + local stateless_root="$case_dir/sdkman-probe-failure" + local target="$stateless_root/candidates/java/$target_identifier" + + create_java_candidate "$target" /bin/bash + export SDKMAN_DIR="$stateless_root" + unset SDKMAN_CANDIDATES_DIR + export FAKE_JAVA_STATUS=17 + + run_capture /bin/bash "$run_script" "$target_identifier" -- /bin/bash "$case_dir/payload" should-not-run + assert_status 17 'java version probe failure status is preserved' + assert_file_contains "$FAKE_JAVA_LOG" 'java arg[0]=<-version>' \ + 'failed candidate java performs the version probe' + assert_file_empty "$FAKE_PAYLOAD_LOG" 'failed Java probe blocks the payload' + scenario_count=$((scenario_count + 1)) + printf 'PASS %s\n' "$case_name" +} - 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 +scenario_run_reserved_current() { + begin_case run_reserved_current - 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 + 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" } @@ -1867,17 +1916,17 @@ 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_existing_candidate_without_init +run_scenario scenario_run_empty_path_is_safe +run_scenario scenario_run_compound_bash_c_keeps_runner_jdk 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_run_relative_sdkman_dir_is_rejected +run_scenario scenario_run_invalid_identifiers_are_rejected +run_scenario scenario_run_java_probe_failure_blocks_payload +run_scenario scenario_lock_create_failure_is_not_type_error +run_scenario scenario_lock_regular_file_is_unsafe +run_scenario scenario_lock_symlink_is_unsafe 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