Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 64 additions & 8 deletions linux_setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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
}

# =============================================================================
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -1165,6 +1220,7 @@ self_update() {
if [[ -z "$RUN_COMMIT" ]]; then
log "当前主脚本无法证明版本一致性,必须切换到固定 Commit" "warn"
exec_prepared_script "$latest_commit"
return $?
fi

echo
Expand Down Expand Up @@ -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)

Expand Down
3 changes: 2 additions & 1 deletion tests/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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) ;;
Expand Down
7 changes: 4 additions & 3 deletions tests/test-selection.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Expand All @@ -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
Expand Down
166 changes: 166 additions & 0 deletions tests/test-setup-exec.sh
Original file line number Diff line number Diff line change
@@ -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