diff --git a/libexec/tfenv-install b/libexec/tfenv-install index cd83ff1..0e987eb 100755 --- a/libexec/tfenv-install +++ b/libexec/tfenv-install @@ -86,6 +86,57 @@ if [ "${TFENV_SKIP_REMOTE_CHECK:-0}" -eq 0 ]; then [ -n "${remote_version}" ] && version="${remote_version}" || log 'error' "No versions matching '${requested:-$version}' found in remote"; fi; +# Attempt to create the config dir and capture any error for precise diagnostics. +# A read-only config dir (e.g. Homebrew Cellar on CI runners) causes mkdir-based +# locking to fail with EACCES, previously misreported as lock contention (#524). +declare mkdir_err; +mkdir_err="$(mkdir -p "${TFENV_CONFIG_DIR}" 2>&1)"; +declare mkdir_rc="${?}"; + +declare config_dir_reason=""; + +if [ "${mkdir_rc}" -ne 0 ]; then + # mkdir failed — determine exactly why and report precisely + if [ -e "${TFENV_CONFIG_DIR}" ] && [ ! -d "${TFENV_CONFIG_DIR}" ]; then + config_dir_reason="${TFENV_CONFIG_DIR} exists but is not a directory"; + else + # Walk up the path to find the first ancestor that exists + declare check_path="${TFENV_CONFIG_DIR}"; + while [ ! -e "${check_path}" ]; do + check_path="$(dirname "${check_path}")"; + done; + if [ ! -d "${check_path}" ]; then + config_dir_reason="ancestor ${check_path} exists but is not a directory"; + elif [ ! -w "${check_path}" ]; then + config_dir_reason="cannot create ${TFENV_CONFIG_DIR} — parent ${check_path} is not writable (owner: $(stat -c '%U' "${check_path}" 2>/dev/null || stat -f '%Su' "${check_path}" 2>/dev/null || echo 'unknown'))"; + else + # Writable ancestor exists but mkdir still failed — relay the actual error + config_dir_reason="mkdir failed: ${mkdir_err}"; + fi; + fi; +elif [ ! -w "${TFENV_CONFIG_DIR}" ]; then + # Directory exists but is not writable by this user + config_dir_reason="exists but is not writable (owner: $(stat -c '%U' "${TFENV_CONFIG_DIR}" 2>/dev/null || stat -f '%Su' "${TFENV_CONFIG_DIR}" 2>/dev/null || echo 'unknown'), mode: $(stat -c '%a' "${TFENV_CONFIG_DIR}" 2>/dev/null || stat -f '%Lp' "${TFENV_CONFIG_DIR}" 2>/dev/null || echo 'unknown'))"; +fi; + +if [ -n "${config_dir_reason}" ]; then + log 'info' "TFENV_CONFIG_DIR (${TFENV_CONFIG_DIR}): ${config_dir_reason}"; + if [[ "${TFENV_FORCE_INTERACTIVE:-}" == "1" || -t 0 ]]; then + echo "tfenv: Use ~/.tfenv instead? [y/N]" >&2; + read -r answer; + if [[ "${answer}" =~ ^[Yy]$ ]]; then + TFENV_CONFIG_DIR="${HOME}/.tfenv"; + export TFENV_CONFIG_DIR; + mkdir -p "${TFENV_CONFIG_DIR}" || log 'error' "Failed to create ${TFENV_CONFIG_DIR}"; + log 'info' "Falling back to TFENV_CONFIG_DIR=${TFENV_CONFIG_DIR}"; + else + log 'error' "TFENV_CONFIG_DIR (${TFENV_CONFIG_DIR}): ${config_dir_reason}. Set TFENV_CONFIG_DIR to a writable path (e.g. export TFENV_CONFIG_DIR=\"\${HOME}/.tfenv\")"; + fi; + else + log 'error' "TFENV_CONFIG_DIR (${TFENV_CONFIG_DIR}): ${config_dir_reason}. Set TFENV_CONFIG_DIR to a writable path (e.g. export TFENV_CONFIG_DIR=\"\${HOME}/.tfenv\")"; + fi; +fi; + dst_path="${TFENV_CONFIG_DIR}/versions/${version}"; if [ -f "${dst_path}/terraform" ]; then echo "Terraform v${version} is already installed"; @@ -98,10 +149,6 @@ declare lockdir="${TFENV_CONFIG_DIR}/.install-lock-${version}"; declare lock_retries=0; declare lock_max_retries=60; -# Ensure the config dir exists so the lock mkdir can succeed on a first install. -# Without this, mkdir fails with ENOENT and the loop below misreads it as contention. -mkdir -p "${TFENV_CONFIG_DIR}" || log 'error' "Failed to create ${TFENV_CONFIG_DIR}"; - cleanup_lock() { rmdir "${lockdir}" 2>/dev/null || true; }; diff --git a/test/test_install_lock.sh b/test/test_install_lock.sh new file mode 100755 index 0000000..9e895bd --- /dev/null +++ b/test/test_install_lock.sh @@ -0,0 +1,258 @@ +#!/usr/bin/env bash + +# Source common test setup +source "$(dirname "${0}")/test_common.sh"; + +##################### +# Begin Script Body # +##################### + +declare -a errors=(); +declare test_version='1.6.1'; + +log 'info' '### Test Suite: install_lock'; + +############################################################################## +# Test 1: Install with non-existent TFENV_CONFIG_DIR (regression test #487/#525) +############################################################################## +log 'info' '## install_lock: install with non-existent TFENV_CONFIG_DIR'; +cleanup || log 'error' 'Cleanup failed?!'; +( + declare fresh_config_dir; + fresh_config_dir="$(mktemp -d 2>/dev/null || mktemp -d -t 'tfenv_lock_test')"; + rm -rf "${fresh_config_dir}"; + # Confirm it does not exist + [ ! -d "${fresh_config_dir}" ] || exit 1; + TFENV_CONFIG_DIR="${fresh_config_dir}" tfenv install "${test_version}" || exit 1; + [ -f "${fresh_config_dir}/versions/${test_version}/terraform" ] || exit 1; + rm -rf "${fresh_config_dir}"; +) && log 'info' '## install_lock: non-existent config dir passed' \ + || error_and_proceed 'install with non-existent TFENV_CONFIG_DIR failed'; + +############################################################################## +# Test 2: Install with read-only TFENV_CONFIG_DIR, non-interactive (#524) +############################################################################## +log 'info' '## install_lock: read-only config dir, non-interactive'; +cleanup || log 'error' 'Cleanup failed?!'; +( + declare ro_dir; + ro_dir="$(mktemp -d 2>/dev/null || mktemp -d -t 'tfenv_lock_ro')"; + chmod 555 "${ro_dir}"; + declare output; + output="$(TFENV_CONFIG_DIR="${ro_dir}" tfenv install "${test_version}" < /dev/null 2>&1)"; + declare rc="${?}"; + chmod 755 "${ro_dir}"; + rm -rf "${ro_dir}"; + [ "${rc}" -ne 0 ] || exit 1; + echo "${output}" | grep -q 'not writable' || exit 1; + # Verify diagnostics include ownership info + echo "${output}" | grep -q 'owner:' || exit 1; +) && log 'info' '## install_lock: read-only non-interactive passed' \ + || error_and_proceed 'read-only TFENV_CONFIG_DIR non-interactive did not fail with expected error'; + +############################################################################## +# Test 3: Install with read-only TFENV_CONFIG_DIR, interactive fallback accepted +############################################################################## +log 'info' '## install_lock: read-only config dir, interactive fallback accepted'; +cleanup || log 'error' 'Cleanup failed?!'; +( + declare ro_dir; + ro_dir="$(mktemp -d 2>/dev/null || mktemp -d -t 'tfenv_lock_ro2')"; + chmod 555 "${ro_dir}"; + declare fallback_home; + fallback_home="$(mktemp -d 2>/dev/null || mktemp -d -t 'tfenv_lock_home')"; + # Use TFENV_FORCE_INTERACTIVE to bypass the [[ -t 0 ]] check so we can + # pipe input directly without needing script(1) and PTY allocation, which + # behaves differently on GNU vs BSD and is unreliable in CI. + declare output; + output="$(printf 'y\n' | TFENV_FORCE_INTERACTIVE=1 TFENV_CONFIG_DIR="${ro_dir}" HOME="${fallback_home}" "${TFENV_ROOT}/bin/tfenv" install "${test_version}" 2>&1)"; + declare rc="${?}"; + chmod 755 "${ro_dir}"; + rm -rf "${ro_dir}"; + if [ "${rc}" -ne 0 ]; then + echo "UNEXPECTED FAILURE output: ${output}" >&2; + rm -rf "${fallback_home}"; + exit 1; + fi; + [ -f "${fallback_home}/.tfenv/versions/${test_version}/terraform" ] || { + echo "terraform binary not found in fallback dir" >&2; + rm -rf "${fallback_home}"; + exit 1; + }; + rm -rf "${fallback_home}"; +) && log 'info' '## install_lock: interactive fallback accepted passed' \ + || error_and_proceed 'read-only TFENV_CONFIG_DIR interactive fallback (y) did not install to ~/.tfenv'; + +############################################################################## +# Test 4: Install with read-only TFENV_CONFIG_DIR, interactive fallback declined +############################################################################## +log 'info' '## install_lock: read-only config dir, interactive fallback declined'; +cleanup || log 'error' 'Cleanup failed?!'; +( + declare ro_dir; + ro_dir="$(mktemp -d 2>/dev/null || mktemp -d -t 'tfenv_lock_ro3')"; + chmod 555 "${ro_dir}"; + declare output; + output="$(printf 'n\n' | TFENV_FORCE_INTERACTIVE=1 TFENV_CONFIG_DIR="${ro_dir}" "${TFENV_ROOT}/bin/tfenv" install "${test_version}" 2>&1)"; + declare rc="${?}"; + chmod 755 "${ro_dir}"; + rm -rf "${ro_dir}"; + [ "${rc}" -ne 0 ] || exit 1; +) && log 'info' '## install_lock: interactive fallback declined passed' \ + || error_and_proceed 'read-only TFENV_CONFIG_DIR interactive fallback (n) did not fail'; + +############################################################################## +# Test 5: Non-existent config dir inside non-writable parent, non-interactive +############################################################################## +log 'info' '## install_lock: non-existent config dir, non-writable parent, non-interactive'; +cleanup || log 'error' 'Cleanup failed?!'; +( + declare readonly_parent; + readonly_parent="$(mktemp -d 2>/dev/null || mktemp -d -t 'tfenv_lock_roprt')"; + chmod 555 "${readonly_parent}"; + declare output; + output="$(TFENV_CONFIG_DIR="${readonly_parent}/tfenv-config" tfenv install "${test_version}" < /dev/null 2>&1)"; + declare rc="${?}"; + chmod 755 "${readonly_parent}"; + rm -rf "${readonly_parent}"; + [ "${rc}" -ne 0 ] || exit 1; + echo "${output}" | grep -q 'not writable' || exit 1; + # Verify diagnostics include ownership info and identify the parent + echo "${output}" | grep -q 'owner:' || exit 1; + echo "${output}" | grep -q 'parent' || exit 1; +) && log 'info' '## install_lock: non-existent config dir, non-writable parent, non-interactive passed' \ + || error_and_proceed 'non-existent config dir inside non-writable parent (non-interactive) did not fail with expected error'; + +############################################################################## +# Test 6: Non-existent config dir inside non-writable parent, interactive fallback accepted +############################################################################## +log 'info' '## install_lock: non-existent config dir, non-writable parent, interactive fallback'; +cleanup || log 'error' 'Cleanup failed?!'; +( + declare readonly_parent; + readonly_parent="$(mktemp -d 2>/dev/null || mktemp -d -t 'tfenv_lock_roprt2')"; + chmod 555 "${readonly_parent}"; + declare fallback_home; + fallback_home="$(mktemp -d 2>/dev/null || mktemp -d -t 'tfenv_lock_home2')"; + declare output; + output="$(printf 'y\n' | TFENV_FORCE_INTERACTIVE=1 TFENV_CONFIG_DIR="${readonly_parent}/tfenv-config" HOME="${fallback_home}" "${TFENV_ROOT}/bin/tfenv" install "${test_version}" 2>&1)"; + declare rc="${?}"; + chmod 755 "${readonly_parent}"; + rm -rf "${readonly_parent}"; + if [ "${rc}" -ne 0 ]; then + echo "UNEXPECTED FAILURE output: ${output}" >&2; + rm -rf "${fallback_home}"; + exit 1; + fi; + [ -f "${fallback_home}/.tfenv/versions/${test_version}/terraform" ] || { + echo "terraform binary not found in fallback dir" >&2; + rm -rf "${fallback_home}"; + exit 1; + }; + rm -rf "${fallback_home}"; +) && log 'info' '## install_lock: non-existent config dir, non-writable parent, interactive fallback passed' \ + || error_and_proceed 'non-existent config dir inside non-writable parent (interactive y) did not install to ~/.tfenv'; + +############################################################################## +# Test 7: Lock cleanup on normal exit +############################################################################## +log 'info' '## install_lock: lock cleanup after successful install'; +cleanup || log 'error' 'Cleanup failed?!'; +( + tfenv install "${test_version}" || exit 1; + # Verify no install lock directories remain + declare lock_count; + lock_count="$(find "${TFENV_CONFIG_DIR}" -maxdepth 1 -name '.install-lock-*' -type d 2>/dev/null | wc -l)"; + [ "${lock_count}" -eq 0 ] || exit 1; +) && log 'info' '## install_lock: lock cleanup passed' \ + || error_and_proceed 'install lock directory was not cleaned up after successful install'; + +############################################################################## +# Test 8: Config dir path exists but is a FILE, not a directory +############################################################################## +log 'info' '## install_lock: config dir is a file, not a directory'; +cleanup || log 'error' 'Cleanup failed?!'; +( + declare tmpdir; + tmpdir="$(mktemp -d 2>/dev/null || mktemp -d -t 'tfenv_lock_notdir')"; + declare fakedir="${tmpdir}/fakedir"; + touch "${fakedir}"; + declare output; + output="$(TFENV_CONFIG_DIR="${fakedir}" tfenv install "${test_version}" < /dev/null 2>&1)"; + declare rc="${?}"; + rm -rf "${tmpdir}"; + [ "${rc}" -ne 0 ] || exit 1; + echo "${output}" | grep -q 'not a directory' || exit 1; +) && log 'info' '## install_lock: config dir is a file passed' \ + || error_and_proceed 'config dir that is a file did not fail with expected error'; + +############################################################################## +# Test 9: Ancestor in path is a file, not a directory +############################################################################## +log 'info' '## install_lock: ancestor in path is a file, not a directory'; +cleanup || log 'error' 'Cleanup failed?!'; +( + declare tmpdir; + tmpdir="$(mktemp -d 2>/dev/null || mktemp -d -t 'tfenv_lock_ancestor')"; + touch "${tmpdir}/blocker"; + declare output; + output="$(TFENV_CONFIG_DIR="${tmpdir}/blocker/deep/config" tfenv install "${test_version}" < /dev/null 2>&1)"; + declare rc="${?}"; + rm -rf "${tmpdir}"; + [ "${rc}" -ne 0 ] || exit 1; + echo "${output}" | grep -q 'ancestor' || exit 1; + echo "${output}" | grep -q 'not a directory' || exit 1; +) && log 'info' '## install_lock: ancestor is a file passed' \ + || error_and_proceed 'ancestor that is a file did not fail with expected error'; + +############################################################################## +# Test 10: Interactive fallback to ~/.tfenv fails when HOME is non-writable +############################################################################## +log 'info' '## install_lock: interactive fallback fails when HOME is non-writable'; +cleanup || log 'error' 'Cleanup failed?!'; +( + declare ro_config; + ro_config="$(mktemp -d 2>/dev/null || mktemp -d -t 'tfenv_lock_rocfg')"; + chmod 555 "${ro_config}"; + declare ro_home; + ro_home="$(mktemp -d 2>/dev/null || mktemp -d -t 'tfenv_lock_rohome')"; + chmod 555 "${ro_home}"; + declare output; + output="$(printf 'y\n' | TFENV_FORCE_INTERACTIVE=1 TFENV_CONFIG_DIR="${ro_config}" HOME="${ro_home}" "${TFENV_ROOT}/bin/tfenv" install "${test_version}" 2>&1)"; + declare rc="${?}"; + chmod 755 "${ro_config}"; + chmod 755 "${ro_home}"; + rm -rf "${ro_config}" "${ro_home}"; + [ "${rc}" -ne 0 ] || exit 1; + echo "${output}" | grep -q 'Failed to create' || exit 1; +) && log 'info' '## install_lock: interactive fallback fails with non-writable HOME passed' \ + || error_and_proceed 'interactive fallback with non-writable HOME did not fail with expected error'; + +############################################################################## +# Test 11: Non-existent config dir, non-writable parent, interactive decline +############################################################################## +log 'info' '## install_lock: non-existent config dir, non-writable parent, interactive decline'; +cleanup || log 'error' 'Cleanup failed?!'; +( + declare readonly_parent; + readonly_parent="$(mktemp -d 2>/dev/null || mktemp -d -t 'tfenv_lock_roprt3')"; + chmod 555 "${readonly_parent}"; + declare output; + output="$(printf 'n\n' | TFENV_FORCE_INTERACTIVE=1 TFENV_CONFIG_DIR="${readonly_parent}/tfenv-config" "${TFENV_ROOT}/bin/tfenv" install "${test_version}" 2>&1)"; + declare rc="${?}"; + chmod 755 "${readonly_parent}"; + rm -rf "${readonly_parent}"; + [ "${rc}" -ne 0 ] || exit 1; +) && log 'info' '## install_lock: non-writable parent, interactive decline passed' \ + || error_and_proceed 'non-existent config dir, non-writable parent, interactive decline did not fail'; + +############################################################################## +# Test 12: Lock cleanup on interrupted exit +# Skipped — reliably testing signal-handler cleanup (SIGINT/SIGTERM during +# install) is inherently racy and would produce flaky CI results. The +# cleanup_lock trap is validated by manual testing and code review. +############################################################################## +log 'info' '## install_lock: signal cleanup — SKIPPED (inherently racy, see comment)'; + +finish_tests 'install_lock'; +# vim: set syntax=bash tabstop=2 softtabstop=2 shiftwidth=2 expandtab smarttab :