From add09cf67836f2aeb11d79be8374637df281a4fa Mon Sep 17 00:00:00 2001 From: Mike McQuaid Date: Mon, 21 Sep 2026 14:12:53 -0500 Subject: [PATCH] Allow installation without sudo access - Allow writable prefixes without administrator membership. - Keep macOS's admin group for members and use the primary group otherwise, allowing group changes without elevation. - Remove group and other write access when falling back to `staff`, including existing files, and restrict the umask for new files. - Honour `HOMEBREW_NO_SUDO` and detect known privilege failures. - Probe sudo without refreshing cached credentials. - Try filesystem operations before requesting elevation. - Skip Command Line Tools without sudo and make their installation failures non-fatal when a usable Git is available. - Validate Git before downloading Homebrew, accepting PATH and Xcode installations without invoking Apple's developer-tool stubs. - Recommend the macOS package for MDM and identify the release requirement for installation without Git or developer tools. - Cover permissions and optional tools with Ruby tests and verify installation and package use as a real non-admin account in CI. - Use the test account's login environment to avoid inheriting the runner's inaccessible working directory. --- .github/workflows/tests.yml | 37 ++++ README.md | 12 +- install.sh | 161 +++++++++----- tests/test_install.rb | 412 ++++++++++++++++++++++++++++++++++++ 4 files changed, 568 insertions(+), 54 deletions(-) create mode 100644 tests/test_install.rb diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index cd454b32..490abe9d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -75,6 +75,15 @@ jobs: echo "/home/linuxbrew/.linuxbrew/bin:/usr/bin:/bin" >> "${GITHUB_PATH}" fi + - name: Test installation permissions + run: | + if [[ "${RUNNER_OS}" != "macOS" ]] + then + sudo apt-get update + sudo apt-get install --yes ruby ruby-minitest + fi + /usr/bin/ruby tests/test_install.rb + - name: Uninstall GitHub Actions Homebrew run: | if which brew &>/dev/null @@ -91,6 +100,34 @@ jobs: - run: /bin/bash uninstall.sh -f >/dev/null + - name: Install as a non-admin user into a provisioned prefix + run: | + if [[ "${RUNNER_OS}" = "macOS" ]] + then + sudo sysadminctl -addUser brewtest -password "$(uuidgen)" -shell /bin/bash + else + sudo useradd --create-home --user-group --shell /bin/bash brewtest + fi + if id -Gn brewtest | grep -Eq '(^| )(admin|sudo|wheel)( |$)' + then + echo "The test account must not be an administrator." + exit 1 + fi + sudo install -d -o brewtest -g "$(id -gn brewtest)" -m 0755 /opt/brew + sudo -i -u brewtest /usr/bin/env HOMEBREW_NO_SUDO=1 NONINTERACTIVE=1 \ + /bin/bash -s -- --path /opt/brew < install.sh + if [[ "${RUNNER_OS}" = "macOS" && "$(id -gn brewtest)" = staff ]] + then + test -z "$(find /opt/brew ! -type l \( -perm -0020 -o -perm -0002 \))" + fi + sudo -i -u brewtest /usr/bin/env HOMEBREW_NO_SUDO=1 /opt/brew/bin/brew config + sudo -i -u brewtest /usr/bin/env HOMEBREW_NO_SUDO=1 /opt/brew/bin/brew install --force-bottle ack + sudo -i -u brewtest /opt/brew/bin/ack --version + test -z "$(find /opt/brew ! -user brewtest -o ! -group "$(id -gn brewtest)")" + # These generated files survive removal of the Cellar. + sudo -i -u brewtest /bin/rm -f /opt/brew/lib/ld.so /opt/brew/share/info/dir + sudo /usr/bin/env NONINTERACTIVE=1 /bin/bash uninstall.sh --path /opt/brew + - name: Install into custom prefixes non-interactively run: | case "$(uname)" in diff --git a/README.md b/README.md index 09dd1356..d359a3fc 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,9 @@ More installation information and options: . -If you're on macOS, try out our new `.pkg` installer. Download it from [Homebrew's latest GitHub release](https://github.com/Homebrew/brew/releases/latest). +For MDM deployments on Apple Silicon Macs, we recommend the `.pkg` installer from [Homebrew's latest GitHub release](https://github.com/Homebrew/brew/releases/latest). +Use [`HOMEBREW_PKG_USER`](https://docs.brew.sh/Installation) to select an existing non-root account to own the installation. +Installing without Git or developer tools requires a package release containing [Homebrew/brew#24062](https://github.com/Homebrew/brew/pull/24062). If you are running Linux or WSL, [there are some pre-requisite packages to install](https://docs.brew.sh/Homebrew-on-Linux#requirements). @@ -38,6 +40,14 @@ For example, to install non-interactively into `/opt/brew`: NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" -- --path /opt/brew ``` +The installing account does not need administrator membership when the prefix is writable. +On macOS, falling back to the `staff` group removes group and other write permissions from the prefix and cache. +Set `HOMEBREW_NO_SUDO=1` to prevent sudo calls; missing sudo, recognised privilege failures and explicit policy denials are also detected automatically. +Filesystem operations try without sudo before requesting elevation when needed. +Installations without sudo skip the system PATH file; follow the printed shell setup instructions instead. +Command Line Tools installation is skipped without sudo and CLT installation failures are non-fatal. +The shell installer aborts if Git is missing or unusable; a working Git on `PATH` or supplied by Xcode is supported. + ## Uninstall Homebrew ```bash diff --git a/install.sh b/install.sh index e84f8515..ea5ab3e6 100755 --- a/install.sh +++ b/install.sh @@ -161,6 +161,7 @@ case $(uname) in esac # Default installation paths. +GROUP_CHMOD="g+rwx" if [[ -n "${HOMEBREW_ON_MACOS-}" ]] then UNAME_MACHINE="$(/usr/bin/uname -m)" @@ -179,8 +180,18 @@ then CHOWN=("/usr/sbin/chown") CHGRP=("/usr/bin/chgrp") GROUP="admin" + # Use admin only for members, so group changes can work without sudo. + if [[ " $(id -Gn) " != *" admin "* ]] + then + GROUP="$(id -gn)" + if [[ "${GROUP}" == staff ]] + then + # Other standard macOS accounts also belong to staff. + GROUP_CHMOD="go-w" + umask go-w + fi + fi TOUCH=("/usr/bin/touch") - INSTALL=("/usr/bin/install" -d -o "root" -g "wheel" -m "0755") else UNAME_MACHINE="$(uname -m)" @@ -193,8 +204,8 @@ else CHGRP=("/bin/chgrp") GROUP="$(id -gn)" TOUCH=("/bin/touch") - INSTALL=("/usr/bin/install" -d -o "${USER}" -g "${GROUP}" -m "0755") fi +INSTALL=("/usr/bin/install" -d -o "${USER}" -g "${GROUP}" -m "0755") HOMEBREW_PREFIX="${HOMEBREW_PREFIX:-"${HOMEBREW_PREFIX_DEFAULT}"}" HOMEBREW_PREFIX="${HOMEBREW_PREFIX%/}" @@ -255,11 +266,12 @@ MACOS_NEWEST_UNSUPPORTED="28.0" # TODO: bump version when new macOS is released MACOS_OLDEST_SUPPORTED="15.0" +REQUIRED_GIT_VERSION=2.7.0 # HOMEBREW_MINIMUM_GIT_VERSION in brew.sh in Homebrew/brew + # For Homebrew on Linux REQUIRED_RUBY_VERSION=3.4 # https://github.com/Homebrew/brew/pull/19779 REQUIRED_GLIBC_VERSION=2.13 # https://docs.brew.sh/Homebrew-on-Linux#requirements REQUIRED_CURL_VERSION=7.41.0 # HOMEBREW_MINIMUM_CURL_VERSION in brew.sh in Homebrew/brew -REQUIRED_GIT_VERSION=2.7.0 # HOMEBREW_MINIMUM_GIT_VERSION in brew.sh in Homebrew/brew # no analytics during installation export HOMEBREW_NO_ANALYTICS_THIS_RUN=1 @@ -267,14 +279,30 @@ export HOMEBREW_NO_ANALYTICS_MESSAGE_OUTPUT=1 unset HAVE_SUDO_ACCESS # unset this from the environment -# create paths.d file for /opt/homebrew installs -if [[ -d "/etc/paths.d" && "${HOMEBREW_PREFIX}" == "/opt/homebrew" && -x "$(command -v tee)" ]] +# Keep conservative detection in sync with Homebrew/brew's Library/Homebrew/brew.sh. +if [[ -z "${HOMEBREW_NO_SUDO-}" ]] then - ADD_PATHS_D=1 + if [[ ! -x /usr/bin/sudo ]] + then + export HOMEBREW_NO_SUDO=1 + # Do not update cached credentials while checking privileges. + elif ! sudo_output="$(LC_ALL=C /usr/bin/sudo -n -k -l 2>&1)" + then + case "${sudo_output}" in + *'The "no new privileges" flag is set'* | \ + *"effective uid is not 0"* | \ + *"must be owned by uid 0 and have the setuid bit set"* | \ + *" is not in the sudoers file."* | *" is not allowed to run sudo on "* | *" may not run sudo on "*) + export HOMEBREW_NO_SUDO=1 + ;; + *) ;; + esac + fi fi +unset sudo_output have_sudo_access() { - if [[ ! -x "/usr/bin/sudo" ]] + if [[ -n "${HOMEBREW_NO_SUDO-}" || ! -x "/usr/bin/sudo" ]] then return 1 fi @@ -292,18 +320,15 @@ have_sudo_access() { then if [[ -n "${NONINTERACTIVE-}" ]] then + ohai "Checking for \`sudo\` access..." "${SUDO[@]}" -l mkdir &>/dev/null else + ohai "Checking for \`sudo\` access (which may request your password)..." "${SUDO[@]}" -v && "${SUDO[@]}" -l mkdir &>/dev/null fi HAVE_SUDO_ACCESS="$?" fi - if [[ -n "${HOMEBREW_ON_MACOS-}" ]] && [[ "${HAVE_SUDO_ACCESS}" -ne 0 ]] - then - abort "Need sudo access on macOS (e.g. the user ${USER} needs to be an Administrator)!" - fi - return "${HAVE_SUDO_ACCESS}" } @@ -334,6 +359,11 @@ retry() { } execute_sudo() { + if "$@" 2>/dev/null + then + return + fi + local -a args=("$@") if [[ "${EUID:-${UID}}" != "0" ]] && have_sudo_access then @@ -408,7 +438,7 @@ should_install_command_line_tools() { return 1 fi - ! [[ -e "/Library/Developer/CommandLineTools/usr/bin/git" ]] + ! [[ -e "/Library/Developer/CommandLineTools/usr/bin/git" ]] && have_sudo_access } get_permission() { @@ -470,13 +500,14 @@ test_curl() { } test_git() { - if [[ ! -x "$1" ]] + # Use the real developer-tools Git instead of Apple's installer stub. + if [[ ! -x "$1" ]] || [[ -n "${HOMEBREW_ON_MACOS-}" && "$1" == "/usr/bin/git" ]] then return 1 fi local git_version_output - git_version_output="$("$1" --version 2>/dev/null)" + git_version_output="$("$1" --version 2>/dev/null)" || return 1 if [[ "${git_version_output}" =~ "git version "([^ ]*).* ]] then version_ge "$(major_minor "${BASH_REMATCH[1]}")" "$(major_minor "${REQUIRED_GIT_VERSION}")" @@ -543,7 +574,7 @@ EOABORT fi # Invalidate sudo timestamp before exiting (if it wasn't active before). -if [[ -x /usr/bin/sudo ]] && ! /usr/bin/sudo -n -v 2>/dev/null +if [[ -z "${HOMEBREW_NO_SUDO-}" && -x /usr/bin/sudo ]] && ! /usr/bin/sudo -n -v 2>/dev/null then trap '/usr/bin/sudo -k' EXIT fi @@ -554,16 +585,16 @@ cd "/usr" || exit 1 ####################################################################### script -# shellcheck disable=SC2016 -ohai 'Checking for `sudo` access (which may request your password)...' - -if [[ -n "${HOMEBREW_ON_MACOS-}" ]] -then - [[ "${EUID:-${UID}}" == "0" ]] || have_sudo_access -elif ! [[ -d "${prefix_parent}" && -w "${prefix_parent}" && -x "${prefix_parent}" ]] && ! have_sudo_access +if ! [[ -d "${prefix_parent}" && -w "${prefix_parent}" && -x "${prefix_parent}" ]] && ! have_sudo_access then abort "Insufficient permissions to install Homebrew to \"${HOMEBREW_PREFIX}\"." fi + +# Create the system PATH entry only when it can be managed. +if [[ -d /etc/paths.d && "${HOMEBREW_PREFIX}" == /opt/homebrew ]] && have_sudo_access +then + ADD_PATHS_D=1 +fi HOMEBREW_CORE="${HOMEBREW_REPOSITORY}/Library/Taps/homebrew/homebrew-core" check_run_command_as_root @@ -718,7 +749,7 @@ fi if [[ "${#group_chmods[@]}" -gt 0 ]] then - ohai "The following existing directories will be made group writable:" + ohai "The following existing directories will be made writable:" printf "%s\n" "${group_chmods[@]}" fi if [[ "${#user_chmods[@]}" -gt 0 ]] @@ -785,7 +816,7 @@ then fi if [[ "${#group_chmods[@]}" -gt 0 ]] then - execute_sudo "${CHMOD[@]}" "g+rwx" "${group_chmods[@]}" + execute_sudo "${CHMOD[@]}" "${GROUP_CHMOD}" "${group_chmods[@]}" fi if [[ "${#user_chmods[@]}" -gt 0 ]] then @@ -806,7 +837,7 @@ fi if [[ "${#mkdirs[@]}" -gt 0 ]] then execute_sudo "${MKDIR[@]}" "${mkdirs[@]}" - execute_sudo "${CHMOD[@]}" "ug=rwx" "${mkdirs[@]}" + execute_sudo "${CHMOD[@]}" "u=rwx,${GROUP_CHMOD}" "${mkdirs[@]}" if [[ "${#mkdirs_user_only[@]}" -gt 0 ]] then execute_sudo "${CHMOD[@]}" "go-w" "${mkdirs_user_only[@]}" @@ -827,7 +858,7 @@ then fi if exists_but_not_writable "${HOMEBREW_CACHE}" then - execute_sudo "${CHMOD[@]}" "g+rwx" "${HOMEBREW_CACHE}" + execute_sudo "${CHMOD[@]}" "u+rwx,${GROUP_CHMOD}" "${HOMEBREW_CACHE}" fi if file_not_owned "${HOMEBREW_CACHE}" then @@ -837,6 +868,10 @@ if file_not_grpowned "${HOMEBREW_CACHE}" then execute_sudo "${CHGRP[@]}" "-R" "${GROUP}" "${HOMEBREW_CACHE}" fi +if [[ "${GROUP_CHMOD}" == go-w ]] +then + execute_sudo "${CHMOD[@]}" -R go-w "${HOMEBREW_PREFIX}" "${HOMEBREW_CACHE}" +fi if [[ -d "${HOMEBREW_CACHE}" ]] then execute "${TOUCH[@]}" "${HOMEBREW_CACHE}/.cleaned" @@ -844,39 +879,50 @@ fi if should_install_command_line_tools then - ohai "Searching online for the Command Line Tools" - # This temporary file prompts the 'softwareupdate' utility to list the Command Line Tools - clt_placeholder="/tmp/.com.apple.dt.CommandLineTools.installondemand.in-progress" - execute_sudo "${TOUCH[@]}" "${clt_placeholder}" - - clt_label_command="/usr/sbin/softwareupdate -l | + ( + ohai "Searching online for the Command Line Tools" + # This temporary file prompts the 'softwareupdate' utility to list the Command Line Tools + clt_placeholder="/tmp/.com.apple.dt.CommandLineTools.installondemand.in-progress" + trap 'execute_sudo /bin/rm -f "${clt_placeholder}"' EXIT + execute_sudo "${TOUCH[@]}" "${clt_placeholder}" + + clt_label_command="/usr/sbin/softwareupdate -l | grep -B 1 -E 'Command Line Tools' | awk -F'*' '/^ *\\*/ {print \$2}' | sed -e 's/^ *Label: //' -e 's/^ *//' | sort -V | tail -n1" - clt_label="$(chomp "$(/bin/bash -c "${clt_label_command}")")" + clt_label="$(chomp "$(/bin/bash -c "${clt_label_command}")")" - if [[ -n "${clt_label}" ]] - then - ohai "Installing ${clt_label}" - execute_sudo "/usr/sbin/softwareupdate" "-i" "${clt_label}" - execute_sudo "/usr/bin/xcode-select" "--switch" "/Library/Developer/CommandLineTools" - fi - execute_sudo "/bin/rm" "-f" "${clt_placeholder}" + if [[ -n "${clt_label}" ]] + then + ohai "Installing ${clt_label}" + execute_sudo "/usr/sbin/softwareupdate" "-i" "${clt_label}" + execute_sudo "/usr/bin/xcode-select" "--switch" "/Library/Developer/CommandLineTools" + fi + ) || warn "Command Line Tools installation failed. Continuing Homebrew installation." fi # Headless install may have failed, so fallback to original 'xcode-select' method if should_install_command_line_tools && test -t 0 then - ohai "Installing the Command Line Tools (expect a GUI popup):" - execute "/usr/bin/xcode-select" "--install" - echo "Press any key when the installation has completed." - getc - execute_sudo "/usr/bin/xcode-select" "--switch" "/Library/Developer/CommandLineTools" + ( + ohai "Installing the Command Line Tools (expect a GUI popup):" + execute "/usr/bin/xcode-select" "--install" + echo "Press any key when the installation has completed." + getc + execute_sudo "/usr/bin/xcode-select" "--switch" "/Library/Developer/CommandLineTools" + ) || warn "Command Line Tools installation failed. Continuing Homebrew installation." +fi + +xcode_path="" +if [[ -n "${HOMEBREW_ON_MACOS-}" ]] +then + xcode_path="$(/usr/bin/xcode-select --print-path 2>/dev/null)" fi -if [[ -n "${HOMEBREW_ON_MACOS-}" ]] && ! output="$(/usr/bin/xcrun clang 2>&1)" && [[ "${output}" == *"license"* ]] +if [[ -n "${xcode_path}" && "${xcode_path}" != / && -x "${xcode_path}/usr/bin/clang" ]] && + ! output="$(/usr/bin/xcrun clang 2>&1)" && [[ "${output}" == *"license"* ]] then abort "$( cat </dev/null then @@ -1024,7 +1078,8 @@ ohai "Downloading and installing Homebrew..." if [[ -n "${ADD_PATHS_D-}" ]] then execute_sudo "${MKDIR[@]}" /etc/paths.d - echo "${HOMEBREW_PREFIX}/bin" | execute_sudo tee /etc/paths.d/homebrew + # Pass the path as an argument so an unprivileged attempt cannot consume stdin. + execute_sudo /bin/bash -c "echo \"\$1\" > /etc/paths.d/homebrew" -- "${HOMEBREW_PREFIX}/bin" execute_sudo "${CHOWN[@]}" root:wheel /etc/paths.d/homebrew execute_sudo "${CHMOD[@]}" "a+r" /etc/paths.d/homebrew elif [[ ":${PATH}:" != *":${HOMEBREW_PREFIX}/bin:"* ]] diff --git a/tests/test_install.rb b/tests/test_install.rb new file mode 100644 index 00000000..78075b71 --- /dev/null +++ b/tests/test_install.rb @@ -0,0 +1,412 @@ +# frozen_string_literal: true + +require "fileutils" +require "minitest/autorun" +require "open3" +require "tmpdir" + +class InstallPermissionsTest < Minitest::Test + INSTALL = File.read(File.expand_path("../install.sh", __dir__)) + + def setup + @directory = Dir.mktmpdir + @sudo = File.join(@directory, "sudo") + @log = File.join(@directory, "sudo.log") + File.write(@sudo, <<~'BASH') + #!/bin/bash + printf "%s\n" "$*" >> "$SUDO_TEST_LOG" + printf "%s" "$SUDO_TEST_OUTPUT" >&2 + exit "${SUDO_TEST_STATUS:-1}" + BASH + File.chmod(0755, @sudo) + @environment = { + "HOMEBREW_NO_SUDO" => "", + "HOMEBREW_ON_MACOS" => "1", + "HOMEBREW_ON_LINUX" => "", + "HAVE_SUDO_ACCESS" => nil, + "NONINTERACTIVE" => "", + "SUDO_ASKPASS" => "", + "SUDO_TEST_OUTPUT" => "", + "SUDO_TEST_STATUS" => "1", + "USER" => "brewer", + "SUDO_TEST_LOG" => @log, + } + end + + def teardown + FileUtils.remove_entry(@directory) + end + + def test_fatal_sudo_errors_disable_elevation + [ + 'sudo: The "no new privileges" flag is set, which prevents sudo from running as root.', + "sudo: effective uid is not 0, is sudo installed setuid root?", + "sudo: /usr/bin/sudo must be owned by uid 0 and have the setuid bit set", + ].each do |message| + @environment["SUDO_TEST_OUTPUT"] = message + stdout, = run_sudo_detection + assert_equal "1\n", stdout, message + end + end + + def test_sudo_detection_preserves_credentials_and_inconclusive_failures + [ + ["Sorry, user brewer may not run sudo on localhost.", "1", "1"], + ["sudo: a password is required", "1", ""], + ["sudo: unable to resolve host localhost", "1", ""], + ["", "0", ""], + ].each do |message, status, expected| + @environment.merge!("SUDO_TEST_OUTPUT" => message, "SUDO_TEST_STATUS" => status) + stdout, = run_sudo_detection + assert_equal "#{expected}\n", stdout, message + end + assert_equal Array.new(4, "-n -k -l"), File.readlines(@log, chomp: true) + end + + def test_explicit_no_sudo_does_not_probe + @environment["HOMEBREW_NO_SUDO"] = "1" + stdout, = run_sudo_detection + assert_equal "1\n", stdout + stdout, = run_shell('have_sudo_access; echo "status=$?"') + assert_equal "status=1\n", stdout + refute File.exist?(@log) + end + + def test_non_admin_denial_does_not_abort + stdout, stderr, = run_shell('have_sudo_access; echo "status=$?"') + assert_equal "status=1\n", stdout + assert_empty stderr + end + + def test_sudo_notice_precedes_first_check_only + @environment["SUDO_TEST_STATUS"] = "0" + _, stderr, status = run_shell(<<~'BASH') + ohai() { echo "$*" >> "$SUDO_TEST_LOG"; } + have_sudo_access; have_sudo_access + BASH + assert_predicate status, :success?, stderr + assert_equal ["Checking for `sudo` access (which may request your password)...", "-v", "-l mkdir"], + File.readlines(@log, chomp: true) + end + + def test_system_path_entry_does_not_require_tee + @environment["PATH"] = @directory + @environment["SUDO_TEST_STATUS"] = "0" + stdout, stderr, status = run_shell( + "HOMEBREW_PREFIX=/opt/homebrew;\n" + + INSTALL.split("# Create the system PATH entry", 2).last.split("HOMEBREW_CORE=", 2).first + .split("\n", 2).last.gsub("/etc/paths.d", @directory) + + 'printf "%s\n" "${ADD_PATHS_D-}"', + ) + assert_predicate status, :success?, stderr + assert_equal "1\n", stdout + end + + def test_noninteractive_sudo_notice_omits_password + @environment["NONINTERACTIVE"] = "1" + @environment["SUDO_TEST_STATUS"] = "0" + stdout, stderr, status = run_shell('ohai() { echo "$*"; }; have_sudo_access') + assert_predicate status, :success?, stderr + assert_equal "Checking for `sudo` access...\n", stdout + assert_equal ["-n -l mkdir"], File.readlines(@log, chomp: true) + end + + def test_command_line_tools_require_sudo + [ + ["", false, "1", "0", 1], + ["", false, "", "1", 1], + ["", false, "", "0", 0], + ["", true, "1", "1", 1], + ["1", false, "1", "1", 1], + ].each do |linux, installed, no_sudo, sudo_status, expected_status| + @environment.merge!("HOMEBREW_ON_LINUX" => linux, "HOMEBREW_NO_SUDO" => no_sudo, + "SUDO_TEST_STATUS" => sudo_status) + stdout, = run_shell( + shell_function("should_install_command_line_tools").gsub( + "/Library/Developer/CommandLineTools/usr/bin/git", + installed ? @sudo : File.join(@directory, "missing"), + ) + 'should_install_command_line_tools; echo "status=$?"', + ) + assert_equal "status=#{expected_status}\n", stdout, [linux, installed, no_sudo, sudo_status].inspect + end + end + + def test_command_line_tools_are_skipped_without_sudo + @environment["HOMEBREW_NO_SUDO"] = "1" + stdout, stderr, status = run_clt_installation(interactive: true) + assert_predicate status, :success?, stderr + assert_equal "Continuing installation\n", stdout + refute File.exist?(File.join(@directory, "clt.log")) + refute File.exist?(@log) + end + + def test_headless_command_line_tools_failures_are_nonfatal + ["touch", "softwareupdate -l", "softwareupdate -i", "xcode-select --switch", "rm"].each do |failure| + stdout, stderr, status = run_clt_installation(failure: failure) + assert_predicate status, :success?, "#{failure}: #{stderr}" + assert_equal "Continuing installation\n", stdout, failure + assert_includes File.read(File.join(@directory, "clt.log")), "rm -f", failure + end + end + + def test_interactive_command_line_tools_failures_are_nonfatal + ["xcode-select --install", "xcode-select --switch"].each do |failure| + stdout, stderr, status = run_clt_installation(failure: failure, interactive: true) + assert_predicate status, :success?, "#{failure}: #{stderr}" + assert_includes stdout, "Continuing installation\n", failure + assert_includes File.read(File.join(@directory, "clt.log")), "xcode-select --install", failure + refute_includes stdout, "Waiting for user", failure if failure == "xcode-select --install" + end + end + + def test_missing_or_unusable_git_is_fatal_on_macos + [ + ["", 1, false], + ["", 1, true], + ["git version 2.54.0 (Apple Git-157)", 1, true], + ["invalid version", 0, true], + ["git version 2.6.0", 0, true], + ].each do |output, exit_status, installed| + stdout, stderr, status = run_git_detection(output: output, exit_status: exit_status, installed: installed) + refute_predicate status, :success?, [output, exit_status, installed].inspect + assert_includes stderr, "Git" + refute_includes stdout, "Ready to download" + end + end + + def test_git_from_xcode_is_accepted + stdout, stderr, status = run_git_detection(output: "git version 2.54.0 (Apple Git-157)") + assert_predicate status, :success?, stderr + assert_equal "Ready to download: #{@environment["GIT_TEST_PATH"]}\n", stdout + end + + def test_git_from_path_is_accepted_without_developer_tools + stdout, stderr, status = run_git_detection(output: "git version 2.54.0", developer_dir: "", git_on_path: true) + assert_predicate status, :success?, stderr + assert_equal "Ready to download: #{@environment["GIT_TEST_PATH"]}\n", stdout + refute File.exist?(File.join(@directory, "apple-tools.log")) + end + + def test_xcode_license_rejection_is_preserved + @environment.merge!("GIT_TEST_CLANG_OUTPUT" => "You have not agreed to the Xcode license.", + "GIT_TEST_CLANG_STATUS" => "1") + _, stderr, status = run_git_detection(output: "git version 2.54.0") + refute_predicate status, :success? + assert_includes stderr, "You have not agreed to the Xcode license." + end + + def test_missing_developer_tools_do_not_invoke_apple_stubs + ["", "/", File.join(@directory, "Missing Developer Tools")].each do |developer_dir| + _, stderr, status = run_git_detection(output: "", installed: false, developer_dir: developer_dir) + refute_predicate status, :success?, developer_dir + assert_includes stderr, "Git" + refute File.exist?(File.join(@directory, "apple-tools.log")), developer_dir + end + end + + def test_writable_operation_does_not_probe + @environment["SUDO_TEST_STATUS"] = "0" + _, stderr, status = run_shell("execute_sudo /usr/bin/true") + assert_predicate status, :success?, stderr + refute File.exist?(@log) + end + + def test_failed_operation_retries_with_sudo + @environment["SUDO_TEST_STATUS"] = "0" + _, stderr, status = run_shell("execute_sudo /usr/bin/false") + assert_predicate status, :success?, stderr + assert_equal ["-v", "-l mkdir", "/usr/bin/false"], File.readlines(@log, chomp: true) + end + + def test_non_admin_prefix_uses_own_group + stdout, = run_shell( + "id() { echo staff; };\n" + + INSTALL.split("# Default installation paths.", 2).last.split('HOMEBREW_PREFIX="${HOMEBREW_PREFIX:-', 2).first + .gsub("/usr/bin/uname -m", "echo arm64") + + "\nprintf \"%s\\n\" \"$GROUP|${INSTALL[*]}\"", + ) + assert_equal "staff|/usr/bin/install -d -o brewer -g staff -m 0755\n", stdout + end + + def test_staff_fallback_removes_group_and_other_write_permissions + [false, true].each do |existing| + prefix, cache, stdout, stderr, status = run_permission_setup(existing: existing) + assert_predicate status, :success?, "#{stdout}\n#{stderr}" + [prefix, cache].each do |root| + [root, *Dir.glob("#{root}/**/*", File::FNM_DOTMATCH)].each do |path| + next if [".", ".."].include?(File.basename(path)) + + assert_equal 0, File.stat(path).mode & 0o022, path + assert File.writable?(path), path + end + end + refute File.exist?(@log) + end + end + + def test_other_groups_keep_group_write_permissions + [ + ["1", "staff admin", "staff"], + ["1", "brew-users", "brew-users"], + ["", "staff", "staff"], + ].each do |macos, groups, primary_group| + prefix, _, stdout, stderr, status = run_permission_setup(macos: macos, groups: groups, primary_group: primary_group) + assert_predicate status, :success?, "#{stdout}\n#{stderr}" + assert_equal 0o020, File.stat(File.join(prefix, "bin")).mode & 0o020 + end + end + + private + + def run_permission_setup(existing: false, macos: "1", groups: "staff", primary_group: "staff") + directory = Dir.mktmpdir("permissions", @directory) + prefix = File.join(directory, "prefix") + cache = File.join(directory, "cache") + FileUtils.mkdir_p(prefix) + if existing + %w[prefix/bin prefix/.git cache].each do |path| + FileUtils.mkdir_p(File.join(directory, path)) + File.chmod(0o777, File.join(directory, path)) + end + %w[prefix/bin/brew prefix/.git/config cache/download].each do |path| + File.write(File.join(directory, path), "existing file\n") + File.chmod(0o666, File.join(directory, path)) + end + File.chmod(0o777, prefix) + end + @environment.merge!("HOMEBREW_ON_MACOS" => macos, "HOMEBREW_NO_SUDO" => "1", + "PERMISSIONS_TEST_GROUPS" => groups, "PERMISSIONS_TEST_PRIMARY_GROUP" => primary_group) + stdout, stderr, status = run_shell( + <<~'BASH' + + umask 000 + id() { + if [[ "$1" == -Gn ]]; then echo "$PERMISSIONS_TEST_GROUPS"; else echo "$PERMISSIONS_TEST_PRIMARY_GROUP"; fi + } + BASH + INSTALL.split("# Default installation paths.", 2).last.split('HOMEBREW_PREFIX="${HOMEBREW_PREFIX:-', 2).first + .gsub("/usr/bin/uname -m", "echo arm64") + + %w[get_permission user_only_chmod exists_but_not_writable].map { |name| shell_function(name) }.join + + <<~BASH + + HOMEBREW_PREFIX="#{prefix}" + HOMEBREW_REPOSITORY="#{prefix}" + HOMEBREW_CACHE="#{cache}" + CHMOD=(/bin/chmod) + MKDIR=(/bin/mkdir -p) + TOUCH=(/usr/bin/touch) + CHOWN=(/usr/bin/true) + CHGRP=(/usr/bin/true) + STAT_PRINTF=(/usr/bin/stat #{RUBY_PLATFORM.include?("darwin") ? "-f" : "-c"}) + PERMISSION_FORMAT=#{RUBY_PLATFORM.include?("darwin") ? "%A" : "%a"} + file_not_owned() { return 1; } + file_not_grpowned() { return 1; } + BASH + INSTALL.split("# Keep relatively in sync with", 2).last.split("\nif should_install_command_line_tools", 2).first + + "\nif [[ -d \"${HOMEBREW_PREFIX}\" ]]\n" + + INSTALL.split("\nif [[ -d \"${HOMEBREW_PREFIX}\" ]]\n", 2).last.split("\nif should_install_command_line_tools", 2).first + <<~'BASH', + mkdir "$HOMEBREW_PREFIX/new-directory" + touch "$HOMEBREW_PREFIX/bin/new-file" + BASH + ) + [prefix, cache, stdout, stderr, status] + end + + def run_sudo_detection + detection = INSTALL.split("# Keep conservative detection", 2).last + .split("have_sudo_access()", 2).first.split("\n", 2).last + run_shell(detection.gsub("/usr/bin/sudo", @sudo) + 'printf "%s\n" "$HOMEBREW_NO_SUDO"') + end + + def shell_function(name) + "#{name}() {#{INSTALL.split("#{name}() {", 2).last.split("\n}\n", 2).first}\n}\n" + end + + def run_shell(script) + helpers = %w[have_sudo_access execute execute_sudo should_install_command_line_tools chomp] + .map { |name| shell_function(name) }.join.gsub("/usr/bin/sudo", @sudo) + Open3.capture3(@environment, "/bin/bash", "-c", + "abort() { echo \"$*\" >&2; exit 1; }; ohai() { :; };\n#{helpers}#{script}") + end + + def run_git_detection(output:, exit_status: 0, installed: true, + developer_dir: File.join(@directory, "Xcode Test.app"), git_on_path: false) + @environment.merge!("HOMEBREW_NO_SUDO" => "1", "REQUIRED_GIT_VERSION" => "2.7.0", + "GIT_TEST_OUTPUT" => output, "GIT_TEST_STATUS" => exit_status.to_s, + "GIT_TEST_DEVELOPER_DIR" => developer_dir, + "GIT_TEST_LOG" => File.join(@directory, "apple-tools.log"), + "GIT_TEST_PATH" => File.join(@directory, git_on_path ? "bin/git" : "Xcode Test.app/usr/bin/git"), + "PATH" => "#{@directory}/bin:#{@directory}/system") + FileUtils.rm_f(@environment["GIT_TEST_LOG"]) + if installed + FileUtils.mkdir_p(File.dirname(@environment["GIT_TEST_PATH"])) + File.write(@environment["GIT_TEST_PATH"], <<~'BASH') + #!/bin/bash + printf "%s\n" "$GIT_TEST_OUTPUT" + exit "$GIT_TEST_STATUS" + BASH + File.chmod(0755, @environment["GIT_TEST_PATH"]) + unless git_on_path + FileUtils.touch(File.join(@directory, "Xcode Test.app/usr/bin/clang")) + File.chmod(0755, File.join(@directory, "Xcode Test.app/usr/bin/clang")) + end + end + FileUtils.mkdir_p(File.join(@directory, "system")) + FileUtils.ln_sf("/bin/cat", File.join(@directory, "system/cat")) + File.write(File.join(@directory, "system/git"), "#!/bin/bash\necho git >> \"$GIT_TEST_LOG\"\nexit 1\n") + File.write(File.join(@directory, "xcrun"), <<~'BASH') + #!/bin/bash + echo xcrun >> "$GIT_TEST_LOG" + if [[ "$1" == clang ]] + then + printf '%s\n' "${GIT_TEST_CLANG_OUTPUT-}" + exit "${GIT_TEST_CLANG_STATUS:-0}" + fi + printf '%s\n' "$GIT_TEST_PATH" + BASH + File.write(File.join(@directory, "xcode-select"), "#!/bin/bash\nprintf '%s\\n' \"$GIT_TEST_DEVELOPER_DIR\"\n") + %w[system/git xcrun xcode-select].each { |tool| File.chmod(0755, File.join(@directory, tool)) } + run_shell( + (%w[major_minor version_ge test_git which find_tool].map { |name| shell_function(name) }.join + + INSTALL.split("if should_install_command_line_tools && test -t 0", 2).last + .split("\nfi\n", 2).last.split("\nif ! command -v curl", 2).first + + "\necho \"Ready to download: ${USABLE_GIT}\"") + .gsub('"/usr/bin/git"', "\"#{@directory}/system/git\"") + .gsub("/usr/bin/xcrun", File.join(@directory, "xcrun")) + .gsub("/usr/bin/xcode-select", File.join(@directory, "xcode-select")), + ) + end + + def run_clt_installation(failure: "", interactive: false) + @environment.merge!("SUDO_TEST_STATUS" => "0", "CLT_TEST_FAILURE" => failure, + "CLT_TEST_INTERACTIVE" => interactive ? "1" : "", + "CLT_TEST_LOG" => File.join(@directory, "clt.log")) + FileUtils.rm_f(@environment["CLT_TEST_LOG"]) + File.write(File.join(@directory, "softwareupdate"), <<~'BASH') + #!/bin/bash + [[ "$CLT_TEST_FAILURE" != "softwareupdate -l" ]] || exit 1 + echo '* Label: Command Line Tools test' + BASH + File.chmod(0755, File.join(@directory, "softwareupdate")) + run_shell( + shell_function("should_install_command_line_tools").gsub( + "/Library/Developer/CommandLineTools/usr/bin/git", File.join(@directory, "missing"), + ) + <<~'BASH' + + TOUCH=("/usr/bin/touch") + warn() { echo "$*" >&2; } + getc() { echo "Waiting for user"; } + test() { [[ "$*" == "-t 0" && -n "$CLT_TEST_INTERACTIVE" ]]; } + execute() { + echo "${1##*/} ${*:2}" >> "$CLT_TEST_LOG" + if [[ -n "$CLT_TEST_FAILURE" && "${1##*/} ${*:2}" == "$CLT_TEST_FAILURE"* ]] + then + abort "Simulated failure: $*" + fi + } + execute_sudo() { execute "$@"; } + BASH + "if should_install_command_line_tools" + INSTALL.split("if should_install_command_line_tools", 3).last + .split("\nxcode_path=", 2).first + .gsub("/usr/sbin/softwareupdate", File.join(@directory, "softwareupdate")) + + "\necho \"Continuing installation\"", + ) + end +end