diff --git a/linux_setup.sh b/linux_setup.sh index 8a9e35b..26d7e50 100755 --- a/linux_setup.sh +++ b/linux_setup.sh @@ -36,6 +36,7 @@ readonly CACHE_MARKER_NAME=".linux-setup-managed-cache-v1" readonly LINE="============================================================" TEMP_DIR="" +TEMP_DIR_IDENTITY="" PREPARED_SCRIPT="" TOTAL_START_TIME=0 @@ -153,15 +154,35 @@ backup_initial_and_previous() { atomic_copy_file "$file" "$previous" } +temp_directory_trusted() { + local identity + [[ -n "$TEMP_DIR" && -n "$TEMP_DIR_IDENTITY" && -d "$TEMP_DIR" && ! -L "$TEMP_DIR" ]] || return 1 + identity=$(stat -c '%d:%i:%u:%a' -- "$TEMP_DIR" 2>/dev/null) || return 1 + [[ "$identity" == "$TEMP_DIR_IDENTITY" && "$identity" == *":$EUID:700" ]] +} + +remove_owned_temp_directory() { + [[ -n "$TEMP_DIR" ]] || return 0 + temp_directory_trusted || { + log "临时目录身份不可验证,拒绝删除:$TEMP_DIR" "error" + return 1 + } + # /tmp is sticky; mode 0700 excludes other users from the owned directory. + # Never authorize recursive removal from a caller-supplied path alone. + rm -rf -- "$TEMP_DIR" || return 1 + [[ ! -e "$TEMP_DIR" && ! -L "$TEMP_DIR" ]] || return 1 + TEMP_DIR="" + TEMP_DIR_IDENTITY="" +} + cleanup() { local exit_code=$? - if [[ -n "$TEMP_DIR" && -d "$TEMP_DIR" ]]; then + if [[ -n "$TEMP_DIR" ]]; then if (( exit_code == 0 )); then - rm -rf "$TEMP_DIR" 2>/dev/null || true + remove_owned_temp_directory || exit_code=1 else log "脚本异常退出,临时文件保留在:$TEMP_DIR" "error" - log "调试完成后可手动删除:rm -rf $TEMP_DIR" "warn" log "详细日志:$LOG_FILE" "error" fi fi @@ -186,7 +207,9 @@ create_temp_dir() { exit 1 fi - chmod 700 "$TEMP_DIR" + chmod 700 "$TEMP_DIR" || return 1 + TEMP_DIR_IDENTITY=$(stat -c '%d:%i:%u:%a' -- "$TEMP_DIR") || return 1 + temp_directory_trusted } # ============================================================================= @@ -1129,12 +1152,44 @@ prepare_main_script() { } exec_prepared_script() { - local commit="$1" + local commit="$1" script_fd identity fd_identity status=0 execfail_was_set=false is_valid_commit "$commit" || return 1 + temp_directory_trusted || return 1 validate_bash_script "$PREPARED_SCRIPT" || return 1 + if [[ "$PREPARED_SCRIPT" == "$CACHE_DIR/linux_setup_${commit}.sh" ]]; then + validate_cached_script "$PREPARED_SCRIPT" "$commit" || return 1 + else + [[ "$PREPARED_SCRIPT" == "$TEMP_DIR/"* ]] || return 1 + file_owner_and_mode_are_safe "$PREPARED_SCRIPT" || return 1 + fi + identity=$(stat -Lc '%d:%i:%u:%a:%s' -- "$PREPARED_SCRIPT") || return 1 + exec {script_fd}<"$PREPARED_SCRIPT" || return 1 + fd_identity=$(stat -Lc '%d:%i:%u:%a:%s' -- "/proc/self/fd/$script_fd") || { + exec {script_fd}<&-; return 1; + } + if [[ "$identity" != "$fd_identity" || -L "$PREPARED_SCRIPT" ]] || + [[ "$(stat -Lc '%d:%i:%u:%a:%s' -- "$PREPARED_SCRIPT")" != "$identity" ]]; then + exec {script_fd}<&- + return 1 + fi + # Pin the validated inode before removing the old runtime. The descriptor + # survives exec, including when the fallback script pathname is unlinked. + # The replacement gets only its script FD, never ownership of an old path. + if ! remove_owned_temp_directory; then + exec {script_fd}<&- + log "无法清理旧临时目录,拒绝重新启动" "error" + return 1 + fi log "正在使用固定 Commit 重新启动主脚本:${commit:0:7}" "success" - exec env RUN_COMMIT="$commit" bash "$PREPARED_SCRIPT" + shopt -q execfail && execfail_was_set=true + shopt -s execfail + # shellcheck disable=SC2093 # execfail returns only on exec failure; close the pinned FD and propagate status. + exec env RUN_COMMIT="$commit" bash "/proc/self/fd/$script_fd" + status=$? + exec {script_fd}<&- + [[ "$execfail_was_set" == true ]] || shopt -u execfail + return "$status" } self_update() { @@ -1165,6 +1220,7 @@ self_update() { if [[ -z "$RUN_COMMIT" ]]; then log "当前主脚本无法证明版本一致性,必须切换到固定 Commit" "warn" exec_prepared_script "$latest_commit" + return $? fi echo @@ -1606,11 +1662,11 @@ main() { fi init_logging - create_temp_dir - trap cleanup EXIT + trap 'exit 129' HUP trap 'exit 130' INT trap 'exit 143' TERM + create_temp_dir || return 1 TOTAL_START_TIME=$(date +%s) diff --git a/tests/run.sh b/tests/run.sh index 4c76084..8d97553 100644 --- a/tests/run.sh +++ b/tests/run.sh @@ -18,7 +18,8 @@ if [[ -n "${TEST_BASE_SHA:-}" && -n "${TEST_HEAD_SHA:-}" ]]; then tools/push.sh) selected[test-push.sh]=1; selected[test-push-worker-registration.sh]=1 ;; tools/cloudflare_tunnel.sh) selected[test-cloudflare-tunnel.sh]=1 ;; modules/ssh-security.sh) selected[test-ssh-security.sh]=1 ;; - linux_setup.sh|modules/zsh-setup.sh|p10k-config.zsh) selected[test-linux-setup.sh]=1 ;; + linux_setup.sh) selected[test-linux-setup.sh]=1; selected[test-setup-exec.sh]=1 ;; + modules/zsh-setup.sh|p10k-config.zsh) selected[test-linux-setup.sh]=1 ;; tests/test-*.sh) if [[ -f "$ROOT_DIR/$path" ]]; then selected["${path##*/}"]=1; else full=true; fi ;; README.md|LICENSE) ;; diff --git a/tests/test-selection.sh b/tests/test-selection.sh index 70f375c..591b5cc 100644 --- a/tests/test-selection.sh +++ b/tests/test-selection.sh @@ -6,7 +6,7 @@ trap 'rm -rf "$TEMP_DIR"' EXIT mkdir -p "$TEMP_DIR/tests" "$TEMP_DIR/bin" cp "$ROOT_DIR/tests/run.sh" "$TEMP_DIR/tests/run.sh" # Only stub suites run here; never invoke production scripts or the real suite. -for name in cloudflare-tunnel linux-setup motd push-worker-registration push ssh-security xanmod; do +for name in cloudflare-tunnel linux-setup motd push-worker-registration push setup-exec ssh-security xanmod; do printf '#!/usr/bin/env bash\nset -euo pipefail\nprintf "%%s\\n" "%s" >> "$CAPTURE"\n' "test-$name.sh" > "$TEMP_DIR/tests/test-$name.sh" done cat > "$TEMP_DIR/bin/git" <<'STUB' @@ -21,7 +21,7 @@ export PATH="$TEMP_DIR/bin:$PATH" export CAPTURE="$TEMP_DIR/capture" CHANGES="$TEMP_DIR/changes" export GITHUB_STEP_SUMMARY="$TEMP_DIR/summary" export TEST_BASE_SHA=base TEST_HEAD_SHA=head DIFF_FAIL=0 -all=$(printf 'test-%s.sh\n' cloudflare-tunnel linux-setup motd push-worker-registration push ssh-security xanmod) +all=$(printf 'test-%s.sh\n' cloudflare-tunnel linux-setup motd push-worker-registration push setup-exec ssh-security xanmod) check() { local expected=$1 actual shift @@ -36,7 +36,8 @@ check $'test-motd.sh\ntest-xanmod.sh' modules/system-customize.sh check $'test-push-worker-registration.sh\ntest-push.sh' tools/push.sh check test-cloudflare-tunnel.sh tools/cloudflare_tunnel.sh check test-ssh-security.sh modules/ssh-security.sh -check test-linux-setup.sh modules/zsh-setup.sh p10k-config.zsh linux_setup.sh +check test-linux-setup.sh modules/zsh-setup.sh p10k-config.zsh +check $'test-linux-setup.sh\ntest-setup-exec.sh' linux_setup.sh check test-xanmod.sh tools/xanmod-install.sh check $'test-motd.sh\ntest-ssh-security.sh' tools/setup-motd.sh modules/ssh-security.sh check test-motd.sh tests/test-motd.sh diff --git a/tests/test-setup-exec.sh b/tests/test-setup-exec.sh new file mode 100644 index 0000000..b44ff84 --- /dev/null +++ b/tests/test-setup-exec.sh @@ -0,0 +1,166 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +root=$(mktemp -d) +trap 'rm -rf -- "$root"' EXIT +unset GH_TOKEN GITHUB_TOKEN SSH_PRIVATE_KEY_B64 +export ROOT_DIR LINUX_SETUP_TEST_MODE=1 LINUX_SETUP_LOG_FILE="$root/log" +export FIXTURE_ROOT="$root" COMMIT=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +cat > "$root/replacement.sh" <<'CHILD' +#!/usr/bin/env bash +set -eu +[[ "$RUN_COMMIT" == "$COMMIT" && ! -e "$OLD_TEMP" ]] +# The replacement can read its unlinked script through its inherited FD. +[[ -r "$0" ]] +printf '%s\n' "$0" > "$CASE_ROOT/executed" +mkdir "$CASE_ROOT/new-runtime" +printf keep > "$CASE_ROOT/new-runtime/sentinel" +exit 0 +CHILD +cat > "$root/driver.sh" <<'DRIVER' +#!/usr/bin/env bash +set -uo pipefail +source "$ROOT_DIR/linux_setup.sh" +# Keep allocations inside the inert fixture, exercising the real allocator. +mktemp() { + if [[ ${1:-} == -d && ${2:-} == -p && ${3:-} == /tmp ]]; then + command mktemp -d -p "$CASE_ROOT" "${4}" + else + command mktemp "$@" + fi +} +trap cleanup EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM +create_temp_dir || exit 90 +export OLD_TEMP="$TEMP_DIR" +printf '%s\n' "$TEMP_DIR" > "$CASE_ROOT/old-path" +if [[ "$CASE_MODE" == cache ]]; then + write_cached_script_atomically "$FIXTURE_ROOT/replacement.sh" "$COMMIT" || exit 91 +else + cp "$FIXTURE_ROOT/replacement.sh" "$TEMP_DIR/replacement.sh" + chmod 700 "$TEMP_DIR/replacement.sh" + PREPARED_SCRIPT="$TEMP_DIR/replacement.sh" +fi +case "$CASE_MODE" in + remove-fail) rm() { return 1; } ;; + replace-dir) + mv "$TEMP_DIR" "$CASE_ROOT/original" + mkdir -m 700 "$TEMP_DIR"; printf keep > "$TEMP_DIR/sentinel" + ;; + symlink-dir) + mv "$TEMP_DIR" "$CASE_ROOT/original" + mkdir "$CASE_ROOT/foreign"; printf keep > "$CASE_ROOT/foreign/sentinel" + ln -s "$CASE_ROOT/foreign" "$TEMP_DIR" + ;; + exec-fail) + # Real exec failure: remove PATH only after inode validation/cleanup. + log() { if [[ "$1" == *重新启动* ]]; then PATH=/nonexistent; fi; } + ;; + HUP|INT|TERM) + kill -s "$CASE_MODE" "$BASHPID" + exit 92 + ;; +esac +exec_prepared_script "$COMMIT" +status=$? +printf '%s\n' "$status" > "$CASE_ROOT/returned" +exit "$status" +DRIVER +for mode in cache fallback remove-fail replace-dir symlink-dir exec-fail HUP INT TERM; do + case_root="$root/$mode"; mkdir "$case_root" + rc=0 + env --default-signal=HUP,INT,TERM -u RUN_COMMIT CASE_MODE="$mode" CASE_ROOT="$case_root" \ + LINUX_SETUP_CACHE_DIR="$case_root/linux-setup" \ + timeout --signal=TERM --kill-after=1s 10s bash "$root/driver.sh" > "$case_root/output" 2>&1 || rc=$? + old=$(cat "$case_root/old-path") + case "$mode" in + cache|fallback) + [[ $rc == 0 && ! -e "$old" && -f "$case_root/executed" && -f "$case_root/new-runtime/sentinel" ]] || { cat "$case_root/output"; exit 1; } + [[ ! -e "$case_root/returned" ]] + ;; + exec-fail) + [[ $rc == 127 && ! -e "$old" && ! -e "$case_root/executed" && $(cat "$case_root/returned") == 127 ]] || { cat "$case_root/output"; exit 1; } + ;; + remove-fail) + [[ $rc == 1 && -d "$old" && ! -e "$case_root/executed" ]] ;; + replace-dir) + [[ $rc == 1 && -f "$old/sentinel" && -f "$case_root/original/replacement.sh" ]] ;; + symlink-dir) + [[ $rc == 1 && -L "$old" && -f "$case_root/foreign/sentinel" ]] ;; + HUP) [[ $rc == 129 && -d "$old" ]] ;; + INT) [[ $rc == 130 && -d "$old" ]] ;; + TERM) [[ $rc == 143 && -d "$old" ]] ;; + esac + printf 'PASS: prepared exec lifecycle %s exit=%s\n' "$mode" "$rc" +done +# Exercise actual main -> self_update -> prepare_main_script -> exec path. +# Only network and production system steps are replaced; no function wrapping. +for source_mode in cache download; do + case_root="$root/main-$source_mode"; mkdir "$case_root" + env -u RUN_COMMIT CASE_ROOT="$case_root" SOURCE_MODE="$source_mode" \ + LINUX_SETUP_CACHE_DIR="$case_root/linux-setup" \ + timeout --signal=TERM --kill-after=1s 10s bash -c ' + source "$ROOT_DIR/linux_setup.sh" + mktemp() { + if [[ ${1:-} == -d && ${2:-} == -p && ${3:-} == /tmp ]]; then + command mktemp -d -p "$CASE_ROOT" "${4}" + else command mktemp "$@"; fi + } + get_latest_commit() { printf "%s\n" "$COMMIT"; } + pre_check() { exit 93; } + clear() { :; } + download_with_retry() { + cp "$FIXTURE_ROOT/replacement.sh" "$2" || return 1 + export OLD_TEMP="$TEMP_DIR" + } + if [[ "$SOURCE_MODE" == cache ]]; then + write_cached_script_atomically "$FIXTURE_ROOT/replacement.sh" "$COMMIT" || exit 94 + # No TEMP_DIR is known before main allocates it: replacement checks + # the fixture for leaked allocator paths directly in this case. + export OLD_TEMP="$CASE_ROOT/nonexistent" + else + prepare_cache_dir() { return 1; } + fi + main + ' > "$case_root/output" 2>&1 || { cat "$case_root/output"; exit 1; } + [[ -f "$case_root/executed" ]] + [[ -z $(find "$case_root" -maxdepth 1 -name 'linux-setup.*' -print -quit) ]] + printf 'PASS: actual main self-update %s path\n' "$source_mode" +done +# A bounded counterexample demonstrates why the old successful exec leaked. +case_root="$root/old-exec"; mkdir "$case_root" +env CASE_ROOT="$case_root" timeout 5s bash -c ' + old=$(mktemp -d "$CASE_ROOT/linux-setup.XXXXXX") + printf "%s\n" "$old" > "$CASE_ROOT/path" + trap '\''rm -rf -- "$old"'\'' EXIT + exec bash -c '\''exit 0'\'' +' +[[ -d $(cat "$case_root/path") ]] +printf 'PASS: old successful exec bypasses EXIT cleanup counterexample\n' +# Existing fixed versions exercise both the consent and refusal branches. +for choice in y n; do + case_root="$root/update-$choice"; mkdir "$case_root" + env CASE_ROOT="$case_root" CHOICE="$choice" RUN_COMMIT=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb \ + LINUX_SETUP_CACHE_DIR="$case_root/linux-setup" timeout 10s bash -c ' + source "$ROOT_DIR/linux_setup.sh" + mktemp() { + if [[ ${1:-} == -d && ${2:-} == -p && ${3:-} == /tmp ]]; then + command mktemp -d -p "$CASE_ROOT" "${4}" + else command mktemp "$@"; fi + } + create_temp_dir || exit 90 + trap cleanup EXIT + export OLD_TEMP="$TEMP_DIR" + get_latest_commit() { printf "%s\n" "$COMMIT"; } + write_cached_script_atomically "$FIXTURE_ROOT/replacement.sh" "$COMMIT" || exit 91 + self_update <<< "$CHOICE" + status=$? + [[ "$CHOICE" == n && $status == 0 && -d "$TEMP_DIR" ]] || exit 92 + printf retained > "$CASE_ROOT/refused" + ' > "$case_root/output" 2>&1 || { cat "$case_root/output"; exit 1; } + [[ -z $(find "$case_root" -maxdepth 1 -name 'linux-setup.*' -print -quit) ]] + if [[ $choice == y ]]; then [[ -f "$case_root/executed" ]]; else [[ -f "$case_root/refused" && ! -e "$case_root/executed" ]]; fi + printf 'PASS: fixed-version self-update choice=%s\n' "$choice" +done