diff --git a/internal/recipes/bundled/python-dev/install-alpine.sh b/internal/recipes/bundled/python-dev/install-alpine.sh index 34d4d79d..0f46ac9b 100755 --- a/internal/recipes/bundled/python-dev/install-alpine.sh +++ b/internal/recipes/bundled/python-dev/install-alpine.sh @@ -12,16 +12,34 @@ fi stoat_pkg_setup stoat_pkg_install python3 py3-pip +# busybox su recognises options anywhere, so a -p among the trailing arguments +# becomes su's own preserve-environment flag and never reaches the command. +# Quote the argument list into -c instead of passing it positionally. +su_user() { + quoted= + for arg do + quoted="$quoted'$(printf '%s' "$arg" | sed "s/'/'\\\\''/g")' " + done + su -s /bin/sh "$user" -c "exec $quoted" +} + +# Alpine's sudoers file grants root nothing, so sudo is on PATH and refuses +# every command. Probe each tool once and keep the first that runs. as_user() { - if command -v runuser >/dev/null 2>&1; then - runuser -u "$user" -- "$@" - elif command -v sudo >/dev/null 2>&1; then - sudo -n -u "$user" -- "$@" - else - command=$1 - shift - su -s /bin/sh "$user" -c 'exec "$@"' stoat "$command" "$@" + if [ -z "${as_user_tool:-}" ]; then + if command -v runuser >/dev/null 2>&1 && runuser -u "$user" -- true 2>/dev/null; then + as_user_tool=runuser + elif command -v sudo >/dev/null 2>&1 && sudo -n -u "$user" -- true 2>/dev/null; then + as_user_tool=sudo + else + as_user_tool=su + fi fi + case "$as_user_tool" in + runuser) runuser -u "$user" -- "$@" ;; + sudo) sudo -n -u "$user" -- "$@" ;; + *) su_user "$@" ;; + esac } python_bin=$(command -v python3 2>/dev/null || command -v python 2>/dev/null || true) diff --git a/internal/recipes/bundled/python-dev/install-arch.sh b/internal/recipes/bundled/python-dev/install-arch.sh index 08707c0d..b6c55dfe 100755 --- a/internal/recipes/bundled/python-dev/install-arch.sh +++ b/internal/recipes/bundled/python-dev/install-arch.sh @@ -12,16 +12,34 @@ fi stoat_pkg_setup stoat_pkg_install python python-pip +# busybox su recognises options anywhere, so a -p among the trailing arguments +# becomes su's own preserve-environment flag and never reaches the command. +# Quote the argument list into -c instead of passing it positionally. +su_user() { + quoted= + for arg do + quoted="$quoted'$(printf '%s' "$arg" | sed "s/'/'\\\\''/g")' " + done + su -s /bin/sh "$user" -c "exec $quoted" +} + +# A sudo binary on PATH is not a working sudo: Alpine's sudoers grants root +# nothing. Probe each tool once and keep the first that runs. as_user() { - if command -v runuser >/dev/null 2>&1; then - runuser -u "$user" -- "$@" - elif command -v sudo >/dev/null 2>&1; then - sudo -n -u "$user" -- "$@" - else - command=$1 - shift - su -s /bin/sh "$user" -c 'exec "$@"' stoat "$command" "$@" + if [ -z "${as_user_tool:-}" ]; then + if command -v runuser >/dev/null 2>&1 && runuser -u "$user" -- true 2>/dev/null; then + as_user_tool=runuser + elif command -v sudo >/dev/null 2>&1 && sudo -n -u "$user" -- true 2>/dev/null; then + as_user_tool=sudo + else + as_user_tool=su + fi fi + case "$as_user_tool" in + runuser) runuser -u "$user" -- "$@" ;; + sudo) sudo -n -u "$user" -- "$@" ;; + *) su_user "$@" ;; + esac } python_bin=$(command -v python3 2>/dev/null || command -v python 2>/dev/null || true) diff --git a/internal/recipes/bundled/python-dev/install-debian.sh b/internal/recipes/bundled/python-dev/install-debian.sh index 3e1b30aa..195b0523 100755 --- a/internal/recipes/bundled/python-dev/install-debian.sh +++ b/internal/recipes/bundled/python-dev/install-debian.sh @@ -12,16 +12,34 @@ fi stoat_pkg_setup stoat_pkg_install python3 python3-pip python3-venv +# busybox su recognises options anywhere, so a -p among the trailing arguments +# becomes su's own preserve-environment flag and never reaches the command. +# Quote the argument list into -c instead of passing it positionally. +su_user() { + quoted= + for arg do + quoted="$quoted'$(printf '%s' "$arg" | sed "s/'/'\\\\''/g")' " + done + su -s /bin/sh "$user" -c "exec $quoted" +} + +# A sudo binary on PATH is not a working sudo: Alpine's sudoers grants root +# nothing. Probe each tool once and keep the first that runs. as_user() { - if command -v runuser >/dev/null 2>&1; then - runuser -u "$user" -- "$@" - elif command -v sudo >/dev/null 2>&1; then - sudo -n -u "$user" -- "$@" - else - command=$1 - shift - su -s /bin/sh "$user" -c 'exec "$@"' stoat "$command" "$@" + if [ -z "${as_user_tool:-}" ]; then + if command -v runuser >/dev/null 2>&1 && runuser -u "$user" -- true 2>/dev/null; then + as_user_tool=runuser + elif command -v sudo >/dev/null 2>&1 && sudo -n -u "$user" -- true 2>/dev/null; then + as_user_tool=sudo + else + as_user_tool=su + fi fi + case "$as_user_tool" in + runuser) runuser -u "$user" -- "$@" ;; + sudo) sudo -n -u "$user" -- "$@" ;; + *) su_user "$@" ;; + esac } python_bin=$(command -v python3 2>/dev/null || command -v python 2>/dev/null || true) diff --git a/internal/recipes/bundled/python-dev/install-rpm.sh b/internal/recipes/bundled/python-dev/install-rpm.sh index 679b2083..511dbbaf 100755 --- a/internal/recipes/bundled/python-dev/install-rpm.sh +++ b/internal/recipes/bundled/python-dev/install-rpm.sh @@ -12,16 +12,34 @@ fi stoat_pkg_setup stoat_pkg_install python3 python3-pip +# busybox su recognises options anywhere, so a -p among the trailing arguments +# becomes su's own preserve-environment flag and never reaches the command. +# Quote the argument list into -c instead of passing it positionally. +su_user() { + quoted= + for arg do + quoted="$quoted'$(printf '%s' "$arg" | sed "s/'/'\\\\''/g")' " + done + su -s /bin/sh "$user" -c "exec $quoted" +} + +# A sudo binary on PATH is not a working sudo: Alpine's sudoers grants root +# nothing. Probe each tool once and keep the first that runs. as_user() { - if command -v runuser >/dev/null 2>&1; then - runuser -u "$user" -- "$@" - elif command -v sudo >/dev/null 2>&1; then - sudo -n -u "$user" -- "$@" - else - command=$1 - shift - su -s /bin/sh "$user" -c 'exec "$@"' stoat "$command" "$@" + if [ -z "${as_user_tool:-}" ]; then + if command -v runuser >/dev/null 2>&1 && runuser -u "$user" -- true 2>/dev/null; then + as_user_tool=runuser + elif command -v sudo >/dev/null 2>&1 && sudo -n -u "$user" -- true 2>/dev/null; then + as_user_tool=sudo + else + as_user_tool=su + fi fi + case "$as_user_tool" in + runuser) runuser -u "$user" -- "$@" ;; + sudo) sudo -n -u "$user" -- "$@" ;; + *) su_user "$@" ;; + esac } python_bin=$(command -v python3 2>/dev/null || command -v python 2>/dev/null || true) diff --git a/internal/recipes/samples_test.go b/internal/recipes/samples_test.go index f493d0a0..1047ac77 100644 --- a/internal/recipes/samples_test.go +++ b/internal/recipes/samples_test.go @@ -260,6 +260,28 @@ func TestBundledPythonDevCreatesAndPreservesVenv(t *testing.T) { } } +// Alpine ships a sudoers file that grants root nothing, so sudo is on PATH and +// still refuses every command. The recipe must fall through to su instead of +// failing the runcmd and leaving cloud-init in error. +func TestBundledPythonDevFallsBackWhenEscalationRefuses(t *testing.T) { + t.Setenv("STOAT_FAKE_NO_ESCALATION", "1") + account := currentTestAccount(t) + root := t.TempDir() + + for _, guestOS := range []string{"alpine", "debian", "arch", "fedora"} { + body := bundledScript(t, "python-dev", guestOS) + venvDir := filepath.Join(root, guestOS, "env") + output, err := runBundledPython(t, body, account.Username, venvDir, + filepath.Join(root, guestOS+"-output"), filepath.Join(root, guestOS+"-calls")) + if err != nil { + t.Fatalf("%s: %v\n%s", guestOS, err, output) + } + if _, err := os.Stat(filepath.Join(venvDir, "pyvenv.cfg")); err != nil { + t.Errorf("%s: environment was not created: %v", guestOS, err) + } + } +} + func TestBundledPythonDevRefusesInvalidTargets(t *testing.T) { body := bundledScript(t, "python-dev", "alpine") account := currentTestAccount(t) @@ -449,6 +471,10 @@ func writeHermeticCommandFakes(t *testing.T, bin string) { t.Helper() accountSwitch := `#!/bin/sh set -eu +if [ -n "${STOAT_FAKE_NO_ESCALATION:-}" ]; then + echo "root is not in the sudoers file." >&2 + exit 1 +fi while [ "$#" -gt 0 ]; do case "$1" in -u|-g|-s) shift 2;; @@ -464,16 +490,20 @@ exec "$@" t.Fatal(err) } } + // busybox su, which is what Alpine has, recognises options anywhere on the + // command line. A -p among the trailing arguments becomes su's own + // preserve-environment flag and never reaches the command, so this fake + // drops every dash argument and passes nothing positional to the shell. if err := os.WriteFile(filepath.Join(bin, "su"), []byte(`#!/bin/sh set -eu user= command= while [ "$#" -gt 0 ]; do case "$1" in + -c) command=$2; shift 2;; -s) shift 2;; -*) shift;; - -c) command=$2; shift 2; break;; - *) user=$1; shift;; + *) [ -n "$user" ] || user=$1; shift;; esac done [ -n "$command" ]