diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 03151fb2..3397c5e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -174,8 +174,8 @@ jobs: --no-parallel \ --filter "$test_filter" - local-installer-tests: - name: Local Installer Tests (Xcode 27) + local-build-tests: + name: Local Build Tests (Xcode 27) # GitHub-hosted Xcode 27 image: actions/runner-images#14404. runs-on: xcode-27 timeout-minutes: 10 @@ -183,12 +183,12 @@ jobs: - name: Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Run local installer tests + - name: Run local build tests timeout-minutes: 2 run: >- python3 -m unittest discover --start-directory scripts/tests - --pattern 'test_install_review_monitor.py' + --pattern 'test_build_review_monitor.py' --verbose - name: Show Xcode environment @@ -196,14 +196,14 @@ jobs: xcodebuild -version xcodebuild -showsdks - - name: Run local installer integration test + - name: Run local build integration test timeout-minutes: 8 env: - RUN_INSTALLER_INTEGRATION: '1' + RUN_LOCAL_BUILD_INTEGRATION: '1' run: >- python3 -m unittest discover --start-directory scripts/tests - --pattern 'test_install_review_monitor.py' + --pattern 'test_build_review_monitor.py' --verbose - name: Test remote release contracts diff --git a/README.md b/README.md index 31f1a5e1..8fab7c51 100644 --- a/README.md +++ b/README.md @@ -97,44 +97,39 @@ separate from CodexReviewMonitor's dedicated runtime home at `~/.codex_review`. ## Build from Source -To build, install, and launch CodexReviewMonitor from the current checkout, run -the installer from the repository root: +To build a local DMG from the current checkout, run from the repository root +using Python 3.10 or newer: ```bash -./scripts/install_review_monitor.py --launch +python3 scripts/build_review_monitor.py ``` -The installer builds the current checkout, applies an ad-hoc hardened-runtime -signature, validates the app, and deploys it to -`/Applications/CodexReviewMonitor.app`. Installing there requires write access. +The command creates `dist/CodexReviewMonitor_yymmdd_hhmm.dmg`, using the local +build-start time. It applies an ad-hoc hardened-runtime signature to the app and +verifies the DMG's contents. Build caches remain +in `.build` for subsequent builds. The first run prepares the pinned DMG tools +in `.build/release-tools` and may download the locked package dependencies. -The local installer requires macOS 26 or newer, an Apple silicon Mac, and -Xcode 26.4 or newer. The first build may download the package versions locked -by the repository. +Keep CodexReviewMonitor running while building. When the DMG is ready and reviews +have finished, quit the app, open the DMG, and drag `CodexReviewMonitor.app` to +Applications. Choose **Replace**, then launch the installed app. There is no need +to delete the existing app first. Builds started in the same minute replace the +same DMG only after the new image passes validation; older filenames are retained. -Quit CodexReviewMonitor before installing. If the default destination already -exists, the installer stops before building; remove the app manually before -rerunning it. The destination is checked again before deployment. To install -only for the current user, select `~/Applications` explicitly: - -```bash -./scripts/install_review_monitor.py \ - --destination ~/Applications/CodexReviewMonitor.app \ - --launch -``` +The local build requires an Apple silicon Mac and Xcode 26.4 or newer; the app +requires macOS 26 or newer. The command does not modify or launch installed apps. The default ad-hoc signature is for local use and does not make a redistributable or notarized app. If the Mac's management policy requires an approved local -identity, pass it explicitly; the installer never falls back to another +identity, pass it explicitly; the command never falls back to another identity: ```bash -./scripts/install_review_monitor.py \ - --signing-identity 'Apple Development: Developer Name (TEAMID)' \ - --launch +python3 scripts/build_review_monitor.py \ + --signing-identity 'Apple Development: Developer Name (TEAMID)' ``` -Device-management policy can still prohibit locally signed apps. The installer +Device-management policy can still prohibit locally signed apps. The command does not disable Gatekeeper or remove quarantine metadata. ## More Detail diff --git a/scripts/build_review_monitor.py b/scripts/build_review_monitor.py new file mode 100755 index 00000000..8362dbd7 --- /dev/null +++ b/scripts/build_review_monitor.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""Build a local CodexReviewMonitor DMG without changing installed apps.""" + +from __future__ import annotations + +import argparse +import fcntl +import os +import shlex +import subprocess +import sys +import tempfile +from datetime import datetime +from pathlib import Path + + +APP_NAME = "CodexReviewMonitor" +APP_BUNDLE_NAME = f"{APP_NAME}.app" + + +def run(arguments: list[str], *, cwd: Path | None = None, **kwargs) -> None: + print(f"+ {shlex.join(arguments)}", flush=True) + subprocess.run(arguments, cwd=cwd, check=True, **kwargs) + + +def packaging_python(repo_root: Path) -> Path: + environment = repo_root / ".build" / "release-tools" + python = environment / "bin" / "python3" + if not python.exists(): + run([sys.executable, "-m", "venv", str(environment)]) + run( + [ + str(python), "-m", "pip", "install", "--disable-pip-version-check", + "--require-hashes", "--only-binary=:all:", + "-r", str(repo_root / "scripts" / "release-requirements.txt"), + ] + ) + return python + + +def build(repo_root: Path, output_dir: Path, signing_identity: str = "-") -> Path: + build_root = repo_root / ".build" + build_root.mkdir(parents=True, exist_ok=True) + with (build_root / "local-dmg.lock").open("a") as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + timestamp = datetime.now().strftime("%y%m%d_%H%M") + archive_name = f"{APP_NAME}_{timestamp}.dmg" + python = packaging_python(repo_root) + derived_data = build_root / "local-dmg-arm64" + run( + [ + "/usr/bin/xcodebuild", "build", + "-project", "Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj", + "-scheme", APP_NAME, + "-configuration", "Release", + "-destination", "generic/platform=macOS", + "-derivedDataPath", str(derived_data), + "-disableAutomaticPackageResolution", + "-onlyUsePackageVersionsFromResolvedFile", + "-skipMacroValidation", + "ARCHS=arm64", "ONLY_ACTIVE_ARCH=NO", + "CODE_SIGNING_ALLOWED=NO", "CODE_SIGNING_REQUIRED=NO", + "CODE_SIGN_IDENTITY=", + ], + cwd=repo_root, + ) + + output_dir.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory( + prefix=".build-review-monitor-", dir=output_dir + ) as directory: + stage_root = Path(directory) + app = stage_root / APP_BUNDLE_NAME + product = derived_data / "Build" / "Products" / "Release" / APP_BUNDLE_NAME + environment = dict(os.environ) + environment.pop("DITTONORSRC", None) + run( + [ + "/usr/bin/ditto", "--rsrc", "--extattr", "--acl", "--qtn", + str(product), str(app), + ], + env=environment, + ) + run( + [ + "/usr/bin/codesign", "--force", "--options", "runtime", + "--timestamp=none", "--sign", signing_identity, str(app), + ] + ) + run(["/usr/bin/codesign", "--verify", "--deep", "--strict", str(app)]) + pending_archive = stage_root / archive_name + run( + [str(python), str(repo_root / "scripts" / "build_dmg.py"), + str(app), str(pending_archive)], + env=environment, + ) + + archive = output_dir / archive_name + pending_archive.replace(archive) + + print(f"Created local DMG: {archive}") + print("When reviews have finished, quit CodexReviewMonitor, open the DMG, " + "and drag the app to Applications to replace it.") + return archive + + +def main(arguments: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--signing-identity", default="-", metavar="IDENTITY", + help="local codesign identity (default: '-' for ad-hoc signing)", + ) + parsed = parser.parse_args(arguments) + if sys.version_info < (3, 10): + parser.error("DMG packaging requires Python 3.10 or newer.") + if not parsed.signing_identity: + parser.error("--signing-identity must not be empty.") + repo_root = Path(__file__).resolve().parent.parent + try: + build(repo_root, repo_root / "dist", parsed.signing_identity) + except (OSError, subprocess.CalledProcessError) as error: + print(f"error: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/install_review_monitor.py b/scripts/install_review_monitor.py deleted file mode 100755 index 0feaf8b7..00000000 --- a/scripts/install_review_monitor.py +++ /dev/null @@ -1,954 +0,0 @@ -#!/usr/bin/env python3 -"""Build and install CodexReviewMonitor from the current checkout.""" - -from __future__ import annotations - -import argparse -import ctypes -import errno -import os -import platform -import plistlib -import re -import shlex -import shutil -import subprocess -import sys -import tempfile -from contextlib import contextmanager -from dataclasses import dataclass -from pathlib import Path -from typing import Callable, Mapping, Optional, Sequence, TextIO - - -APP_NAME = "CodexReviewMonitor" -APP_BUNDLE_NAME = f"{APP_NAME}.app" -BUNDLE_IDENTIFIER = "lynnpd.CodexReviewMonitor" -MINIMUM_MACOS_MAJOR = 26 -MINIMUM_XCODE_VERSION = (26, 4) -SUPPORTED_ARCHITECTURE = "arm64" -QUARANTINE_ATTRIBUTE = "com.apple.quarantine" -RENAME_SWAP = 0x00000002 -RENAME_EXCL = 0x00000004 -RUNNING_EXECUTABLE_PATTERN = ( - rf"^(.*/)?{APP_NAME}[.]app/Contents/MacOS/{APP_NAME}([[:space:]].*)?$" -) - - -_LIBC = ctypes.CDLL(None, use_errno=True) -_RENAME_WITH_FLAGS = getattr(_LIBC, "renamex_np", None) -if _RENAME_WITH_FLAGS is not None: - _RENAME_WITH_FLAGS.argtypes = [ - ctypes.c_char_p, - ctypes.c_char_p, - ctypes.c_uint, - ] - _RENAME_WITH_FLAGS.restype = ctypes.c_int - - -class InstallerError(RuntimeError): - """A user-actionable local installation failure.""" - - -class PreservedInstallStateError(InstallerError): - """A failure whose recovery artifacts must not be deleted.""" - - -def _rename_with_flags(source: Path, destination: Path, flags: int) -> None: - if _RENAME_WITH_FLAGS is None: - raise OSError(errno.ENOSYS, "renamex_np is unavailable on this host") - result = _RENAME_WITH_FLAGS( - os.fsencode(source), - os.fsencode(destination), - flags, - ) - if result != 0: - error_number = ctypes.get_errno() - raise OSError( - error_number, - f"{os.strerror(error_number)}: {source} -> {destination}", - ) - - -def rename_exclusive(source: Path, destination: Path) -> None: - """Atomically rename without replacing an existing destination.""" - _rename_with_flags(source, destination, RENAME_EXCL) - - -def rename_swap(first: Path, second: Path) -> None: - """Atomically exchange two existing filesystem entries.""" - _rename_with_flags(first, second, RENAME_SWAP) - - -@dataclass(frozen=True) -class CommandResult: - returncode: int - stdout: str = "" - stderr: str = "" - - -@dataclass(frozen=True) -class FilesystemIdentity: - device: int - inode: int - mode: int - - -class CommandRunner: - def __init__(self, output: TextIO = sys.stdout) -> None: - self.output = output - - def run( - self, - arguments: Sequence[str], - *, - cwd: Optional[Path] = None, - check: bool = True, - capture_output: bool = False, - environment: Optional[Mapping[str, str]] = None, - ) -> CommandResult: - command = [str(argument) for argument in arguments] - print(f"+ {shlex.join(command)}", file=self.output, flush=True) - completed = subprocess.run( - command, - cwd=str(cwd) if cwd is not None else None, - check=False, - text=True, - stdout=subprocess.PIPE if capture_output else None, - stderr=subprocess.PIPE if capture_output else None, - env=dict(environment) if environment is not None else None, - ) - result = CommandResult( - returncode=completed.returncode, - stdout=completed.stdout or "", - stderr=completed.stderr or "", - ) - if check and result.returncode != 0: - details = "\n".join( - output.strip() - for output in (result.stdout, result.stderr) - if output.strip() - ) - message = ( - f"Command failed with exit status {result.returncode}: " - f"{shlex.join(command)}" - ) - if details: - message += f"\n{details}" - raise InstallerError(message) - return result - - -@dataclass(frozen=True) -class HostEnvironment: - system: str - macos_version: str - architecture: str - - @classmethod - def current(cls) -> "HostEnvironment": - return cls( - system=platform.system(), - macos_version=platform.mac_ver()[0], - architecture=platform.machine(), - ) - - -@dataclass(frozen=True) -class Toolchain: - xcodebuild: str = "/usr/bin/xcodebuild" - codesign: str = "/usr/bin/codesign" - ditto: str = "/usr/bin/ditto" - lipo: str = "/usr/bin/lipo" - pgrep: str = "/usr/bin/pgrep" - sysctl: str = "/usr/sbin/sysctl" - open: str = "/usr/bin/open" - xattr: str = "/usr/bin/xattr" - - @property - def required_paths(self) -> tuple[str, ...]: - return ( - self.xcodebuild, - self.codesign, - self.ditto, - self.lipo, - self.pgrep, - self.sysctl, - self.open, - self.xattr, - ) - - -@dataclass(frozen=True) -class InstallerConfiguration: - repo_root: Path - destination: Path - signing_identity: str - launch: bool - architecture: str = SUPPORTED_ARCHITECTURE - - @property - def derived_data_path(self) -> Path: - return self.repo_root / ".build" / f"local-installer-{self.architecture}" - - @property - def build_product_path(self) -> Path: - return ( - self.derived_data_path - / "Build" - / "Products" - / "Release" - / APP_BUNDLE_NAME - ) - -def _path_exists(path: Path) -> bool: - return os.path.lexists(str(path)) - - -def _absolute_path(path: Path) -> Path: - expanded = path.expanduser() - if not expanded.is_absolute(): - expanded = Path.cwd() / expanded - return Path(os.path.abspath(str(expanded))) - - -def _path_comparison_key(path: Path) -> tuple[str, ...]: - try: - resolved = path.resolve(strict=False) - except (OSError, RuntimeError) as error: - raise InstallerError( - f"Could not resolve path for comparison: {path}" - ) from error - # macOS volumes are commonly case-insensitive even though resolve() keeps - # the caller's spelling, so compare path components conservatively. - return tuple(part.casefold() for part in resolved.parts) - - -def standard_installation_paths( - home_directory: Path, - applications_directory: Path = Path("/Applications"), -) -> tuple[Path, Path]: - return ( - home_directory / "Applications" / APP_BUNDLE_NAME, - applications_directory / APP_BUNDLE_NAME, - ) - - -def select_destination( - requested: Optional[Path], - *, - home_directory: Optional[Path] = None, - applications_directory: Path = Path("/Applications"), -) -> Path: - home = home_directory or Path.home() - standard_paths = tuple( - _absolute_path(path) - for path in standard_installation_paths(home, applications_directory) - ) - destination = standard_paths[1] if requested is None else _absolute_path(requested) - - if destination.name != APP_BUNDLE_NAME: - raise InstallerError( - f"The destination must end with {APP_BUNDLE_NAME}: {destination}" - ) - destination_key = _path_comparison_key(destination) - destination = next( - ( - standard_path - for standard_path in standard_paths - if _path_comparison_key(standard_path) == destination_key - ), - destination, - ) - return destination - - -class ReviewMonitorInstaller: - def __init__( - self, - configuration: InstallerConfiguration, - *, - runner: Optional[CommandRunner] = None, - host: Optional[HostEnvironment] = None, - toolchain: Optional[Toolchain] = None, - process_checker: Optional[Callable[[], Sequence[str]]] = None, - exclusive_rename: Callable[[Path, Path], None] = rename_exclusive, - swap_rename: Callable[[Path, Path], None] = rename_swap, - home_directory: Optional[Path] = None, - applications_directory: Path = Path("/Applications"), - output: TextIO = sys.stdout, - ) -> None: - self.configuration = configuration - self.runner = runner or CommandRunner(output) - self.host = host or HostEnvironment.current() - self.toolchain = toolchain or Toolchain() - self.process_checker = process_checker or self._running_process_ids - self.exclusive_rename = exclusive_rename - self.swap_rename = swap_rename - self.home_directory = home_directory or Path.home() - self.applications_directory = applications_directory - self.output = output - - def install(self) -> None: - self._preflight() - with self._exclusive_install_lock(): - self._install_while_locked() - - def _install_while_locked(self) -> None: - self._ensure_app_not_running() - self._ensure_destination_safe() - self._build() - - destination_parent = self.configuration.destination.parent - destination_parent.mkdir(parents=True, exist_ok=True) - stage_root = Path( - tempfile.mkdtemp( - prefix=f".{APP_NAME}.install-", - dir=str(destination_parent), - ) - ) - staged_app = stage_root / APP_BUNDLE_NAME - try: - ditto_environment = dict(os.environ) - ditto_environment.pop("DITTONORSRC", None) - self.runner.run( - [ - self.toolchain.ditto, - "--rsrc", - "--extattr", - "--acl", - "--qtn", - str(self.configuration.build_product_path), - str(staged_app), - ], - environment=ditto_environment, - ) - self._sign(staged_app) - staged_app_identity = self._validate_staged_app(staged_app) - self._ensure_app_not_running() - destination_identity = self._ensure_destination_safe() - self._publish_staged_app( - staged_app, - expected_staged_identity=staged_app_identity, - expected_destination_identity=destination_identity, - ) - except PreservedInstallStateError: - raise - except BaseException: - try: - self._remove_stage_root(stage_root) - except OSError as cleanup_error: - raise PreservedInstallStateError( - "The install failed and its staging directory could not be " - f"removed. Inspect it before retrying: {stage_root}" - ) from cleanup_error - raise - else: - try: - self._remove_stage_root(stage_root) - except OSError as cleanup_error: - raise PreservedInstallStateError( - f"{APP_NAME} was installed and validated, but the staging " - f"directory remains at {stage_root}" - ) from cleanup_error - - print( - f"Installed {APP_NAME} from the current checkout at: " - f"{self.configuration.destination}", - file=self.output, - ) - if self.configuration.signing_identity == "-": - print( - "Signature: ad-hoc with hardened runtime (local use only)", - file=self.output, - ) - else: - print( - f"Signature: {self.configuration.signing_identity}", - file=self.output, - ) - - if self.configuration.launch: - try: - self.runner.run( - [self.toolchain.open, str(self.configuration.destination)] - ) - except InstallerError as launch_error: - raise InstallerError( - f"{APP_NAME} was installed successfully, but launch failed: " - f"{launch_error}" - ) from launch_error - - def _preflight(self) -> None: - if self.host.system != "Darwin": - raise InstallerError("The local installer requires macOS.") - try: - macos_major = int(self.host.macos_version.split(".", maxsplit=1)[0]) - except (ValueError, IndexError) as error: - raise InstallerError( - f"Could not determine the macOS version: {self.host.macos_version!r}" - ) from error - if macos_major < MINIMUM_MACOS_MAJOR: - raise InstallerError( - f"{APP_NAME} requires macOS {MINIMUM_MACOS_MAJOR} or newer; " - f"this Mac reports {self.host.macos_version}." - ) - for tool_path in self.toolchain.required_paths: - if not os.path.isfile(tool_path) or not os.access(tool_path, os.X_OK): - raise InstallerError(f"Required tool is unavailable: {tool_path}") - - arm64_capability = self.runner.run( - [self.toolchain.sysctl, "-in", "hw.optional.arm64"], - capture_output=True, - ).stdout.strip() - if arm64_capability != "1": - raise InstallerError( - f"The verified local build requires an Apple silicon Mac; " - f"this host reports arm64 capability {arm64_capability!r} " - f"from a {self.host.architecture} Python process." - ) - - project_path = ( - self.configuration.repo_root - / "Tools" - / "ReviewMonitor" - / "CodexReviewMonitor.xcodeproj" - ) - resolved_path = ( - project_path - / "project.xcworkspace" - / "xcshareddata" - / "swiftpm" - / "Package.resolved" - ) - for required_path in (project_path, resolved_path): - if not required_path.exists(): - raise InstallerError( - "Run this installer from a complete CodexReviewKit checkout; " - f"missing {required_path}" - ) - - xcode_version = self.runner.run( - [self.toolchain.xcodebuild, "-version"], - capture_output=True, - ).stdout - match = re.search(r"^Xcode (\d+)(?:\.(\d+))?", xcode_version, re.MULTILINE) - if match is None: - raise InstallerError( - f"Could not parse the active Xcode version:\n{xcode_version.strip()}" - ) - version = (int(match.group(1)), int(match.group(2) or 0)) - if version < MINIMUM_XCODE_VERSION: - required = ".".join(str(component) for component in MINIMUM_XCODE_VERSION) - actual = ".".join(str(component) for component in version) - raise InstallerError( - f"The local installer requires Xcode {required} or newer; " - f"the active developer directory provides Xcode {actual}." - ) - - self._ensure_app_not_running() - self._ensure_destination_safe() - - def _ensure_app_not_running(self) -> None: - running_processes = tuple(self.process_checker()) - if running_processes: - process_list = ", ".join(running_processes) - raise InstallerError( - f"Quit {APP_NAME} before installing so active reviews are not " - f"interrupted. Running process IDs: {process_list}" - ) - - def _ensure_destination_safe(self) -> Optional[FilesystemIdentity]: - self._ensure_build_workspace_disjoint_from_destination() - destination = self.configuration.destination - destination_key = _path_comparison_key(destination) - destination_identity = self._filesystem_identity_if_exists(destination) - if destination.is_symlink(): - raise InstallerError( - f"Refusing to replace a symbolic-link destination: {destination}" - ) - if _path_exists(destination): - if not destination.is_dir(): - raise InstallerError( - f"The destination exists but is not an app directory: {destination}" - ) - self._require_expected_bundle_identifier(destination) - system_destination = _absolute_path( - self.applications_directory / APP_BUNDLE_NAME - ) - if destination_key == _path_comparison_key(system_destination): - raise InstallerError( - f"An app already exists at {system_destination}. Remove it " - "manually before installing into /Applications." - ) - - conflicts = [] - for standard_path in standard_installation_paths( - self.home_directory, - self.applications_directory, - ): - candidate = _absolute_path(standard_path) - if ( - _path_comparison_key(candidate) == destination_key - or not _path_exists(candidate) - ): - continue - if candidate.is_symlink() or not candidate.is_dir(): - raise InstallerError( - f"A conflicting standard installation path is not a regular " - f"app directory: {candidate}" - ) - if self._bundle_identifier(candidate) == BUNDLE_IDENTIFIER: - conflicts.append(candidate) - if conflicts: - locations = "\n".join(f" - {path}" for path in conflicts) - raise InstallerError( - "Another CodexReviewMonitor installation would conflict with the " - f"selected destination:\n{locations}" - ) - return destination_identity - - def _ensure_build_workspace_disjoint_from_destination(self) -> None: - destination = self.configuration.destination - derived_data = self.configuration.derived_data_path - destination_parts = _path_comparison_key(destination) - derived_data_parts = _path_comparison_key(derived_data) - destination_contains_build = ( - destination_parts == derived_data_parts[: len(destination_parts)] - ) - build_contains_destination = ( - derived_data_parts == destination_parts[: len(derived_data_parts)] - ) - if destination_contains_build or build_contains_destination: - raise InstallerError( - "The installation destination must be outside Xcode DerivedData:" - f"\n destination: {destination}" - f"\n DerivedData: {derived_data}" - ) - - @staticmethod - def _filesystem_identity_if_exists( - path: Path, - ) -> Optional[FilesystemIdentity]: - if not _path_exists(path): - return None - try: - metadata = os.stat(path, follow_symlinks=False) - except OSError as error: - raise InstallerError( - f"Could not inspect filesystem identity for {path}: {error}" - ) from error - return FilesystemIdentity( - device=metadata.st_dev, - inode=metadata.st_ino, - mode=metadata.st_mode, - ) - - def _require_expected_filesystem_app( - self, - path: Path, - expected_identity: Optional[FilesystemIdentity], - ) -> None: - self._require_filesystem_identity(path, expected_identity) - self._require_expected_bundle_identifier(path) - - def _require_filesystem_identity( - self, - path: Path, - expected_identity: Optional[FilesystemIdentity], - ) -> None: - actual_identity = self._filesystem_identity_if_exists(path) - if expected_identity is None or actual_identity != expected_identity: - raise InstallerError( - f"Filesystem identity changed for the app at {path}" - ) - - def _validate_staged_app(self, staged_app: Path) -> FilesystemIdentity: - staged_identity = self._filesystem_identity_if_exists(staged_app) - if staged_identity is None: - raise InstallerError(f"The staged app disappeared: {staged_app}") - try: - self._validate_app(staged_app) - except BaseException as validation_error: - try: - self._require_filesystem_identity(staged_app, staged_identity) - except InstallerError as identity_error: - raise PreservedInstallStateError( - "The staged app changed during validation. Nothing was deleted; " - f"inspect it at {staged_app}." - ) from identity_error - raise - - try: - self._require_expected_filesystem_app(staged_app, staged_identity) - except InstallerError as identity_error: - raise PreservedInstallStateError( - "The staged app changed during validation. Nothing was deleted; " - f"inspect it at {staged_app}." - ) from identity_error - return staged_identity - - def _move_expected_app_exclusively( - self, - source: Path, - destination: Path, - expected_identity: Optional[FilesystemIdentity], - ) -> None: - try: - self._require_expected_filesystem_app(source, expected_identity) - self.exclusive_rename(source, destination) - self._require_expected_filesystem_app(destination, expected_identity) - except KeyboardInterrupt as error: - raise PreservedInstallStateError( - "Installation was interrupted during an app move. Nothing was " - f"deleted; inspect both paths:\n source: {source}" - f"\n destination: {destination}" - ) from error - except OSError as error: - raise InstallerError( - f"Could not move the app from {source} to {destination}: {error}" - ) from error - except InstallerError as error: - raise PreservedInstallStateError( - "The app identity changed while it was being moved. Nothing was " - f"deleted; inspect both paths:\n source: {source}" - f"\n destination: {destination}" - ) from error - - def _swap_expected_apps( - self, - first: Path, - first_identity: Optional[FilesystemIdentity], - second: Path, - second_identity: Optional[FilesystemIdentity], - ) -> None: - try: - self._require_expected_filesystem_app(first, first_identity) - self._require_expected_filesystem_app(second, second_identity) - self.swap_rename(first, second) - self._require_expected_filesystem_app(first, second_identity) - self._require_expected_filesystem_app(second, first_identity) - except KeyboardInterrupt as error: - raise PreservedInstallStateError( - "Installation was interrupted during an atomic app swap. Nothing " - f"was deleted; inspect both paths:\n first: {first}" - f"\n second: {second}" - ) from error - except OSError as error: - raise InstallerError( - f"Could not atomically swap {first} and {second}: {error}" - ) from error - except InstallerError as error: - raise PreservedInstallStateError( - "An app identity changed during the atomic swap. Nothing was " - f"deleted; inspect both paths:\n first: {first}" - f"\n second: {second}" - ) from error - - def _running_process_ids(self) -> Sequence[str]: - result = self.runner.run( - [self.toolchain.pgrep, "-a", "-f", RUNNING_EXECUTABLE_PATTERN], - check=False, - capture_output=True, - ) - if result.returncode == 1: - return () - if result.returncode != 0: - raise InstallerError( - f"Could not determine whether {APP_NAME} is running: " - f"{result.stderr.strip() or f'exit status {result.returncode}'}" - ) - return tuple(line for line in result.stdout.splitlines() if line) - - @contextmanager - def _exclusive_install_lock(self): - # A created lockfile can be unlinked and replaced while its old inode is - # locked. The system Applications directory is an existing protected inode. - lock_anchor = self.applications_directory - open_flags = ( - os.O_RDONLY - | os.O_DIRECTORY - | os.O_NOFOLLOW - | os.O_CLOEXEC - | os.O_EXLOCK - | os.O_NONBLOCK - ) - try: - descriptor = os.open(str(lock_anchor), open_flags) - except BlockingIOError as error: - raise InstallerError( - f"Another {APP_NAME} installer is already running" - ) from error - except OSError as error: - raise InstallerError( - f"Could not lock the shared installation namespace at " - f"{lock_anchor}: {error}" - ) from error - try: - yield - finally: - os.close(descriptor) - - def _build(self) -> None: - build_product = self.configuration.build_product_path - if _path_exists(build_product): - shutil.rmtree(build_product) - self.configuration.derived_data_path.mkdir(parents=True, exist_ok=True) - self.runner.run( - [ - self.toolchain.xcodebuild, - "build", - "-project", - "Tools/ReviewMonitor/CodexReviewMonitor.xcodeproj", - "-scheme", - APP_NAME, - "-configuration", - "Release", - "-destination", - "generic/platform=macOS", - "-derivedDataPath", - str(self.configuration.derived_data_path), - "-disableAutomaticPackageResolution", - "-onlyUsePackageVersionsFromResolvedFile", - "-skipMacroValidation", - f"ARCHS={self.configuration.architecture}", - "ONLY_ACTIVE_ARCH=NO", - "CODE_SIGNING_ALLOWED=NO", - "CODE_SIGNING_REQUIRED=NO", - "CODE_SIGN_IDENTITY=", - ], - cwd=self.configuration.repo_root, - ) - if not build_product.is_dir(): - raise InstallerError( - f"Xcode completed without producing the expected app: {build_product}" - ) - self._assert_no_quarantine(build_product) - - def _sign(self, app_path: Path) -> None: - self.runner.run( - [ - self.toolchain.codesign, - "--force", - "--options", - "runtime", - "--timestamp=none", - "--sign", - self.configuration.signing_identity, - str(app_path), - ] - ) - - def _validate_app(self, app_path: Path) -> None: - if not app_path.is_dir(): - raise InstallerError(f"Expected an app bundle at: {app_path}") - info_path = app_path / "Contents" / "Info.plist" - try: - with info_path.open("rb") as info_file: - info = plistlib.load(info_file) - except (OSError, plistlib.InvalidFileException) as error: - raise InstallerError(f"Could not read {info_path}: {error}") from error - - self._require_expected_bundle_identifier(app_path, info=info) - executable_name = info.get("CFBundleExecutable") - if not isinstance(executable_name, str) or not executable_name: - raise InstallerError(f"CFBundleExecutable is missing from {info_path}") - executable_path = app_path / "Contents" / "MacOS" / executable_name - if not executable_path.is_file() or not os.access(executable_path, os.X_OK): - raise InstallerError( - f"The app executable is missing or not executable: {executable_path}" - ) - - self.runner.run( - [ - self.toolchain.codesign, - "--verify", - "--deep", - "--strict", - "--verbose=2", - str(app_path), - ] - ) - signature_description = self.runner.run( - [self.toolchain.codesign, "--display", "--verbose=4", str(app_path)], - capture_output=True, - ) - signature_output = ( - signature_description.stdout + "\n" + signature_description.stderr - ) - code_directory_line = next( - ( - line - for line in signature_output.splitlines() - if line.startswith("CodeDirectory ") - ), - "", - ) - if "runtime" not in code_directory_line: - raise InstallerError( - f"The app signature does not enable hardened runtime: {app_path}" - ) - - architectures = self.runner.run( - [self.toolchain.lipo, "-archs", str(executable_path)], - capture_output=True, - ).stdout.split() - if architectures != [self.configuration.architecture]: - raise InstallerError( - f"Expected a thin {self.configuration.architecture} executable at " - f"{executable_path}, found: {' '.join(architectures) or '(none)'}" - ) - self._assert_no_quarantine(app_path) - - def _bundle_identifier(self, app_path: Path) -> object: - info_path = app_path / "Contents" / "Info.plist" - try: - with info_path.open("rb") as info_file: - return plistlib.load(info_file).get("CFBundleIdentifier") - except (OSError, plistlib.InvalidFileException) as error: - raise InstallerError(f"Could not read {info_path}: {error}") from error - - def _require_expected_bundle_identifier( - self, - app_path: Path, - *, - info: Optional[dict] = None, - ) -> None: - bundle_identifier = ( - info.get("CFBundleIdentifier") - if info is not None - else self._bundle_identifier(app_path) - ) - if bundle_identifier != BUNDLE_IDENTIFIER: - raise InstallerError( - f"Unexpected bundle identifier in {app_path}: " - f"{bundle_identifier!r}" - ) - - def _assert_no_quarantine(self, app_path: Path) -> None: - metadata = self.runner.run( - [self.toolchain.xattr, "-lr", str(app_path)], - capture_output=True, - ) - output = metadata.stdout + "\n" + metadata.stderr - if QUARANTINE_ATTRIBUTE in output: - raise InstallerError( - f"The locally built app unexpectedly has {QUARANTINE_ATTRIBUTE}. " - "The installer will not remove security metadata automatically: " - f"{app_path}" - ) - - def _publish_staged_app( - self, - staged_app: Path, - *, - expected_staged_identity: FilesystemIdentity, - expected_destination_identity: Optional[FilesystemIdentity], - ) -> None: - destination = self.configuration.destination - current_destination_identity = self._filesystem_identity_if_exists(destination) - if current_destination_identity != expected_destination_identity: - raise InstallerError( - "The destination changed after validation and was not modified: " - f"{destination}" - ) - - replacing_existing_app = expected_destination_identity is not None - if replacing_existing_app: - # Keep the previous local app in private staging instead of creating - # a shared backup pathname that needs its own recovery protocol. - try: - self._swap_expected_apps( - staged_app, - expected_staged_identity, - destination, - expected_destination_identity, - ) - except PreservedInstallStateError: - raise - except InstallerError as error: - raise InstallerError( - f"Could not atomically replace the app at {destination}" - ) from error - else: - try: - self._move_expected_app_exclusively( - staged_app, - destination, - expected_staged_identity, - ) - except PreservedInstallStateError: - raise - except InstallerError as error: - raise InstallerError( - f"Could not install the app at {destination}" - ) from error - - @staticmethod - def _remove_stage_root(stage_root: Path) -> None: - if _path_exists(stage_root): - shutil.rmtree(stage_root) - - -def parse_arguments(arguments: Optional[Sequence[str]] = None) -> argparse.Namespace: - parser = argparse.ArgumentParser( - description=( - "Build CodexReviewMonitor from the current checkout and install it " - "for local use." - ) - ) - parser.add_argument( - "--destination", - type=Path, - help=( - f"app path to install or update (default: " - f"/Applications/{APP_BUNDLE_NAME})" - ), - ) - parser.add_argument( - "--signing-identity", - default="-", - metavar="IDENTITY", - help=( - "codesign identity permitted by the local environment " - "(default: '-' for ad-hoc signing)" - ), - ) - parser.add_argument( - "--launch", - action="store_true", - help="launch the installed app after validation", - ) - return parser.parse_args(arguments) - - -def main(arguments: Optional[Sequence[str]] = None) -> int: - parsed = parse_arguments(arguments) - if not parsed.signing_identity: - print("error: --signing-identity must not be empty", file=sys.stderr) - return 2 - - repo_root = Path(__file__).resolve().parent.parent - try: - destination = select_destination(parsed.destination) - configuration = InstallerConfiguration( - repo_root=repo_root, - destination=destination, - signing_identity=parsed.signing_identity, - launch=parsed.launch, - ) - ReviewMonitorInstaller(configuration).install() - except InstallerError as error: - print(f"error: {error}", file=sys.stderr) - return 1 - except OSError as error: - print(f"error: filesystem operation failed: {error}", file=sys.stderr) - return 1 - except KeyboardInterrupt: - print("error: installation interrupted", file=sys.stderr) - return 130 - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/tests/test_build_review_monitor.py b/scripts/tests/test_build_review_monitor.py new file mode 100644 index 00000000..4a4cc172 --- /dev/null +++ b/scripts/tests/test_build_review_monitor.py @@ -0,0 +1,295 @@ +from __future__ import annotations + +import contextlib +import io +import os +import plistlib +import shutil +import subprocess +import sys +import tempfile +import unittest +from datetime import datetime +from pathlib import Path +from unittest import mock + + +SCRIPTS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(SCRIPTS)) + +import build_review_monitor as builder # noqa: E402 + + +def create_app(path: Path, marker: str = "new") -> None: + executable_directory = path / "Contents" / "MacOS" + executable_directory.mkdir(parents=True, exist_ok=True) + (executable_directory / builder.APP_NAME).write_text("#!/bin/sh\nexit 0\n") + with (path / "Contents" / "Info.plist").open("wb") as file: + plistlib.dump({"CFBundleIdentifier": "lynnpd.CodexReviewMonitor"}, file) + (path / "Contents" / "marker").write_text(marker) + + +class FakeCommands: + def __init__(self) -> None: + self.calls = [] + self.failure = None + + def __call__(self, arguments, **kwargs): + command = [str(argument) for argument in arguments] + self.calls.append((command, kwargs)) + executable = Path(command[0]).name + if "venv" in command: + python = Path(command[-1]) / "bin" / "python3" + python.parent.mkdir(parents=True) + python.touch() + elif "pip" in command: + if self.failure == "dependencies": + raise subprocess.CalledProcessError(1, command) + elif executable == "xcodebuild": + if self.failure == "build": + raise subprocess.CalledProcessError(1, command) + derived_data = Path(command[command.index("-derivedDataPath") + 1]) + create_app(derived_data / "Build/Products/Release" / builder.APP_BUNDLE_NAME) + elif executable == "ditto": + shutil.copytree(command[-2], command[-1], symlinks=True) + elif executable == "codesign": + if self.failure == "sign" and "--sign" in command: + raise subprocess.CalledProcessError(1, command) + elif command[1].endswith("build_dmg.py"): + archive = Path(command[-1]) + archive.write_bytes(b"validated image") + if self.failure == "package": + raise subprocess.CalledProcessError(1, command) + else: + raise AssertionError(f"Unexpected command: {command}") + return subprocess.CompletedProcess(command, 0) + + +class LocalBuildTests(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.repo = self.root / "Checkout With Spaces" + self.dist = self.repo / "dist" + self.dist.mkdir(parents=True) + self.archive = self.dist / "CodexReviewMonitor_261003_1430.dmg" + self.commands = FakeCommands() + self.addCleanup(mock.patch.stopall) + mock.patch.object(builder.subprocess, "run", side_effect=self.commands).start() + mock.patch.object(builder, "datetime").start().now.return_value = datetime(2026, 10, 3, 14, 30) + self.output = contextlib.redirect_stdout(io.StringIO()) + self.output.__enter__() + self.addCleanup(self.output.__exit__, None, None, None) + + def test_build_creates_timestamped_dmg_without_changing_installed_app(self): + installed_app = self.root / "Applications" / builder.APP_BUNDLE_NAME + create_app(installed_app, marker="installed") + older_archive = self.dist / "CodexReviewMonitor_261002_1000.dmg" + older_archive.write_bytes(b"older image") + + archive = builder.build(self.repo, self.dist) + + self.assertEqual(archive, self.archive) + self.assertEqual(archive.read_bytes(), b"validated image") + self.assertEqual(older_archive.read_bytes(), b"older image") + self.assertEqual((installed_app / "Contents/marker").read_text(), "installed") + self.assertEqual(set(self.dist.iterdir()), {older_archive, self.archive}) + commands = [command for command, _ in self.commands.calls] + self.assertFalse(any(Path(command[0]).name in {"pgrep", "open", "killall"} for command in commands)) + self.assertFalse(list(self.dist.glob(".build-review-monitor-*"))) + + def test_build_preserves_existing_release_app(self): + previous_app = self.dist / "arm64" / builder.APP_BUNDLE_NAME + create_app(previous_app, marker="previous") + + archive = builder.build(self.repo, self.dist) + + self.assertEqual(archive.read_bytes(), b"validated image") + self.assertEqual((previous_app / "Contents/marker").read_text(), "previous") + self.assertFalse(list(self.dist.glob(".build-review-monitor-*"))) + + def test_rebuild_keeps_caches_and_reuses_packaging_environment(self): + builder.build(self.repo, self.dist) + cache = self.repo / ".build/local-dmg-arm64/cache-marker" + cache.write_text("cached") + self.commands.calls.clear() + + builder.build(self.repo, self.dist) + + self.assertEqual(cache.read_text(), "cached") + self.assertFalse(any("venv" in command for command, _ in self.commands.calls)) + self.assertEqual(self.archive.read_bytes(), b"validated image") + + def test_failures_preserve_previous_outputs(self): + previous_app = self.dist / "arm64" / builder.APP_BUNDLE_NAME + create_app(previous_app, marker="previous") + for failure in ("dependencies", "build", "sign", "package"): + with self.subTest(failure=failure): + self.archive.write_bytes(b"previous image") + self.commands.failure = failure + with self.assertRaises(subprocess.CalledProcessError): + builder.build(self.repo, self.dist) + self.assertEqual(self.archive.read_bytes(), b"previous image") + self.assertEqual((previous_app / "Contents/marker").read_text(), "previous") + self.assertFalse(list(self.dist.glob(".build-review-monitor-*"))) + + def test_archive_destination_directory_is_preserved(self): + previous_app = self.dist / "arm64" / builder.APP_BUNDLE_NAME + create_app(previous_app, marker="previous") + self.archive.mkdir() + collision_marker = self.archive / "marker" + collision_marker.write_text("existing directory") + + with self.assertRaises(OSError): + builder.build(self.repo, self.dist) + + self.assertEqual(collision_marker.read_text(), "existing directory") + self.assertEqual((previous_app / "Contents/marker").read_text(), "previous") + self.assertFalse(list(self.dist.glob(".build-review-monitor-*"))) + + def test_archive_publication_failure_preserves_previous_outputs(self): + previous_app = self.dist / "arm64" / builder.APP_BUNDLE_NAME + create_app(previous_app, marker="previous") + self.archive.write_bytes(b"previous image") + failure = PermissionError("archive publication denied") + native_replace = Path.replace + + def replace(source, destination): + if destination == self.archive: + raise failure + return native_replace(source, destination) + + with mock.patch.object(Path, "replace", autospec=True, side_effect=replace): + with self.assertRaises(PermissionError) as error: + builder.build(self.repo, self.dist) + + self.assertIs(error.exception, failure) + self.assertEqual(self.archive.read_bytes(), b"previous image") + self.assertEqual((previous_app / "Contents/marker").read_text(), "previous") + self.assertFalse(list(self.dist.glob(".build-review-monitor-*"))) + + def test_explicit_signing_identity_is_one_argument(self): + identity = "Apple Development: Developer Name (TEAMID)" + builder.build(self.repo, self.dist, identity) + command = next(command for command, _ in self.commands.calls if "--sign" in command) + self.assertEqual(command[command.index("--sign") + 1], identity) + self.assertEqual(command[command.index("--options") + 1], "runtime") + self.assertIn("--timestamp=none", command) + + def test_copy_preserves_metadata_even_when_dittonorsrc_is_set(self): + with mock.patch.dict(os.environ, {"DITTONORSRC": "1"}): + builder.build(self.repo, self.dist) + command, kwargs = next(call for call in self.commands.calls if Path(call[0][0]).name == "ditto") + self.assertTrue({"--rsrc", "--extattr", "--acl", "--qtn"}.issubset(command)) + self.assertNotIn("DITTONORSRC", kwargs["env"]) + _, packaging_kwargs = next(call for call in self.commands.calls if call[0][1].endswith("build_dmg.py")) + self.assertEqual(packaging_kwargs["env"], kwargs["env"]) + + def test_help_does_not_build_or_prepare_dependencies(self): + with self.assertRaises(SystemExit) as result: + builder.main(["--help"]) + self.assertEqual(result.exception.code, 0) + self.assertEqual(self.commands.calls, []) + + +@unittest.skipUnless( + os.environ.get("RUN_LOCAL_BUILD_INTEGRATION") == "1", + "set RUN_LOCAL_BUILD_INTEGRATION=1 to build and validate a real local DMG", +) +class LocalBuildIntegrationTests(unittest.TestCase): + def test_dmg_preserves_resource_metadata_when_dittonorsrc_is_set(self): + native_run = subprocess.run + attribute = "com.example.codex-review-local-fixture" + metadata = b"local DMG metadata fixture" + with tempfile.TemporaryDirectory(prefix="local-build-metadata-") as directory: + root = Path(directory) + repo = root / "checkout" + (repo / "scripts").mkdir(parents=True) + (repo / "scripts/build_dmg.py").symlink_to(SCRIPTS / "build_dmg.py") + + def build_fixture(arguments, **kwargs): + command = [str(argument) for argument in arguments] + if len(command) > 1 and command[1].endswith("build_dmg.py"): + resource = Path(command[2]) / "Contents/Resources/metadata.txt" + attributes = native_run( + ["/usr/bin/xattr", "-p", attribute, str(resource)], + capture_output=True, check=True, + ) + self.assertEqual(attributes.stdout.rstrip(b"\n"), metadata) + if Path(command[0]).name != "xcodebuild": + return native_run(arguments, **kwargs) + derived_data = Path(command[command.index("-derivedDataPath") + 1]) + app = derived_data / "Build/Products/Release" / builder.APP_BUNDLE_NAME + (app / "Contents/MacOS").mkdir(parents=True) + with (app / "Contents/Info.plist").open("wb") as file: + plistlib.dump({ + "CFBundleExecutable": builder.APP_NAME, + "CFBundleIdentifier": "com.example.codex-review-local-fixture", + "CFBundlePackageType": "APPL", + "CFBundleVersion": "1", + }, file) + resource = app / "Contents/Resources/metadata.txt" + resource.parent.mkdir() + resource.write_text("metadata fixture\n") + native_run(["/usr/bin/xattr", "-w", attribute, metadata.decode(), str(resource)], check=True) + native_run( + ["/usr/bin/xcrun", "clang", "-arch", "arm64", "-x", "c", "-", "-o", + str(app / "Contents/MacOS" / builder.APP_NAME)], + input="int main(void) { return 0; }\n", text=True, check=True, + ) + return subprocess.CompletedProcess(command, 0) + + python = builder.packaging_python(SCRIPTS.parent) + with mock.patch.dict(os.environ, {"DITTONORSRC": "1"}), \ + mock.patch.object(builder, "packaging_python", return_value=python), \ + mock.patch.object(builder.subprocess, "run", side_effect=build_fixture): + archive = builder.build(repo, root / "dist") + + mount = root / "mount" + mount.mkdir() + try: + native_run( + ["hdiutil", "attach", "-readonly", "-nobrowse", "-mountpoint", str(mount), str(archive)], + check=True, + ) + resource = mount / builder.APP_BUNDLE_NAME / "Contents/Resources/metadata.txt" + self.assertEqual( + subprocess.check_output(["/usr/bin/xattr", "-p", attribute, str(resource)]).rstrip(b"\n"), + metadata, + ) + finally: + if os.path.ismount(mount): + native_run(["hdiutil", "detach", "-force", str(mount)], check=True) + + def test_real_build_sign_and_dmg(self): + repo_root = SCRIPTS.parent + with tempfile.TemporaryDirectory(prefix="local-build-integration-") as directory: + root = Path(directory) + archive = builder.build(repo_root, root / "dist") + self.assertRegex(archive.name, r"^CodexReviewMonitor_\d{6}_\d{4}\.dmg$") + mount = root / "mount" + mount.mkdir() + try: + subprocess.run( + ["hdiutil", "attach", "-readonly", "-nobrowse", "-mountpoint", str(mount), str(archive)], + check=True, + ) + app = mount / builder.APP_BUNDLE_NAME + with (app / "Contents/Info.plist").open("rb") as file: + self.assertEqual(plistlib.load(file)["CFBundleIdentifier"], "lynnpd.CodexReviewMonitor") + subprocess.run(["codesign", "--verify", "--deep", "--strict", str(app)], check=True) + signature = subprocess.run( + ["codesign", "--display", "--verbose=4", str(app)], + capture_output=True, text=True, check=True, + ) + self.assertIn("runtime", signature.stderr) + self.assertTrue((mount / "\u200b").is_symlink()) + self.assertEqual(os.readlink(mount / "\u200b"), "/Applications") + finally: + if os.path.ismount(mount): + subprocess.run(["hdiutil", "detach", "-force", str(mount)], check=True) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_install_review_monitor.py b/scripts/tests/test_install_review_monitor.py deleted file mode 100644 index ded73664..00000000 --- a/scripts/tests/test_install_review_monitor.py +++ /dev/null @@ -1,927 +0,0 @@ -from __future__ import annotations - -import contextlib -import io -import os -import plistlib -import shutil -import sys -import tempfile -import unittest -from pathlib import Path -from typing import Mapping, Optional, Sequence -from unittest import mock - - -SCRIPTS_DIRECTORY = Path(__file__).resolve().parents[1] -sys.path.insert(0, str(SCRIPTS_DIRECTORY)) - -import install_review_monitor as installer # noqa: E402 - - -def create_app( - path: Path, - *, - bundle_identifier: str = installer.BUNDLE_IDENTIFIER, - marker: str = "new", -) -> None: - executable_directory = path / "Contents" / "MacOS" - resources_directory = path / "Contents" / "Resources" - executable_directory.mkdir(parents=True) - resources_directory.mkdir(parents=True) - info = { - "CFBundleExecutable": installer.APP_NAME, - "CFBundleIdentifier": bundle_identifier, - "CFBundlePackageType": "APPL", - } - with (path / "Contents" / "Info.plist").open("wb") as info_file: - plistlib.dump(info, info_file) - executable = executable_directory / installer.APP_NAME - executable.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") - executable.chmod(0o755) - (resources_directory / "build-marker.txt").write_text( - marker, - encoding="utf-8", - ) - - -class FakeRunner: - def __init__(self) -> None: - self.calls: list[tuple[list[str], Optional[dict[str, str]]]] = [] - self.xcode_version = "Xcode 27.0\nBuild version 27A5252f\n" - self.architectures = "arm64\n" - self.runtime_signature = True - self.fail_build = False - self.xattr_count = 0 - self.quarantine_at_xattr_call: Optional[int] = None - self.fail_xattr_at: Optional[int] = None - self.pgrep_returncode = 1 - self.pgrep_stdout = "" - self.arm64_capability = "1\n" - self.open_failure = False - self.app_to_create_after_build: Optional[Path] = None - - def run( - self, - arguments: Sequence[str], - *, - cwd: Optional[Path] = None, - check: bool = True, - capture_output: bool = False, - environment: Optional[Mapping[str, str]] = None, - ) -> installer.CommandResult: - del cwd, capture_output - command = [str(argument) for argument in arguments] - recorded_environment = dict(environment) if environment is not None else None - self.calls.append((command, recorded_environment)) - executable = Path(command[0]).name - - if executable == "xcodebuild" and command[1:] == ["-version"]: - return installer.CommandResult(0, stdout=self.xcode_version) - if executable == "xcodebuild" and command[1] == "build": - if self.fail_build: - raise installer.InstallerError("simulated build failure") - derived_data = Path(command[command.index("-derivedDataPath") + 1]) - create_app( - derived_data - / "Build" - / "Products" - / "Release" - / installer.APP_BUNDLE_NAME - ) - if self.app_to_create_after_build is not None: - create_app( - self.app_to_create_after_build, - marker="external installation", - ) - return installer.CommandResult(0) - if executable == "ditto": - source = Path(command[-2]) - destination = Path(command[-1]) - shutil.copytree(source, destination, symlinks=True) - return installer.CommandResult(0) - if executable == "codesign" and "--verify" in command: - return installer.CommandResult(0) - if executable == "codesign" and "--display" in command: - flags = "adhoc,runtime" if self.runtime_signature else "adhoc" - return installer.CommandResult( - 0, - stderr=f"CodeDirectory v=20500 flags=0x10002({flags})\n", - ) - if executable == "codesign": - return installer.CommandResult(0) - if executable == "lipo": - return installer.CommandResult(0, stdout=self.architectures) - if executable == "xattr": - self.xattr_count += 1 - if self.fail_xattr_at == self.xattr_count: - raise installer.InstallerError("simulated xattr inspection failure") - if self.quarantine_at_xattr_call == self.xattr_count: - return installer.CommandResult( - 0, - stdout=( - f"{command[-1]}/Contents/MacOS/{installer.APP_NAME}: " - f"{installer.QUARANTINE_ATTRIBUTE}: 0081;...\n" - ), - ) - return installer.CommandResult(0) - if executable == "pgrep": - result = installer.CommandResult( - self.pgrep_returncode, - stdout=self.pgrep_stdout, - stderr="pgrep failed\n" if self.pgrep_returncode > 1 else "", - ) - if check and result.returncode != 0: - raise installer.InstallerError("simulated pgrep failure") - return result - if executable == "sysctl": - return installer.CommandResult(0, stdout=self.arm64_capability) - if executable == "open": - if self.open_failure: - raise installer.InstallerError("simulated launch failure") - return installer.CommandResult(0) - raise AssertionError(f"Unexpected command: {command}") - - def commands_named(self, executable_name: str) -> list[list[str]]: - return [ - command - for command, _ in self.calls - if Path(command[0]).name == executable_name - ] - - -class InstallerTestCase(unittest.TestCase): - def setUp(self) -> None: - self.temporary_directory = tempfile.TemporaryDirectory() - self.addCleanup(self.temporary_directory.cleanup) - self.root = Path(self.temporary_directory.name) - self.repo_root = self.root / "CodexReviewKit" - project = ( - self.repo_root - / "Tools" - / "ReviewMonitor" - / "CodexReviewMonitor.xcodeproj" - ) - resolved = ( - project - / "project.xcworkspace" - / "xcshareddata" - / "swiftpm" - / "Package.resolved" - ) - resolved.parent.mkdir(parents=True) - resolved.write_text("{}\n", encoding="utf-8") - - self.home = self.root / "Home With Spaces" - self.applications_directory = self.root / "System Applications" - self.applications_directory.mkdir() - self.destination = ( - self.home / "Applications" / installer.APP_BUNDLE_NAME - ) - self.toolchain = self._create_fake_toolchain() - self.host = installer.HostEnvironment( - system="Darwin", - macos_version="26.6.2", - architecture="arm64", - ) - self.runner = FakeRunner() - self.output = io.StringIO() - - def _create_fake_toolchain(self) -> installer.Toolchain: - tools_directory = self.root / "tools" - tools_directory.mkdir() - tool_paths = {} - for name in ( - "xcodebuild", - "codesign", - "ditto", - "lipo", - "pgrep", - "sysctl", - "open", - "xattr", - ): - path = tools_directory / name - path.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") - path.chmod(0o755) - tool_paths[name] = str(path) - return installer.Toolchain(**tool_paths) - - def make_installer( - self, - *, - destination: Optional[Path] = None, - signing_identity: str = "-", - launch: bool = False, - process_checker=lambda: (), - exclusive_rename=installer.rename_exclusive, - swap_rename=installer.rename_swap, - applications_directory: Optional[Path] = None, - ) -> installer.ReviewMonitorInstaller: - configuration = installer.InstallerConfiguration( - repo_root=self.repo_root, - destination=destination or self.destination, - signing_identity=signing_identity, - launch=launch, - ) - return installer.ReviewMonitorInstaller( - configuration, - runner=self.runner, - host=self.host, - toolchain=self.toolchain, - process_checker=process_checker, - exclusive_rename=exclusive_rename, - swap_rename=swap_rename, - home_directory=self.home, - applications_directory=( - applications_directory or self.applications_directory - ), - output=self.output, - ) - - def marker(self, app_path: Path) -> str: - return ( - app_path - / "Contents" - / "Resources" - / "build-marker.txt" - ).read_text(encoding="utf-8") - - def staging_paths(self) -> list[Path]: - if not self.destination.parent.exists(): - return [] - return list( - self.destination.parent.glob(f".{installer.APP_NAME}.install-*") - ) - - def test_destination_defaults_to_system_applications(self) -> None: - selected = installer.select_destination( - None, - home_directory=self.home, - applications_directory=self.applications_directory, - ) - - self.assertEqual( - selected, - self.applications_directory / installer.APP_BUNDLE_NAME, - ) - - def test_default_destination_is_deployed_to_system_applications(self) -> None: - selected = installer.select_destination( - None, - home_directory=self.home, - applications_directory=self.applications_directory, - ) - - self.make_installer(destination=selected).install() - - self.assertEqual(self.marker(selected), "new") - - def test_standard_destination_case_alias_is_canonicalized(self) -> None: - selected = installer.select_destination( - self.home / "applications" / installer.APP_BUNDLE_NAME, - home_directory=self.home, - applications_directory=self.applications_directory, - ) - create_app(self.destination, marker="old") - - self.make_installer(destination=selected).install() - - self.assertEqual(selected, self.destination) - self.assertEqual(self.marker(self.destination), "new") - - def test_destination_requires_expected_bundle_name(self) -> None: - with self.assertRaisesRegex(installer.InstallerError, "must end with"): - installer.select_destination( - self.root / "Other.app", - home_directory=self.home, - applications_directory=self.applications_directory, - ) - - def test_new_install_builds_signs_and_validates_before_publish(self) -> None: - self.make_installer().install() - - self.assertEqual(self.marker(self.destination), "new") - self.assertEqual(self.staging_paths(), []) - - build_command = self.runner.commands_named("xcodebuild")[1] - self.assertIn("-configuration", build_command) - self.assertIn("Release", build_command) - self.assertIn("-onlyUsePackageVersionsFromResolvedFile", build_command) - self.assertIn("ARCHS=arm64", build_command) - self.assertIn("CODE_SIGNING_ALLOWED=NO", build_command) - - sign_command = next( - command - for command in self.runner.commands_named("codesign") - if "--sign" in command - ) - self.assertIn("runtime", sign_command) - self.assertIn("--timestamp=none", sign_command) - self.assertEqual(sign_command[sign_command.index("--sign") + 1], "-") - self.assertNotIn("--deep", sign_command) - - ditto_call = next( - call for call in self.runner.calls if Path(call[0][0]).name == "ditto" - ) - ditto_command, ditto_environment = ditto_call - self.assertTrue({"--rsrc", "--extattr", "--acl", "--qtn"}.issubset(ditto_command)) - self.assertIsNotNone(ditto_environment) - self.assertNotIn("DITTONORSRC", ditto_environment) - self.assertEqual(self.runner.commands_named("open"), []) - - def test_existing_app_is_replaced_only_after_new_app_validates(self) -> None: - create_app(self.destination, marker="old") - - self.make_installer().install() - - self.assertEqual(self.marker(self.destination), "new") - - def test_build_failure_preserves_existing_app(self) -> None: - create_app(self.destination, marker="old") - self.runner.fail_build = True - - with self.assertRaisesRegex(installer.InstallerError, "build failure"): - self.make_installer().install() - - self.assertEqual(self.marker(self.destination), "old") - - def test_build_product_destination_is_rejected_before_cleanup(self) -> None: - build_product = self.make_installer().configuration.build_product_path - create_app(build_product, marker="existing destination") - - with self.assertRaisesRegex(installer.InstallerError, "outside Xcode"): - self.make_installer(destination=build_product).install() - - self.assertEqual(self.marker(build_product), "existing destination") - self.assertEqual( - [ - command - for command in self.runner.commands_named("xcodebuild") - if len(command) > 1 and command[1] == "build" - ], - [], - ) - self.assertEqual(self.runner.commands_named("ditto"), []) - - def test_nested_build_product_destination_is_rejected_before_cleanup(self) -> None: - build_product = self.make_installer().configuration.build_product_path - destination = build_product / "Nested" / installer.APP_BUNDLE_NAME - create_app(destination, marker="nested destination") - - with self.assertRaisesRegex(installer.InstallerError, "outside Xcode"): - self.make_installer(destination=destination).install() - - self.assertEqual(self.marker(destination), "nested destination") - self.assertEqual( - [ - command - for command in self.runner.commands_named("xcodebuild") - if len(command) > 1 and command[1] == "build" - ], - [], - ) - - def test_build_product_destination_via_parent_symlink_is_rejected(self) -> None: - build_product = self.make_installer().configuration.build_product_path - destination = build_product / "Nested" / installer.APP_BUNDLE_NAME - create_app(destination, marker="aliased destination") - alias = self.root / "Build Product Alias" - alias.symlink_to(build_product, target_is_directory=True) - aliased_destination = alias / "Nested" / installer.APP_BUNDLE_NAME - - with self.assertRaisesRegex(installer.InstallerError, "outside Xcode"): - self.make_installer(destination=aliased_destination).install() - - self.assertEqual(self.marker(destination), "aliased destination") - self.assertEqual( - [ - command - for command in self.runner.commands_named("xcodebuild") - if len(command) > 1 and command[1] == "build" - ], - [], - ) - self.assertEqual(self.runner.commands_named("ditto"), []) - - def test_derived_data_name_prefix_does_not_overlap(self) -> None: - derived_data = self.make_installer().configuration.derived_data_path - destination = ( - derived_data.parent - / f"{derived_data.name}-copy" - / installer.APP_BUNDLE_NAME - ) - - self.make_installer(destination=destination).install() - - self.assertEqual(self.marker(destination), "new") - - def test_destination_containing_derived_data_is_rejected(self) -> None: - destination = self.root / "Workspace" / installer.APP_BUNDLE_NAME - configuration = installer.InstallerConfiguration( - repo_root=destination / "Checkout", - destination=destination, - signing_identity="-", - launch=False, - ) - local_installer = installer.ReviewMonitorInstaller( - configuration, - runner=self.runner, - host=self.host, - toolchain=self.toolchain, - process_checker=lambda: (), - home_directory=self.home, - applications_directory=self.applications_directory, - output=self.output, - ) - - with self.assertRaisesRegex(installer.InstallerError, "outside Xcode"): - local_installer._ensure_destination_safe() - - def test_running_app_fails_before_build(self) -> None: - with self.assertRaisesRegex(installer.InstallerError, "active reviews"): - self.make_installer(process_checker=lambda: ("123",)).install() - - build_commands = [ - command - for command in self.runner.commands_named("xcodebuild") - if len(command) > 1 and command[1] == "build" - ] - self.assertEqual(build_commands, []) - - def test_pgrep_inspection_failure_is_not_treated_as_not_running(self) -> None: - self.runner.pgrep_returncode = 2 - local_installer = self.make_installer(process_checker=None) - local_installer.process_checker = local_installer._running_process_ids - - with self.assertRaisesRegex(installer.InstallerError, "Could not determine"): - local_installer.install() - - def test_running_process_check_matches_the_full_app_executable_path(self) -> None: - local_installer = self.make_installer(process_checker=None) - local_installer.process_checker = local_installer._running_process_ids - - self.assertEqual(local_installer._running_process_ids(), ()) - - pgrep_command = self.runner.commands_named("pgrep")[-1] - self.assertEqual(pgrep_command[1:3], ["-a", "-f"]) - self.assertEqual(pgrep_command[3], installer.RUNNING_EXECUTABLE_PATTERN) - self.assertTrue(pgrep_command[3].startswith("^")) - self.assertIn("Contents/MacOS/CodexReviewMonitor", pgrep_command[3]) - - def test_rosetta_process_is_allowed_on_an_apple_silicon_host(self) -> None: - self.host = installer.HostEnvironment( - system="Darwin", - macos_version="26.6.2", - architecture="x86_64", - ) - - self.make_installer().install() - - self.assertTrue(self.destination.exists()) - - def test_physical_host_without_arm64_capability_is_rejected(self) -> None: - self.host = installer.HostEnvironment( - system="Darwin", - macos_version="26.6.2", - architecture="x86_64", - ) - self.runner.arm64_capability = "0\n" - - with self.assertRaisesRegex(installer.InstallerError, "Apple silicon"): - self.make_installer().install() - - self.assertFalse(self.destination.exists()) - - def test_conflicting_system_installation_fails_before_build(self) -> None: - system_app = self.applications_directory / installer.APP_BUNDLE_NAME - create_app(system_app) - - with self.assertRaisesRegex(installer.InstallerError, "would conflict"): - self.make_installer().install() - - self.assertFalse(self.destination.exists()) - - def test_existing_system_destination_requires_manual_removal(self) -> None: - system_app = self.applications_directory / installer.APP_BUNDLE_NAME - create_app(system_app, marker="system app") - default_destination = installer.select_destination( - None, - home_directory=self.home, - applications_directory=self.applications_directory, - ) - - with self.assertRaisesRegex(installer.InstallerError, "Remove it manually"): - self.make_installer(destination=default_destination).install() - - self.assertEqual(self.marker(system_app), "system app") - self.assertEqual( - [ - command - for command in self.runner.commands_named("xcodebuild") - if len(command) > 1 and command[1] == "build" - ], - [], - ) - - def test_system_destination_is_rechecked_after_build(self) -> None: - system_app = self.applications_directory / installer.APP_BUNDLE_NAME - default_destination = installer.select_destination( - None, - home_directory=self.home, - applications_directory=self.applications_directory, - ) - self.runner.app_to_create_after_build = system_app - - with self.assertRaisesRegex(installer.InstallerError, "Remove it manually"): - self.make_installer(destination=default_destination).install() - - self.assertEqual(self.marker(system_app), "external installation") - build_commands = [ - command - for command in self.runner.commands_named("xcodebuild") - if len(command) > 1 and command[1] == "build" - ] - self.assertEqual(len(build_commands), 1) - self.assertEqual( - list( - self.applications_directory.glob( - f".{installer.APP_NAME}.install-*" - ) - ), - [], - ) - - def test_different_bundle_at_standard_path_does_not_conflict(self) -> None: - system_app = self.applications_directory / installer.APP_BUNDLE_NAME - create_app(system_app, bundle_identifier="example.Unrelated") - - self.make_installer().install() - - self.assertTrue(self.destination.exists()) - self.assertEqual( - self.make_installer()._bundle_identifier(system_app), - "example.Unrelated", - ) - - def test_wrong_bundle_at_destination_is_never_replaced(self) -> None: - create_app(self.destination, bundle_identifier="example.Unrelated", marker="old") - - with self.assertRaisesRegex(installer.InstallerError, "Unexpected bundle"): - self.make_installer().install() - - self.assertEqual(self.marker(self.destination), "old") - - def test_symbolic_link_destination_is_never_replaced(self) -> None: - actual_app = self.root / "Actual.app" - create_app(actual_app) - self.destination.parent.mkdir(parents=True) - self.destination.symlink_to(actual_app, target_is_directory=True) - - with self.assertRaisesRegex(installer.InstallerError, "symbolic-link"): - self.make_installer().install() - - self.assertTrue(actual_app.exists()) - - def test_quarantine_in_nested_content_stops_before_replacement(self) -> None: - create_app(self.destination, marker="old") - self.runner.quarantine_at_xattr_call = 2 - - with self.assertRaisesRegex(installer.InstallerError, "quarantine"): - self.make_installer().install() - - self.assertEqual(self.marker(self.destination), "old") - self.assertEqual(self.staging_paths(), []) - - def test_quarantine_inspection_failure_stops_before_replacement(self) -> None: - create_app(self.destination, marker="old") - self.runner.fail_xattr_at = 2 - - with self.assertRaisesRegex(installer.InstallerError, "xattr inspection"): - self.make_installer().install() - - self.assertEqual(self.marker(self.destination), "old") - - def test_architecture_mismatch_stops_before_replacement(self) -> None: - create_app(self.destination, marker="old") - self.runner.architectures = "x86_64\n" - - with self.assertRaisesRegex(installer.InstallerError, "thin arm64"): - self.make_installer().install() - - self.assertEqual(self.marker(self.destination), "old") - - def test_missing_runtime_flag_stops_before_replacement(self) -> None: - create_app(self.destination, marker="old") - self.runner.runtime_signature = False - - with self.assertRaisesRegex(installer.InstallerError, "hardened runtime"): - self.make_installer().install() - - self.assertEqual(self.marker(self.destination), "old") - - def test_atomic_swap_failure_leaves_existing_app_unchanged(self) -> None: - create_app(self.destination, marker="old") - - def fail_swap(first: Path, second: Path) -> None: - del first, second - raise OSError("simulated atomic swap failure") - - with self.assertRaisesRegex(installer.InstallerError, "atomically replace"): - self.make_installer(swap_rename=fail_swap).install() - - self.assertEqual(self.marker(self.destination), "old") - self.assertEqual(self.staging_paths(), []) - - def test_exclusive_move_interrupt_after_syscall_preserves_the_app(self) -> None: - source = self.root / "Exclusive Source.app" - destination = self.root / "Exclusive Destination.app" - create_app(source, marker="source") - local_installer = self.make_installer() - source_identity = local_installer._filesystem_identity_if_exists(source) - require_app = local_installer._require_expected_filesystem_app - checks = 0 - - def interrupt_after_move(path: Path, expected_identity) -> None: - nonlocal checks - checks += 1 - if checks == 2: - raise KeyboardInterrupt() - require_app(path, expected_identity) - - local_installer._require_expected_filesystem_app = interrupt_after_move - - with self.assertRaisesRegex( - installer.PreservedInstallStateError, - "interrupted during an app move", - ): - local_installer._move_expected_app_exclusively( - source, - destination, - source_identity, - ) - - self.assertFalse(source.exists()) - self.assertEqual(self.marker(destination), "source") - - def test_swap_interrupt_after_syscall_preserves_both_apps(self) -> None: - first = self.root / "First.app" - second = self.root / "Second.app" - create_app(first, marker="first") - create_app(second, marker="second") - local_installer = self.make_installer() - first_identity = local_installer._filesystem_identity_if_exists(first) - second_identity = local_installer._filesystem_identity_if_exists(second) - require_app = local_installer._require_expected_filesystem_app - checks = 0 - - def interrupt_after_swap(path: Path, expected_identity) -> None: - nonlocal checks - checks += 1 - if checks == 3: - raise KeyboardInterrupt() - require_app(path, expected_identity) - - local_installer._require_expected_filesystem_app = interrupt_after_swap - - with self.assertRaisesRegex( - installer.PreservedInstallStateError, - "interrupted during an atomic app swap", - ): - local_installer._swap_expected_apps( - first, - first_identity, - second, - second_identity, - ) - - self.assertEqual(self.marker(first), "second") - self.assertEqual(self.marker(second), "first") - - def test_new_destination_collision_is_not_replaced(self) -> None: - def occupy_destination_before_publish( - source: Path, - destination: Path, - ) -> None: - create_app(self.destination, marker="external destination") - installer.rename_exclusive(source, destination) - - with self.assertRaisesRegex(installer.InstallerError, "Could not install"): - self.make_installer( - exclusive_rename=occupy_destination_before_publish - ).install() - - self.assertEqual(self.marker(self.destination), "external destination") - self.assertEqual(self.staging_paths(), []) - - def test_app_started_during_build_stops_before_publish(self) -> None: - create_app(self.destination, marker="old") - checks = 0 - - def process_checker(): - nonlocal checks - checks += 1 - return () if checks < 3 else ("456",) - - with self.assertRaisesRegex(installer.InstallerError, "active reviews"): - self.make_installer(process_checker=process_checker).install() - - self.assertEqual(self.marker(self.destination), "old") - self.assertEqual(self.staging_paths(), []) - - def test_staged_app_swap_after_validation_is_preserved(self) -> None: - displaced_staged_app = self.root / "Displaced Validated Staged.app" - checks = 0 - - def process_checker(): - nonlocal checks - checks += 1 - if checks == 3: - stage_root = self.staging_paths()[0] - staged_app = stage_root / installer.APP_BUNDLE_NAME - os.rename(staged_app, displaced_staged_app) - create_app(staged_app, marker="external staged app") - return () - - with self.assertRaisesRegex( - installer.PreservedInstallStateError, - "identity changed while it was being moved", - ): - self.make_installer(process_checker=process_checker).install() - - self.assertFalse(self.destination.exists()) - self.assertEqual(self.marker(displaced_staged_app), "new") - staging_paths = self.staging_paths() - self.assertEqual(len(staging_paths), 1) - self.assertEqual( - self.marker(staging_paths[0] / installer.APP_BUNDLE_NAME), - "external staged app", - ) - - def test_staged_app_swap_during_validation_is_preserved(self) -> None: - displaced_staged_app = self.root / "Displaced During Validation.app" - local_installer = self.make_installer() - validate_app = local_installer._validate_app - - def swap_after_validation(staged_app: Path) -> None: - validate_app(staged_app) - os.rename(staged_app, displaced_staged_app) - create_app(staged_app, marker="external unvalidated app") - - local_installer._validate_app = swap_after_validation - - with self.assertRaisesRegex( - installer.PreservedInstallStateError, - "changed during validation", - ): - local_installer.install() - - self.assertFalse(self.destination.exists()) - self.assertEqual(self.marker(displaced_staged_app), "new") - staging_paths = self.staging_paths() - self.assertEqual(len(staging_paths), 1) - self.assertEqual( - self.marker(staging_paths[0] / installer.APP_BUNDLE_NAME), - "external unvalidated app", - ) - - def test_explicit_identity_is_one_argument_and_launches_after_install(self) -> None: - identity = "Apple Development: Local Developer (ABCDE12345)" - - self.make_installer(signing_identity=identity, launch=True).install() - - sign_command = next( - command - for command in self.runner.commands_named("codesign") - if "--sign" in command - ) - self.assertEqual(sign_command[sign_command.index("--sign") + 1], identity) - open_command = self.runner.commands_named("open")[-1] - self.assertEqual(open_command[-1], str(self.destination)) - - def test_launch_failure_does_not_remove_installed_app(self) -> None: - self.runner.open_failure = True - - with self.assertRaisesRegex(installer.InstallerError, "installed successfully"): - self.make_installer(launch=True).install() - - self.assertEqual(self.marker(self.destination), "new") - - def test_lock_contention_fails_before_build(self) -> None: - first = self.make_installer() - second_destination = self.applications_directory / installer.APP_BUNDLE_NAME - second = self.make_installer(destination=second_destination) - - with first._exclusive_install_lock(): - with self.assertRaisesRegex(installer.InstallerError, "already running"): - second.install() - - build_commands = [ - command - for command in self.runner.commands_named("xcodebuild") - if len(command) > 1 and command[1] == "build" - ] - self.assertEqual(build_commands, []) - - def test_lock_uses_the_existing_system_applications_directory(self) -> None: - local_installer = self.make_installer() - entries_before_lock = list(self.applications_directory.iterdir()) - - with mock.patch.object(installer.os, "open", wraps=os.open) as open_lock: - with local_installer._exclusive_install_lock(): - self.assertEqual( - list(self.applications_directory.iterdir()), - entries_before_lock, - ) - - lock_path, open_flags = open_lock.call_args.args - self.assertEqual(Path(lock_path), self.applications_directory) - for required_flag in ( - os.O_DIRECTORY, - os.O_NOFOLLOW, - os.O_CLOEXEC, - os.O_EXLOCK, - os.O_NONBLOCK, - ): - self.assertTrue(open_flags & required_flag) - self.assertFalse(open_flags & os.O_CREAT) - - def test_invalid_shared_lock_anchors_are_rejected(self) -> None: - real_directory = self.root / "Real Applications" - real_directory.mkdir() - invalid_anchors = { - "missing": self.root / "Missing Applications", - "file": self.root / "Applications File", - "symlink": self.root / "Applications Link", - } - invalid_anchors["file"].write_text("not a directory", encoding="utf-8") - invalid_anchors["symlink"].symlink_to( - real_directory, - target_is_directory=True, - ) - - for name, anchor in invalid_anchors.items(): - with self.subTest(name=name): - with self.assertRaisesRegex( - installer.InstallerError, - "shared installation namespace", - ): - with self.make_installer( - applications_directory=anchor - )._exclusive_install_lock(): - self.fail("an invalid lock anchor must not be acquired") - - def test_help_is_available_without_running_installer(self) -> None: - help_output = io.StringIO() - with contextlib.redirect_stdout(help_output): - with self.assertRaises(SystemExit) as exit_context: - installer.parse_arguments(["--help"]) - - self.assertEqual(exit_context.exception.code, 0) - self.assertIn("--signing-identity", help_output.getvalue()) - self.assertIn( - f"/Applications/{installer.APP_BUNDLE_NAME}", - help_output.getvalue(), - ) - - -@unittest.skipUnless( - os.environ.get("RUN_INSTALLER_INTEGRATION") == "1", - "set RUN_INSTALLER_INTEGRATION=1 to build and install into a temporary root", -) -class InstallerIntegrationTests(unittest.TestCase): - def test_real_build_sign_and_temporary_install(self) -> None: - repo_root = Path(__file__).resolve().parents[2] - with tempfile.TemporaryDirectory() as temporary_directory: - root = Path(temporary_directory) - destination = root / "Applications" / installer.APP_BUNDLE_NAME - applications_directory = root / "System Applications" - applications_directory.mkdir() - configuration = installer.InstallerConfiguration( - repo_root=repo_root, - destination=destination, - signing_identity="-", - launch=False, - ) - local_installer = installer.ReviewMonitorInstaller( - configuration, - process_checker=lambda: (), - home_directory=root / "Home", - applications_directory=applications_directory, - ) - create_app(destination, marker="old") - - local_installer.install() - - self.assertTrue(destination.is_dir()) - self.assertFalse( - (destination / "Contents" / "Resources" / "build-marker.txt").exists() - ) - self.assertEqual( - local_installer._bundle_identifier(destination), - installer.BUNDLE_IDENTIFIER, - ) - - -if __name__ == "__main__": - unittest.main()