From 0a9855f7f49bd186e8ec8589c6ea17de81451354 Mon Sep 17 00:00:00 2001 From: cdesouza-chromium Date: Mon, 25 May 2026 13:46:55 +0100 Subject: [PATCH] [toolchain] Xcode license accept tools (#36672) This PR adds a python script that can be setup to accept the License for us, in an approach similar to the one used by Chromium infra's puppet. --- build/mac/download_hermetic_xcode.py | 72 +++-- script/deps_config.py | 6 - tools/cr/toolchain/README.md | 64 ++++- .../toolchain/install_xcode_accept_license.py | 272 ++++++++++++++++++ tools/cr/toolchain/xcode_accept_license.py | 98 +++++++ 5 files changed, 479 insertions(+), 33 deletions(-) create mode 100755 tools/cr/toolchain/install_xcode_accept_license.py create mode 100755 tools/cr/toolchain/xcode_accept_license.py diff --git a/build/mac/download_hermetic_xcode.py b/build/mac/download_hermetic_xcode.py index 13747b6d668..cd8da5d2976 100755 --- a/build/mac/download_hermetic_xcode.py +++ b/build/mac/download_hermetic_xcode.py @@ -11,7 +11,7 @@ port cleanly. Brave-specific differences: * Downloads a Brave-internal tarball rather than a CIPD package. * Gated on USE_BRAVE_HERMETIC_TOOLCHAIN=1 in place of upstream's should_use_hermetic_xcode.py check. - * Compares Xcode versions via pkg_resources.parse_version (semver-aware) + * Compares Xcode versions via packaging.version.parse (semver-aware) instead of upstream's string-split lexicographic compare. """ @@ -26,10 +26,31 @@ import sys from pathlib import Path from urllib.error import URLError # pylint: disable=no-name-in-module,import-error -import pkg_resources +from packaging.version import parse as parse_version -import deps -from deps_config import DEPS_PACKAGES_INTERNAL_URL, MAC_TOOLCHAIN_ROOT +# Importing script explicitly, so we can call this script from the terminal +# without needing to set up the PYTHONPATH. +sys.path.append(str(Path(__file__).resolve().parents[2] / 'script')) + +import deps # pylint: disable=wrong-import-position + +# This contains binaries from Xcode 26.4 (17E202) along with the macOS 26.4 SDK +# (25E251) and the Metal toolchain (17E188). +XCODE_VERSION = '26.4.1' +XCODE_TOOLCHAIN_DOWNLOAD_URL = ( + f'https://vhemnu34de4lf5cj6bx2wwshyy0egdxk.lambda-url.us-west-2.on.aws' + f'/xcode-hermetic-toolchain/xcode-hermetic-toolchain-{XCODE_VERSION}.tar.gz' +) + +# The toolchain will not be downloaded if the minimum OS version is not met. 19 +# is the Darwin major version number for macOS 10.15. Xcode 26.0 17A324 only +# runs on macOS 15.6 and newer, but some bots are still running older OS +# versions. macOS 10.15.4, the OS minimum through Xcode 12.4, still seems to +# work. +MAC_MINIMUM_OS_VERSION = [19, 4] + +MAC_TOOLCHAIN_ROOT = Path( + __file__).resolve().parents[3] / 'build' / 'mac_files' def LoadPList(path: Path) -> dict: @@ -37,20 +58,6 @@ def LoadPList(path: Path) -> dict: return plistlib.loads(path.read_bytes()) -# This contains binaries from Xcode 26.4.1, along with the macOS 26.4 SDK -XCODE_VERSION = '26.4.1' -HERMETIC_XCODE_BINARY = ( - DEPS_PACKAGES_INTERNAL_URL + - '/xcode-hermetic-toolchain/xcode-hermetic-toolchain-' + XCODE_VERSION + - '.tar.gz') - -# The toolchain will not be downloaded if the minimum OS version is not met. 19 -# is the Darwin major version number for macOS 10.15. -MAC_MINIMUM_OS_VERSION = [19, 4] - -TOOLCHAIN_BUILD_DIR = Path(MAC_TOOLCHAIN_ROOT) / 'Xcode.app' - - def PlatformMeetsHermeticXcodeRequirements() -> bool: if sys.platform == 'darwin': needed = MAC_MINIMUM_OS_VERSION @@ -75,14 +82,14 @@ def GetHermeticXcodeVersion(binaries_root: Path) -> str: def InstallXcodeBinaries() -> int: """Installs the Xcode binaries needed to build Brave and accepts the license.""" - binaries_root = Path(MAC_TOOLCHAIN_ROOT) / 'xcode_binaries' + binaries_root = MAC_TOOLCHAIN_ROOT / 'xcode_binaries' # Tarball extraction or not, if we have a hermetic toolchain,we still want # to process the license if the version is newer than the currently # accepted one. if (XCODE_VERSION != GetHermeticXcodeVersion(binaries_root) or binaries_root.is_symlink()): - url = HERMETIC_XCODE_BINARY + url = XCODE_TOOLCHAIN_DOWNLOAD_URL print(f"Downloading hermetic Xcode: {url}") try: deps.DownloadAndUnpack(url, binaries_root) @@ -93,6 +100,26 @@ def InstallXcodeBinaries() -> int: else: print(f"Hermetic Xcode {XCODE_VERSION} already installed") + on_disk_version = GetHermeticXcodeVersion(binaries_root) + license_info_path = (binaries_root / + 'Contents/Resources/LicenseInfo.plist') + on_disk_license = (LoadPList(license_info_path).get( + 'licenseID', '(missing)') if license_info_path.exists() else + '(LicenseInfo.plist not present)') + print(f" on-disk hermetic version: {on_disk_version}") + print(f" on-disk hermetic licenseID: {on_disk_license}") + current_license_path = Path( + '/Library/Preferences/com.apple.dt.Xcode.plist') + if current_license_path.exists(): + sys_plist = LoadPList(current_license_path) + sys_version = sys_plist.get('IDEXcodeVersionForAgreedToGMLicense', + '(missing)') + sys_license = sys_plist.get('IDELastGMLicenseAgreedTo', '(missing)') + else: + sys_version = sys_license = '(plist not present)' + print(f" system recorded version: {sys_version}") + print(f" system recorded licenseID: {sys_license}") + if sys.platform != 'darwin': return 0 @@ -115,9 +142,8 @@ def InstallXcodeBinaries() -> int: current_license_plist = LoadPList(current_license_path) xcode_version = current_license_plist.get( 'IDEXcodeVersionForAgreedToGMLicense') - if (xcode_version is not None - and pkg_resources.parse_version(xcode_version) - >= pkg_resources.parse_version(hermetic_xcode_version)): + if (xcode_version is not None and parse_version(xcode_version) + >= parse_version(hermetic_xcode_version)): should_overwrite_license = False if not should_overwrite_license: diff --git a/script/deps_config.py b/script/deps_config.py index d9ac5517034..86fdb5113f0 100755 --- a/script/deps_config.py +++ b/script/deps_config.py @@ -5,12 +5,6 @@ # License, v. 2.0. If a copy of the MPL was not distributed with this file, # You can obtain one at https://mozilla.org/MPL/2.0/. -import os - # Version number and URL for pre-configured rust dependency package # e.g. rust_deps_mac_0.1.0.gz DEPS_PACKAGES_URL = "https://brave-build-deps-public.s3.brave.com" -DEPS_PACKAGES_INTERNAL_URL = "https://vhemnu34de4lf5cj6bx2wwshyy0egdxk.lambda-url.us-west-2.on.aws" # pylint: disable=line-too-long -MAC_TOOLCHAIN_ROOT = os.path.join(os.path.dirname(os.path.dirname( - os.path.dirname(__file__))), - 'build', 'mac_files') diff --git a/tools/cr/toolchain/README.md b/tools/cr/toolchain/README.md index e5da39e5c9e..a3a22931b73 100644 --- a/tools/cr/toolchain/README.md +++ b/tools/cr/toolchain/README.md @@ -1,15 +1,14 @@ # Browser Toolchain Scripts -This directory contains scripts for generating browser toolchains (compilers, +This directory contains scripts for relating to the browser toolchains (compilers, linkers, standard libraries, etc.) used by the Brave build. ## Design rules Scripts here are designed to be launched with a single `curl` one-liner, so they must be **self-contained in a single source file** with **no -dependencies outside the Python standard library** (or the platform's -default tooling such as `git`). Do not split logic across helper modules or -add third-party imports. +dependencies outside the Python standard library**. Do not split logic across +helper modules or add third-party imports. Example invocation pattern: @@ -36,3 +35,60 @@ curl -sL \ ``` Pass `--help` for the full list of options. + +### `build_xcode_toolchain.py` + +macOS-only. Builds a hermetic, reproducible Xcode toolchain archive from +the local Xcode.app installation. The archive contains the subset of +files listed in Chromium's `build/xcode_binaries.yaml` plus the on-demand +Metal toolchain, with all archive metadata zeroed for reproducible builds. + +```sh +curl -sL \ + https://raw.githubusercontent.com/brave/brave-core/refs/heads/master/tools/cr/toolchain/build_xcode_toolchain.py \ + | python3 - \ + --out-dir=./out/ \ + --chromium-tag=150.0.7841.1 +``` + +The output filename encodes the Xcode and SDK versions and is consumed by +`brockit update-xcode-toolchain` to pin Brave's +`build/mac/download_hermetic_xcode.py`. Pass `--help` for the full list of +options; see the script's module docstring for the archive filename format. + +### `xcode_accept_license.py` + +macOS-only. Writes the two preference keys Apple's command-line tools +check for license acceptance +(`IDEXcodeVersionForAgreedToGMLicense` and `IDELastGMLicenseAgreedTo`) +into `/Library/Preferences/com.apple.dt.Xcode.plist`. Designed to be +installed at `/usr/local/bin/` and invoked under a narrowly-scoped +NOPASSWD sudoers grant so build hooks +(`brave/build/mac/download_hermetic_xcode.py`) can accept the license +without a password prompt. + +The script's own docstring covers manual install, the corresponding +sudoers entry, and the security model. Run with `--help` for the CLI. + +### `install_xcode_accept_license.py` + +Installer/verifier for the helper above. Lays down both the script +(`/usr/local/bin/xcode_accept_license.py`, mode 0755 owned by +`root:wheel`) and the matching sudoers drop-in +(`/etc/sudoers.d/xcode_accept_license`, mode 0440 owned by root, +validated via `visudo -c`), then runs a non-destructive smoke test using +`sudo -n -l` to confirm the grant works without ever invoking the helper. + +```sh +# install + verify +python3 install_xcode_accept_license.py --username $USER + +# verify an existing install +python3 install_xcode_accept_license.py --check-only --username $USER + +# tear down +python3 install_xcode_accept_license.py --uninstall +``` + +The script re-execs under sudo automatically if not invoked as root, so +there's no need to remember the `sudo` prefix. diff --git a/tools/cr/toolchain/install_xcode_accept_license.py b/tools/cr/toolchain/install_xcode_accept_license.py new file mode 100755 index 00000000000..03bc2df9b44 --- /dev/null +++ b/tools/cr/toolchain/install_xcode_accept_license.py @@ -0,0 +1,272 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 The Brave Authors. All rights reserved. +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this file, +# You can obtain one at https://mozilla.org/MPL/2.0/. +"""install_xcode_accept_license.py + +Installs the `xcode_accept_license.py` helper and its sudoers drop-in on a +macOS. Also doubles as a local verifier: the smoke test uses `sudo -n -l`, +which asks sudo whether the policy would permit the invocation without actually +executing the script, so it never touches +/Library/Preferences/com.apple.dt.Xcode.plist. + +USAGE + # install + verify + python3 install_xcode_accept_license.py --username $USER + + # check existing install only + python3 install_xcode_accept_license.py --check-only \\ + --username $USER + + # tear down + python3 install_xcode_accept_license.py --uninstall + +The script re-exec's itself under sudo if not already root, so a single +password prompt is enough. Re-running is safe (idempotent overwrite). +""" + +from __future__ import annotations + +import argparse +import os +import shutil +import subprocess +import sys +from pathlib import Path + +SCRIPT_NAME = 'xcode_accept_license.py' +DEFAULT_TARGET = Path('/usr/local/bin') / SCRIPT_NAME +DEFAULT_SUDOERS_PATH = Path('/etc/sudoers.d/xcode_accept_license') +VISUDO = '/usr/sbin/visudo' +SUDO = '/usr/bin/sudo' + +# Args used by the smoke test. Both pass the sudoers glob pin and the +# helper's regex check, but because `sudo -n -l` lists the matching policy +# without executing the command, the helper itself is never run and the +# Xcode license plist is not touched. +SMOKE_TEST_ARGS = ['0', '0'] + + +def sudoers_entry(username: str, target: Path) -> str: + """The single line we write into /etc/sudoers.d/. + + The `[0-9]*` / `[A-Za-z0-9_.-]*` patterns are sudoers globs (not regex) + pinning the leading character of each argument. Precise input + validation still lives in the helper script's own regex check. + """ + return (f'{username} ALL=(root) NOPASSWD: ' + f'{target} [0-9]* [A-Za-z0-9_.-]*\n') + + +def require_root() -> None: + if os.geteuid() == 0: + return + # Re-exec under sudo. This process is replaced; sudo prompts for a + # password (or uses cached credentials), then runs us again with euid 0 + # and $SUDO_USER set to the invoking account, so the --username + # default keeps working transparently. + print('Elevating via sudo (you may be prompted for your password)...', + file=sys.stderr) + os.execv('/usr/bin/sudo', ['/usr/bin/sudo', sys.executable, *sys.argv]) + + +def require_macos() -> None: + if sys.platform != 'darwin': + sys.exit(f'Only supported on macOS; got {sys.platform}.') + + +def find_source(explicit: Path | None) -> Path: + if explicit is not None: + if not explicit.is_file(): + sys.exit(f'--source does not exist: {explicit}') + return explicit.resolve() + here = Path(__file__).resolve().parent + candidate = here / SCRIPT_NAME + if not candidate.is_file(): + sys.exit(f'Cannot locate {SCRIPT_NAME} alongside this installer ' + f'({candidate}). Pass --source explicitly.') + return candidate + + +def install_script(source: Path, target: Path) -> None: + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy(source, target) + # gid 0 == `wheel` on macOS, matching the docs' `install -o root -g wheel`. + os.chown(target, 0, 0) + target.chmod(0o755) + print(f' installed {target} (root:wheel mode 0755)') + + +def install_sudoers(path: Path, contents: str) -> None: + """Write the sudoers drop-in atomically. + + Stages the file as `.tmp` so that even if validation fails the + half-written file is never picked up by sudo. Files under /etc/sudoers.d/ + whose name contains `.` are skipped by sudo's includedir loader, so the + staging file is invisible to policy while it exists. + """ + tmp = path.with_suffix(path.suffix + '.tmp') + tmp.write_bytes(contents.encode('utf-8')) + os.chown(tmp, 0, 0) + tmp.chmod(0o440) + try: + subprocess.run([VISUDO, '-c', '-f', str(tmp)], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT) + except subprocess.CalledProcessError as e: + tmp.unlink(missing_ok=True) + sys.exit('visudo rejected the generated sudoers entry:\n' + + e.stdout.decode('utf-8', errors='replace')) + tmp.replace(path) + print(f' installed {path} (root:wheel mode 0440, visudo -c passed)') + + +def verify_target(target: Path) -> None: + if not target.is_file(): + sys.exit(f' MISSING: {target}') + st = target.stat() + mode = st.st_mode & 0o777 + if (st.st_uid, mode) != (0, 0o755): + sys.exit(f' WRONG ownership/mode for {target}: ' + f'uid={st.st_uid} mode={oct(mode)} (want uid=0 mode=0o755)') + print(f' ok: {target} (uid={st.st_uid} mode={oct(mode)})') + + +def verify_sudoers(path: Path) -> None: + if not path.is_file(): + sys.exit(f' MISSING: {path}') + st = path.stat() + mode = st.st_mode & 0o777 + if (st.st_uid, mode) != (0, 0o440): + sys.exit(f' WRONG ownership/mode for {path}: ' + f'uid={st.st_uid} mode={oct(mode)} (want uid=0 mode=0o440)') + subprocess.run([VISUDO, '-c', '-f', str(path)], + check=True, + stdout=subprocess.DEVNULL) + print(f' ok: {path} (uid={st.st_uid} mode={oct(mode)}, ' + f'visudo -c passed)') + + +def smoke_test(username: str, target: Path) -> None: + """Verify the sudoers grant permits to run the helper. + + `sudo -n -l ` only asks sudo's policy engine whether the invocation + would be permitted; it never executes . That keeps this check + non-destructive — no plist write happens — while still exercising every + layer: the user identity, the NOPASSWD bit, the absolute path match, and + the sudoers argument globs. + """ + args = [ + SUDO, '-u', username, SUDO, '-n', '-l', + str(target), *SMOKE_TEST_ARGS + ] + try: + result = subprocess.run(args, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + except subprocess.CalledProcessError as e: + sys.exit( + f' FAILED: sudo would not let {username} run {target} ' + 'without a password.\n' + f' stderr: {e.stderr.decode("utf-8", errors="replace").strip()}' + ) + permitted = result.stdout.decode('utf-8', errors='replace').strip() + print(f' ok: sudo policy permits {username!r} to run:') + print(f' {permitted}') + + +def do_install(args: argparse.Namespace) -> int: + source = find_source(args.source) + entry = sudoers_entry(args.username, args.target) + print('Installing:') + print(f' source {source}') + print(f' target {args.target}') + print(f' sudoers file {args.sudoers_file}') + print(f' username {args.username}') + print(f' sudoers entry {entry.strip()}') + print() + install_script(source, args.target) + install_sudoers(args.sudoers_file, entry) + print() + print('Verifying:') + verify_target(args.target) + verify_sudoers(args.sudoers_file) + smoke_test(args.username, args.target) + print() + print('Done.') + return 0 + + +def do_uninstall(args: argparse.Namespace) -> int: + print('Removing:') + for path in (args.sudoers_file, args.target): + if path.exists(): + path.unlink() + print(f' removed {path}') + else: + print(f' not present: {path}') + return 0 + + +def do_check_only(args: argparse.Namespace) -> int: + print('Verifying:') + verify_target(args.target) + verify_sudoers(args.sudoers_file) + smoke_test(args.username, args.target) + print() + print('Done.') + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser( + description='Install / verify / remove the xcode_accept_license.py ' + 'helper and its sudoers drop-in on macOS.', + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument( + '--username', + default=os.environ.get('SUDO_USER'), + help='Account that gets the NOPASSWD grant. Defaults to $SUDO_USER ' + '(the account that invoked sudo). Required for install and ' + '--check-only.') + parser.add_argument('--source', + type=Path, + help=f'Path to {SCRIPT_NAME} to install. Defaults to ' + 'the file alongside this installer.') + parser.add_argument('--target', + type=Path, + default=DEFAULT_TARGET, + help=f'Install target (default: {DEFAULT_TARGET}).') + parser.add_argument( + '--sudoers-file', + type=Path, + default=DEFAULT_SUDOERS_PATH, + help=f'sudoers drop-in path (default: {DEFAULT_SUDOERS_PATH}).') + mode = parser.add_mutually_exclusive_group() + mode.add_argument('--uninstall', + action='store_true', + help='Remove the helper and the sudoers entry.') + mode.add_argument( + '--check-only', + action='store_true', + help='Verify an existing install; do not modify anything.') + args = parser.parse_args() + + require_macos() + require_root() + + if args.uninstall: + return do_uninstall(args) + if not args.username: + sys.exit('--username is required (no $SUDO_USER fallback ' + 'available).') + if args.check_only: + return do_check_only(args) + return do_install(args) + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tools/cr/toolchain/xcode_accept_license.py b/tools/cr/toolchain/xcode_accept_license.py new file mode 100755 index 00000000000..5ed9cae04e0 --- /dev/null +++ b/tools/cr/toolchain/xcode_accept_license.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 The Brave Authors. All rights reserved. +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this file, +# You can obtain one at https://mozilla.org/MPL/2.0/. +"""xcode_accept_license.py + +Accepts the Xcode GM license on a macOS machine without prompting for a +password. Mirrors the approach used in Chromium's infra puppet. + +USAGE + sudo /usr/local/bin/xcode_accept_license.py + + Example: + sudo /usr/local/bin/xcode_accept_license.py 26.3 EA1647 + + Both values come from the hermetic Xcode package: + = Contents/version.plist : CFBundleShortVersionString + = Contents/Resources/LicenseInfo.plist : licenseID + +SETTING UP MANUALLY + Replace `username` with the account name. + + sudo install -o root -g wheel -m 0755 \\ + xcode_accept_license.py /usr/local/bin/xcode_accept_license.py + echo 'username ALL=(root) NOPASSWD: /usr/local/bin/xcode_accept_license.py [0-9]* [A-Za-z0-9_.-]*' \\ + | sudo tee /etc/sudoers.d/xcode_accept_license + sudo chown root:wheel /etc/sudoers.d/xcode_accept_license + sudo chmod 0440 /etc/sudoers.d/xcode_accept_license + sudo visudo -c -f /etc/sudoers.d/xcode_accept_license + sudo -n /usr/local/bin/xcode_accept_license.py 26.3 EA1647 + +SETTING UP WITH install_xcode_accept_license.py + Same outcome with atomic sudoers placement and a non-destructive smoke + test. See that script's own docstring for `--check-only` / `--uninstall`. + + python3 install_xcode_accept_license.py --username $USER + +SECURITY NOTES + - The script writes only to /Library/Preferences/com.apple.dt.Xcode.plist + and performs no network I/O. + - Arguments are regex-validated before being passed to `defaults` so the + NOPASSWD path cannot be used to set arbitrary preference keys. + - Subprocess calls use list-form `subprocess.check_call` + (never `shell=True`), so each argument lands in argv as a single opaque + element. Shell-style injection through quoting bugs is not reachable by + construction. + - Absolute paths to /usr/bin/defaults and /usr/bin/plutil are hard-coded so + a manipulated PATH cannot redirect either tool. +""" + +import argparse +import re +import subprocess +import sys + +PLIST = '/Library/Preferences/com.apple.dt.Xcode.plist' +DEFAULTS = '/usr/bin/defaults' +PLUTIL = '/usr/bin/plutil' + +# Xcode short versions look like "26.3" or "26.3.1"; license IDs are short +# alphanumeric tokens such as "EA1647". +XCODE_VERSION_RE = re.compile(r'\d+(\.\d+)*') +LICENSE_VERSION_RE = re.compile(r'[A-Za-z0-9_.-]+') + + +def main() -> int: + parser = argparse.ArgumentParser( + description='Accept the Xcode GM license on behalf of all users.') + parser.add_argument('xcode_version', + help='CFBundleShortVersionString from the hermetic ' + 'Xcode bundle, e.g. 26.3') + parser.add_argument('license_version', + help='licenseID from LicenseInfo.plist, e.g. EA1647') + args = parser.parse_args() + + if not XCODE_VERSION_RE.fullmatch(args.xcode_version): + print(f'invalid xcode-version: {args.xcode_version}', file=sys.stderr) + return 64 + if not LICENSE_VERSION_RE.fullmatch(args.license_version): + print(f'invalid license-version: {args.license_version}', + file=sys.stderr) + return 64 + + subprocess.check_call([ + DEFAULTS, 'write', PLIST, 'IDEXcodeVersionForAgreedToGMLicense', + '-string', args.xcode_version + ]) + subprocess.check_call([ + DEFAULTS, 'write', PLIST, 'IDELastGMLicenseAgreedTo', '-string', + args.license_version + ]) + subprocess.check_call([PLUTIL, '-convert', 'xml1', PLIST]) + return 0 + + +if __name__ == '__main__': + sys.exit(main())