diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index da42c017af..bf76c8e28e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,6 +7,7 @@ on: - "**" paths: - .github/workflows/release.yml + - Library/Homebrew/utils/macos_user.sh - package/**/* workflow_dispatch: inputs: @@ -143,13 +144,24 @@ jobs: - name: Open macOS keychain run: security list-keychain -d user -s "${RUNNER_TEMP}/${TEMPORARY_KEYCHAIN_FILE}" + - name: Prepare Homebrew installer scripts + run: | + installer_scripts="${RUNNER_TEMP}/homebrew-installer-scripts" + rm -rvf "${installer_scripts}" + mkdir -vp "${installer_scripts}" + cp -vp brew/package/scripts/preinstall \ + brew/package/scripts/postinstall \ + brew/Library/Homebrew/utils/macos_user.sh \ + "${installer_scripts}/" + echo "HOMEBREW_INSTALLER_SCRIPTS=${installer_scripts}" >> "${GITHUB_ENV}" + - name: Build Homebrew installer component package env: HOMEBREW_VERSION: ${{ steps.homebrew-version.outputs.version }} # Note: `Library/Homebrew/test/support/fixtures/` contains unsigned # binaries so it needs to be excluded from notarization. run: pkgbuild --root brew - --scripts brew/package/scripts + --scripts "${HOMEBREW_INSTALLER_SCRIPTS}" --identifier sh.brew.homebrew --version "${HOMEBREW_VERSION}" --install-location /opt/homebrew diff --git a/Library/Homebrew/brew.sh b/Library/Homebrew/brew.sh index ae8f7b972b..7d803c9075 100644 --- a/Library/Homebrew/brew.sh +++ b/Library/Homebrew/brew.sh @@ -239,6 +239,10 @@ check-run-command-as-root() { [[ -f /run/.containerenv ]] && return [[ -f /proc/1/cgroup ]] && grep -E "azpl_job|actions_job|docker|garden|kubepods" -q /proc/1/cgroup && return + # `brew as-console-user` is run by root-owned MDM/Munki/Jamf workflows so it + # can immediately dispatch the requested Homebrew command as the console user. + [[ "${HOMEBREW_COMMAND}" == "as-console-user" ]] && return + # `brew services` may need `sudo` for system-wide daemons. if [[ "${HOMEBREW_COMMAND}" == "services" ]] then diff --git a/Library/Homebrew/cmd/as-console-user.rb b/Library/Homebrew/cmd/as-console-user.rb new file mode 100644 index 0000000000..7ae6f1dd1a --- /dev/null +++ b/Library/Homebrew/cmd/as-console-user.rb @@ -0,0 +1,28 @@ +# typed: strict +# frozen_string_literal: true + +require "abstract_command" +require "shell_command" + +module Homebrew + module Cmd + class AsConsoleUser < AbstractCommand + include ShellCommand + + cmd_args do + usage_banner <<~EOS + `as-console-user` [ ...] + + Run a Homebrew command as the active macOS console user. + + This is intended for MDM, Munki and Jamf workflows where `brew` is + invoked as root but Homebrew operations should run as the logged-in + console user. The nested command is always dispatched through + `HOMEBREW_BREW_FILE`. + EOS + + named_args min: 1 + end + end + end +end diff --git a/Library/Homebrew/cmd/as-console-user.sh b/Library/Homebrew/cmd/as-console-user.sh new file mode 100644 index 0000000000..ad6737bf11 --- /dev/null +++ b/Library/Homebrew/cmd/as-console-user.sh @@ -0,0 +1,44 @@ +# Documentation defined in Library/Homebrew/cmd/as-console-user.rb + +# `HOMEBREW_*` variables are set by brew.sh before sourcing this command. +# shellcheck disable=SC2154 +homebrew-as-console-user() { + case "${1:-}" in + --help | -h | --usage | "-?") + "${HOMEBREW_BREW_FILE}" help as-console-user + return + ;; + *) ;; + esac + + if [[ "$#" -eq 0 ]] + then + "${HOMEBREW_BREW_FILE}" help as-console-user + return 1 + fi + + [[ -n "${HOMEBREW_MACOS}" ]] || odie "\`brew as-console-user\` is only supported on macOS." + + # `HOMEBREW_LIBRARY` is set by brew.sh, so ShellCheck cannot follow it. + # shellcheck disable=SC1091 + source "${HOMEBREW_LIBRARY}/Homebrew/utils/macos_user.sh" + + local console_user + console_user="$(homebrew-console-user)" || odie "No supported macOS console user is logged in." + + local console_home + console_home="$(homebrew-user-home "${console_user}")" || + odie "Could not determine home directory for console user: ${console_user}" + + ( + cd "${console_home}" &>/dev/null || odie "Failed to cd to ${console_home}!" + + sudo -H -u "${console_user}" /usr/bin/env -i \ + "HOME=${console_home}" \ + "USER=${console_user}" \ + "LOGNAME=${console_user}" \ + "PWD=${console_home}" \ + "PATH=/usr/bin:/bin:/usr/sbin:/sbin" \ + "${HOMEBREW_BREW_FILE}" "$@" + ) +} diff --git a/Library/Homebrew/test/cmd/as-console-user_spec.rb b/Library/Homebrew/test/cmd/as-console-user_spec.rb new file mode 100644 index 0000000000..7df44c0c97 --- /dev/null +++ b/Library/Homebrew/test/cmd/as-console-user_spec.rb @@ -0,0 +1,178 @@ +# typed: false +# frozen_string_literal: true + +require "open3" + +require "cmd/shared_examples/args_parse" +require "cmd/as-console-user" + +RSpec.describe Homebrew::Cmd::AsConsoleUser do + let(:as_console_user_script) { HOMEBREW_LIBRARY_PATH/"cmd/as-console-user.sh" } + let(:repository_root) { HOMEBREW_LIBRARY_PATH.parent.parent } + let(:test_root) { mktmpdir } + let(:macos_user_script) { repository_root/"Library/Homebrew/utils/macos_user.sh" } + + let(:macos_env) do + { + "HOMEBREW_BREW_FILE" => "brew", + "HOMEBREW_LIBRARY" => (repository_root/"Library").to_s, + "HOMEBREW_MACOS" => "1", + } + end + + it_behaves_like "parseable arguments" + + def run_as_console_user_shell(script, env = {}) + Bundler.with_unbundled_env do + Open3.capture3(env, "/bin/bash", "-c", script) + end + end + + it "prints help and fails when no command is provided" do + stdout, stderr, status = run_as_console_user_shell( + <<~SH, + source "#{as_console_user_script}" + brew() { printf '%s\\n' "$*" >&2; } + homebrew-as-console-user + SH + "HOMEBREW_BREW_FILE" => "brew", + ) + + expect(status.exitstatus).to eq 1 + expect(stdout).to be_empty + expect(stderr).to eq("help as-console-user\n") + end + + it "rejects a root console user" do + stdout, stderr, status = run_as_console_user_shell( + <<~SH, + source "#{as_console_user_script}" + odie() { echo "Error: $*" >&2; exit 1; } + stat() { printf 'root\\n'; } + homebrew-as-console-user install wget + SH + macos_env, + ) + + expect(status.exitstatus).to eq 1 + expect(stdout).to be_empty + expect(stderr).to eq("Error: No supported macOS console user is logged in.\n") + end + + it "rejects a loginwindow console user" do + stdout, stderr, status = run_as_console_user_shell( + <<~SH, + source "#{as_console_user_script}" + odie() { echo "Error: $*" >&2; exit 1; } + stat() { printf 'loginwindow\\n'; } + homebrew-as-console-user install wget + SH + macos_env, + ) + + expect(status.exitstatus).to eq 1 + expect(stdout).to be_empty + expect(stderr).to eq("Error: No supported macOS console user is logged in.\n") + end + + it "rejects non-macOS systems" do + stdout, stderr, status = run_as_console_user_shell( + <<~SH, + source "#{as_console_user_script}" + odie() { echo "Error: $*" >&2; exit 1; } + homebrew-as-console-user install wget + SH + "HOMEBREW_BREW_FILE" => "brew", + ) + + expect(status.exitstatus).to eq 1 + expect(stdout).to be_empty + expect(stderr).to eq("Error: `brew as-console-user` is only supported on macOS.\n") + end + + it "uses the package user plist before the console user" do + homebrew_pkg_user_plist = test_root/".homebrew_pkg_user.plist" + homebrew_pkg_user_plist.write "plist" + + stdout, stderr, status = run_as_console_user_shell( + <<~SH, + source "#{macos_user_script}" + defaults() { printf 'munki\\n'; } + stat() { printf 'root\\n'; } + homebrew-package-user + SH + "HOMEBREW_PKG_USER_PLIST" => homebrew_pkg_user_plist.to_s, + ) + + expect(status.success?).to be true + expect(stdout).to eq("munki\n") + expect(stderr).to be_empty + end + + it "falls back to the console user without a package user plist" do + stdout, stderr, status = run_as_console_user_shell <<~SH + source "#{macos_user_script}" + stat() { printf 'mike\\n'; } + homebrew-package-user + SH + + expect(status.success?).to be true + expect(stdout).to eq("mike\n") + expect(stderr).to be_empty + end + + it "rejects package user lookup without a package user or console user" do + stdout, stderr, status = run_as_console_user_shell <<~SH + source "#{macos_user_script}" + stat() { printf 'root\\n'; } + homebrew-package-user + SH + + expect(status.exitstatus).to eq 1 + expect(stdout).to be_empty + expect(stderr).to be_empty + end + + it "dispatches the nested brew command as the console user" do + args_file = test_root/"sudo-args.txt" + console_home = test_root/"console-home" + console_home.mkpath + + stdout, stderr, status = run_as_console_user_shell( + <<~SH, + source "#{as_console_user_script}" + odie() { echo "Error: $*" >&2; exit 1; } + stat() { printf 'mike\\n'; } + id() { printf 'mike:*:501:20::0:0:Mike:#{console_home}:/bin/zsh\\n'; } + sudo() { + printf 'cwd=%s\\n' "$PWD" > "#{args_file}" + printf '%s\\n' "$@" >> "#{args_file}" + return 42 + } + homebrew-as-console-user upgrade git --minimum-version=2.50.1 + SH + macos_env.merge("HOMEBREW_BREW_FILE" => "/opt/homebrew/bin/brew"), + ) + + expect(status.exitstatus).to eq 42 + expect(stdout).to be_empty + expect(stderr).to be_empty + expect(args_file.read).to eq <<~EOS + cwd=#{console_home} + -H + -u + mike + /usr/bin/env + -i + HOME=#{console_home} + USER=mike + LOGNAME=mike + PWD=#{console_home} + PATH=/usr/bin:/bin:/usr/sbin:/sbin + /opt/homebrew/bin/brew + upgrade + git + --minimum-version=2.50.1 + EOS + end +end diff --git a/Library/Homebrew/utils/macos_user.sh b/Library/Homebrew/utils/macos_user.sh new file mode 100644 index 0000000000..9b0cb5cdf5 --- /dev/null +++ b/Library/Homebrew/utils/macos_user.sh @@ -0,0 +1,46 @@ +# Used by `brew as-console-user` and macOS installer package scripts. +# Keep this standalone: package scripts source it before Homebrew is installed. + +# Print the active macOS console user, or fail for login-window/system users. +homebrew-console-user() { + local console_user + console_user="$(stat -f "%Su" /dev/console 2>/dev/null)" || return 1 + + case "${console_user}" in + "" | root | loginwindow | _mbsetupuser) + return 1 + ;; + *) ;; + esac + + echo "${console_user}" +} + +# Print a user's home directory from the local account database. +homebrew-user-home() { + local user_record + user_record="$(id -P "$1" 2>/dev/null)" || return 1 + user_record="${user_record%:*}" + user_record="${user_record##*:}" + [[ -n "${user_record}" ]] || return 1 + + echo "${user_record}" +} + +# Print the package install user, preferring MDM's plist override. +homebrew-package-user() { + local homebrew_pkg_user_plist="${HOMEBREW_PKG_USER_PLIST:-/var/tmp/.homebrew_pkg_user.plist}" + if [[ -f "${homebrew_pkg_user_plist}" ]] + then + local homebrew_pkg_user + if homebrew_pkg_user="$(defaults read "${homebrew_pkg_user_plist}" HOMEBREW_PKG_USER 2>/dev/null)" && + [[ -n "${homebrew_pkg_user}" ]] + then + echo "${homebrew_pkg_user}" + return + fi + fi + + # Fall back to the active console user when MDM has not specified one. + homebrew-console-user +} diff --git a/package/scripts/postinstall b/package/scripts/postinstall index 114a5a1945..1cf770a4b3 100755 --- a/package/scripts/postinstall +++ b/package/scripts/postinstall @@ -5,6 +5,18 @@ # $4 System root directory (unused) set -euo pipefail +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd -P)" +if [[ -f "${script_dir}/macos_user.sh" ]] +then + # `pkgbuild` stages this helper beside the package scripts. + # shellcheck disable=SC1091 + source "${script_dir}/macos_user.sh" +else + # This fallback keeps direct local execution from the repository working. + # shellcheck disable=SC1091 + source "${script_dir}/../../Library/Homebrew/utils/macos_user.sh" +fi + # disable analytics while installing export HOMEBREW_NO_ANALYTICS_THIS_RUN=1 export HOMEBREW_NO_ANALYTICS_MESSAGE_OUTPUT=1 @@ -72,14 +84,12 @@ fi mkdir -vp Caskroom Cellar Frameworks etc include lib opt sbin share var/homebrew/linked # optionally define an install user at /var/tmp/.homebrew_pkg_user.plist -homebrew_pkg_user_plist="/var/tmp/.homebrew_pkg_user.plist" -if [[ -f "${homebrew_pkg_user_plist}" ]] && [[ -n $(defaults read "${homebrew_pkg_user_plist}" HOMEBREW_PKG_USER) ]] -then - homebrew_pkg_user=$(defaults read /var/tmp/.homebrew_pkg_user HOMEBREW_PKG_USER) # otherwise, get valid logged in user -else - homebrew_pkg_user=$(echo "show State:/Users/ConsoleUser" | scutil | awk '/Name :/ { print $3 }') -fi +homebrew_pkg_user="$(homebrew-package-user)" || + { + echo "No valid user for Homebrew installation. Log in before install or specify an install user." + exit 1 + } # set permissions chmod ug=rwx Caskroom Cellar Frameworks bin etc include lib opt sbin share var var/homebrew var/homebrew/linked @@ -93,7 +103,11 @@ else fi # move API cache to ~/Library/Caches/Homebrew -user_home_dir=$(dscl . read /Users/"${homebrew_pkg_user}" NFSHomeDirectory | awk '{ print $2 }') +user_home_dir="$(homebrew-user-home "${homebrew_pkg_user}")" || + { + echo "No home directory for Homebrew installation user: ${homebrew_pkg_user}" + exit 1 + } user_cache_dir="${user_home_dir}/Library/Caches/Homebrew" user_api_cache_dir="${user_cache_dir}/api" mkdir -vp "${user_api_cache_dir}" diff --git a/package/scripts/preinstall b/package/scripts/preinstall index 9e6baa06ce..6138a181b9 100755 --- a/package/scripts/preinstall +++ b/package/scripts/preinstall @@ -1,17 +1,22 @@ #!/bin/bash set -euo pipefail -homebrew_pkg_user_plist="/var/tmp/.homebrew_pkg_user.plist" -if [[ -f "${homebrew_pkg_user_plist}" ]] && [[ -n $(defaults read "${homebrew_pkg_user_plist}" HOMEBREW_PKG_USER) ]] +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd -P)" +if [[ -f "${script_dir}/macos_user.sh" ]] +then + # `pkgbuild` stages this helper beside the package scripts. + # shellcheck disable=SC1091 + source "${script_dir}/macos_user.sh" +else + # This fallback keeps direct local execution from the repository working. + # shellcheck disable=SC1091 + source "${script_dir}/../../Library/Homebrew/utils/macos_user.sh" +fi + +if homebrew-package-user &>/dev/null then exit 0 fi -homebrew_pkg_user=$(echo "show State:/Users/ConsoleUser" | scutil | awk '/Name :/ { print $3 }') -if [[ "${homebrew_pkg_user}" =~ _mbsetupuser|loginwindow|root ]] || [[ -z "${homebrew_pkg_user}" ]] -then - echo "No valid user for Homebrew installation. Log in before install or specify an install user." - exit 1 -else - exit 0 -fi +echo "No valid user for Homebrew installation. Log in before install or specify an install user." +exit 1