diff --git a/install.sh b/install.sh index 3bc2ff4c88..f7d4baa19c 100755 --- a/install.sh +++ b/install.sh @@ -655,6 +655,15 @@ _apt_distro_description() { ) } +# ── Helper: can the controlling terminal actually be opened for reading? ── +# `test -r` only checks permission bits, which look fine in containers and +# systemd units where open() then fails with ENXIO. Probe with a real open. +# The subshell is required: in dash a failed redirection on the special +# builtin `:` exits the whole script. +_can_read_tty() { + ( : /dev/null 2>&1 +} + # ── Helper: install packages via apt, escalating to sudo only if needed ── # Usage: _smart_apt_install pkg1 pkg2 pkg3 ... _smart_apt_install() { @@ -695,24 +704,51 @@ _smart_apt_install() { echo " from your distro's official repositories (not a third-party tarball)." echo " !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" echo "" - printf " Accept? [Y/n] " - if [ -r /dev/tty ]; then - read -r REPLY /dev/null +} + +# $1 tty: "tty" | "notty" | "unopenable" +# $2 sudo: "nopasswd" | "needspasswd" | "aptneedspasswd" | "cached" | "absent" +run_smart() { + _tty_mode="$1"; _sudo_mode="$2" + _d=$(mktemp -d -p "$_TMP_ROOT") + case "$_tty_mode" in + tty) printf 'y\n' > "$_d/tty" ;; + # Opens fine but reads EOF straight away (drained/half-closed + # terminal): openable is not the same as answerable. + eof) : > "$_d/tty" ;; + unopenable) make_unopenable "$_d/tty" ;; + esac + + _f=$(mktemp -p "$_TMP_ROOT") + sed -n -e '/^_can_read_tty()/,/^}/p' \ + -e '/^_smart_apt_install()/,/^}/p' "$INSTALL_SH" \ + | sed -e "s#/dev/tty#$_d/tty#g" > "$_f" + + ( + TAURI_MODE=false + _apt_distro_description() { echo "TestOS 1.0 (debian-like)"; } + _is_pkg_installed() { return 1; } # nothing ever installs + apt-get() { return 1; } # unprivileged attempt fails + command() { + if [ "$1" = -v ] && [ "$2" = sudo ]; then + [ "$_sudo_mode" != absent ]; return $? + fi + builtin command "$@" + } + # Models real sudo: -n refuses (exit 1, nothing runs) when a password + # would be needed. -k ignores any cached timestamp for this invocation + # (sudo(8)), so only a real NOPASSWD rule counts as passwordless. + sudo() { + _noninteractive=false + _ignore_cache=false + while :; do + case "$1" in + -n) _noninteractive=true; shift ;; + -k) _ignore_cache=true; shift ;; + *) break ;; + esac + done + if [ "$_noninteractive" = true ]; then + case "$_sudo_mode" in + nopasswd) ;; + # A valid timestamp from an earlier, unrelated sudo. Without + # -k this looks passwordless; with -k it must not. + cached) [ "$_ignore_cache" = true ] && return 1 ;; + # Authorized for everything, NOPASSWD only on trivial + # commands: `sudo -l` says yes while execution still needs + # a password. Authorization is not the question to ask. + aptneedspasswd) + case " $* " in + *" apt-get "*) return 1 ;; + esac + ;; + *) return 1 ;; + esac + fi + echo "SUDO_RAN: $*" + } + # shellcheck disable=SC1090 + . "$_f" + _smart_apt_install cmake 2>&1 + echo "EXIT:$?" + ) || true +} + +_out=$(run_smart notty needspasswd) +assert_contains "no tty + password sudo: says it cannot run unattended" \ + "$_out" "cannot be done unattended" +assert_contains "no tty + password sudo: gives the manual command" \ + "$_out" "sudo apt-get update -y && sudo apt-get install -y cmake" +assert_contains "no tty + password sudo: names the distro" \ + "$_out" "TestOS 1.0 (debian-like)" +case "$_out" in + *SUDO_RAN*) echo " FAIL: no tty + password sudo must not run apt-get as root"; FAIL=$((FAIL + 1)) ;; + *) echo " PASS: no tty + password sudo runs nothing as root"; PASS=$((PASS + 1)) ;; +esac +case "$_out" in + *"Accept? [Y/n]"*) echo " FAIL: must not print an unanswerable prompt"; FAIL=$((FAIL + 1)) ;; + *) echo " PASS: no dangling Accept? prompt without a tty"; PASS=$((PASS + 1)) ;; +esac + +# Passwordless sudo is the one case where unattended escalation is legitimate. +_out=$(run_smart notty nopasswd) +assert_contains "no tty + passwordless sudo: still installs" "$_out" "SUDO_RAN: apt-get install -y cmake" +assert_contains "no tty + passwordless sudo: says why it proceeded" \ + "$_out" "passwordless sudo" + +# A readable tty must behave exactly as before: prompt, then honour the answer. +_out=$(run_smart tty needspasswd) +assert_contains "tty present: still prompts" "$_out" "Accept? [Y/n]" +assert_contains "tty present: accepts and installs" "$_out" "SUDO_RAN: apt-get install -y cmake" + +# No sudo at all keeps its own message. +_out=$(run_smart notty absent) +assert_contains "no sudo binary: unchanged message" "$_out" "sudo is not available on this system" + +# A /dev/tty that passes `test -r` but cannot be opened counts as no tty. +# Only assert where the platform can actually produce that shape. +_probe=$(mktemp -d -p "$_TMP_ROOT") +if make_unopenable "$_probe/tty" && [ -r "$_probe/tty" ] && ! ( : <"$_probe/tty" ) 2>/dev/null; then + _out=$(run_smart unopenable needspasswd) + assert_contains "unopenable tty: treated as no tty" "$_out" "cannot be done unattended" + case "$_out" in + *"Accept? [Y/n]"*) echo " FAIL: unopenable tty must not print a prompt"; FAIL=$((FAIL + 1)) ;; + *) echo " PASS: unopenable tty prints no prompt"; PASS=$((PASS + 1)) ;; + esac +else + echo " SKIP: this platform cannot fake a readable-but-unopenable /dev/tty" +fi + +# A tty that opens but yields EOF must decline: a failed read is nobody +# answering, and calling that "yes" escalates through the branch that does +# have a terminal. +_out=$(run_smart eof needspasswd) +assert_contains "eof tty: declines instead of escalating" \ + "$_out" "Please install these packages first" +case "$_out" in + *SUDO_RAN*) echo " FAIL: eof tty must not escalate"; FAIL=$((FAIL + 1)) ;; + *) echo " PASS: eof tty runs nothing as root"; PASS=$((PASS + 1)) ;; +esac + +# A cached timestamp from an earlier, unrelated sudo must not count as +# passwordless: nobody answered this run's prompt and the apt-get rule still +# carries PASSWD. Asserts the -k is present and effective. +_out=$(run_smart notty cached) +assert_contains "cached credentials: says it cannot run unattended" \ + "$_out" "cannot be done unattended" +case "$_out" in + *SUDO_RAN*) echo " FAIL: a cached timestamp must not authorise unattended install"; FAIL=$((FAIL + 1)) ;; + *) echo " PASS: cached credentials run nothing as root"; PASS=$((PASS + 1)) ;; +esac + +# The failure message must not blame a password when apt itself failed: sudo +# passes the command's own exit status through when the command runs. +assert_contains "failure message does not blame a password exclusively" \ + "$_out" "or apt-get itself" + +# Authorized for apt-get but not NOPASSWD on it. Both `sudo -n true` and +# `sudo -n -l -- apt-get ...` read this as unattended, since list mode answers +# authorization, not authentication. Only running it with -n is truthful. +_out=$(run_smart notty aptneedspasswd) +assert_contains "apt-get needs a password: says it cannot run unattended" \ + "$_out" "cannot be done unattended" +case "$_out" in + *SUDO_RAN*) echo " FAIL: apt-get needing a password must not run as root"; FAIL=$((FAIL + 1)) ;; + *) echo " PASS: apt-get needing a password runs nothing as root"; PASS=$((PASS + 1)) ;; +esac + echo "" echo "Results: $PASS passed, $FAIL failed" [ "$FAIL" -eq 0 ]