diff --git a/README.md b/README.md index 5a02d13..3a914a2 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,8 @@ sudo iptables-forward /usr/local/bin/iptables-forward -> /path/to/IPTables-Management/iptables-forward.sh ``` +若目标路径已存在且不是当前仓库脚本的符号链接,`install.sh` 会拒绝覆盖;`--uninstall` 也只会删除当前脚本创建的链接,避免误删其它文件。 + ## 使用说明 ### 交互模式 @@ -148,6 +150,7 @@ tests/run_all.sh --skip-integration 测试覆盖: - `tests/test_cli.sh`:入口脚本与安装脚本帮助输出 +- `tests/test_install.sh`:安装脚本的 install/uninstall 实际行为与误覆盖保护 - `tests/test_interactive.sh`:交互主菜单的 add/list/delete/save 回归 - `tests/test_common.sh`:输入校验 - `tests/test_storage.sh`:规则存储 diff --git a/install.sh b/install.sh index 2281a97..61f93a8 100644 --- a/install.sh +++ b/install.sh @@ -25,21 +25,38 @@ require_root() { fi } +target_is_current_link() { + [[ -L ${TARGET_BIN} ]] || return 1 + [[ $(readlink -- "${TARGET_BIN}") == "${SOURCE_SCRIPT}" ]] +} + install_link() { [[ -f ${SOURCE_SCRIPT} ]] || { printf '未找到入口脚本: %s\n' "${SOURCE_SCRIPT}" >&2 exit 1 } mkdir -p "$(dirname -- "${TARGET_BIN}")" - ln -sfn "${SOURCE_SCRIPT}" "${TARGET_BIN}" + if [[ -e ${TARGET_BIN} || -L ${TARGET_BIN} ]]; then + if target_is_current_link; then + chmod 755 "${SOURCE_SCRIPT}" + printf '链接已存在: %s -> %s\n' "${TARGET_BIN}" "${SOURCE_SCRIPT}" + return 0 + fi + printf '目标已存在且不是当前脚本的链接,拒绝覆盖: %s\n' "${TARGET_BIN}" >&2 + exit 1 + fi + ln -s "${SOURCE_SCRIPT}" "${TARGET_BIN}" chmod 755 "${SOURCE_SCRIPT}" printf '已创建链接: %s -> %s\n' "${TARGET_BIN}" "${SOURCE_SCRIPT}" } uninstall_link() { - if [[ -L ${TARGET_BIN} || -e ${TARGET_BIN} ]]; then + if target_is_current_link; then rm -f "${TARGET_BIN}" printf '已删除链接: %s\n' "${TARGET_BIN}" + elif [[ -L ${TARGET_BIN} || -e ${TARGET_BIN} ]]; then + printf '目标不是当前脚本创建的链接,拒绝删除: %s\n' "${TARGET_BIN}" >&2 + exit 1 else printf '未发现已安装链接: %s\n' "${TARGET_BIN}" fi diff --git a/tests/run_all.sh b/tests/run_all.sh index 21495f6..4048840 100644 --- a/tests/run_all.sh +++ b/tests/run_all.sh @@ -24,6 +24,7 @@ run_test() { run_test "${ROOT_DIR}/tests/test_common.sh" run_test "${ROOT_DIR}/tests/test_cli.sh" +run_test "${ROOT_DIR}/tests/test_install.sh" run_test "${ROOT_DIR}/tests/test_interactive.sh" run_test "${ROOT_DIR}/tests/test_storage.sh" run_test "${ROOT_DIR}/tests/test_env_check.sh" diff --git a/tests/test_install.sh b/tests/test_install.sh new file mode 100644 index 0000000..3f8c721 --- /dev/null +++ b/tests/test_install.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +# shellcheck source=tests/lib/assert.sh +source "${ROOT_DIR}/tests/lib/assert.sh" + +maybe_enter_namespace() { + if (( EUID == 0 )); then + return 0 + fi + + if [[ ${IPF_IN_NAMESPACE:-0} == 1 ]]; then + return 0 + fi + + if command -v unshare >/dev/null 2>&1 && unshare -Ur true >/dev/null 2>&1; then + exec unshare -Ur env IPF_IN_NAMESPACE=1 bash "$0" + fi + + printf 'SKIP: 安装测试需要 root 或可用的 unshare。\n' + exit 0 +} + +status_of() { + set +e + "$@" >/dev/null 2>&1 + local rc=$? + set -e + printf '%s\n' "${rc}" +} + +maybe_enter_namespace + +TMP_DIR=$(mktemp -d) +trap 'rm -rf "${TMP_DIR}"' EXIT + +TARGET_BIN="${TMP_DIR}/bin/iptables-forward" +OTHER_TARGET="${TMP_DIR}/bin/not-ours" +SOURCE_SCRIPT="${ROOT_DIR}/iptables-forward.sh" + +install_output=$(INSTALL_TARGET="${TARGET_BIN}" "${ROOT_DIR}/install.sh") +assert_contains "${install_output}" '已创建链接:' 'install should create symlink on first run' +[[ -L ${TARGET_BIN} ]] || fail 'install should create a symlink' +assert_eq "${SOURCE_SCRIPT}" "$(readlink -- "${TARGET_BIN}")" 'install should link to project entry script' + +install_output=$(INSTALL_TARGET="${TARGET_BIN}" "${ROOT_DIR}/install.sh") +assert_contains "${install_output}" '链接已存在:' 'install should be idempotent for existing project link' + +printf 'keep-me\n' >"${OTHER_TARGET}" +assert_status '1' "$(status_of env INSTALL_TARGET="${OTHER_TARGET}" "${ROOT_DIR}/install.sh")" 'install should refuse to overwrite non-project target' +assert_eq 'keep-me' "$(cat "${OTHER_TARGET}")" 'install refusal should keep existing target untouched' + +uninstall_output=$(INSTALL_TARGET="${TARGET_BIN}" "${ROOT_DIR}/install.sh" --uninstall) +assert_contains "${uninstall_output}" '已删除链接:' 'uninstall should remove project symlink' +[[ ! -e ${TARGET_BIN} && ! -L ${TARGET_BIN} ]] || fail 'uninstall should remove installed symlink' + +assert_status '1' "$(status_of env INSTALL_TARGET="${OTHER_TARGET}" "${ROOT_DIR}/install.sh" --uninstall)" 'uninstall should refuse to delete non-project target' +assert_eq 'keep-me' "$(cat "${OTHER_TARGET}")" 'uninstall refusal should keep existing target untouched' + +pass 'test_install.sh'