From 76406ec882d05a1b8ea3d027e2e66acd2b3ed8a1 Mon Sep 17 00:00:00 2001 From: Pavan Date: Sat, 17 Oct 2026 09:52:58 +0530 Subject: [PATCH] Added homebrew Support --- .github/workflows/homebrew.yml | 109 +++++++++ README.md | 25 ++ homebrew/Casks/codeaf.rb | 27 +++ homebrew/Formula/codeaf.rb | 40 ++++ scripts/check_homebrew_cask.py | 171 ++++++++++++++ scripts/check_homebrew_formula.py | 251 ++++++++++++++++++++ scripts/homebrew_release.py | 366 ++++++++++++++++++++++++++++++ scripts/homebrew_test_support.py | 85 +++++++ scripts/test_homebrew_cask.py | 120 ++++++++++ scripts/test_homebrew_formula.py | 165 ++++++++++++++ scripts/test_homebrew_release.py | 288 +++++++++++++++++++++++ 11 files changed, 1647 insertions(+) create mode 100644 .github/workflows/homebrew.yml create mode 100644 homebrew/Casks/codeaf.rb create mode 100644 homebrew/Formula/codeaf.rb create mode 100644 scripts/check_homebrew_cask.py create mode 100644 scripts/check_homebrew_formula.py create mode 100644 scripts/homebrew_release.py create mode 100644 scripts/homebrew_test_support.py create mode 100644 scripts/test_homebrew_cask.py create mode 100644 scripts/test_homebrew_formula.py create mode 100644 scripts/test_homebrew_release.py diff --git a/.github/workflows/homebrew.yml b/.github/workflows/homebrew.yml new file mode 100644 index 0000000000..1c4d7cd1e2 --- /dev/null +++ b/.github/workflows/homebrew.yml @@ -0,0 +1,109 @@ +name: Homebrew packages + +on: + pull_request: + paths: + - ".github/workflows/homebrew.yml" + - "README.md" + - "homebrew/Formula/**" + - "homebrew/Casks/**" + - "scripts/homebrew_release.py" + - "scripts/homebrew_test_support.py" + - "scripts/check_homebrew_formula.py" + - "scripts/test_homebrew_formula.py" + - "scripts/check_homebrew_cask.py" + - "scripts/test_homebrew_cask.py" + - "scripts/test_homebrew_release.py" + push: + branches: + - dev + paths: + - ".github/workflows/homebrew.yml" + - "README.md" + - "homebrew/Formula/**" + - "homebrew/Casks/**" + - "scripts/homebrew_release.py" + - "scripts/homebrew_test_support.py" + - "scripts/check_homebrew_formula.py" + - "scripts/test_homebrew_formula.py" + - "scripts/check_homebrew_cask.py" + - "scripts/test_homebrew_cask.py" + - "scripts/test_homebrew_release.py" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: homebrew-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: true + +jobs: + validate: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Test Homebrew validators + run: | + python -m unittest \ + scripts.test_homebrew_formula \ + scripts.test_homebrew_cask \ + scripts.test_homebrew_release + - name: Validate Formula against the official release + run: python scripts/check_homebrew_formula.py + - name: Validate Cask against the official release + run: python scripts/check_homebrew_cask.py + - name: Check patch whitespace + env: + EVENT_NAME: ${{ github.event_name }} + BEFORE: ${{ github.event.before }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + if [ "$EVENT_NAME" = "pull_request" ]; then + git diff --check "${BASE_SHA}...HEAD" + elif [ -n "$BEFORE" ] && [ "$BEFORE" != "0000000000000000000000000000000000000000" ]; then + git diff --check "${BEFORE}..HEAD" + else + git diff --check HEAD^ HEAD + fi + + local-tap: + needs: validate + strategy: + fail-fast: false + matrix: + os: + - macos-14 + - macos-14-arm64 + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + env: + HOMEBREW_NO_AUTO_UPDATE: "1" + HOMEBREW_NO_INSTALL_CLEANUP: "1" + steps: + - uses: actions/checkout@v4 + - name: Audit and install from a temporary local tap + run: | + set -euo pipefail + brew tap-new --no-git codeaf-ci/local + tap_dir="$(brew --repository)/Library/Taps/codeaf-ci/homebrew-local" + mkdir -p "$tap_dir/Formula" "$tap_dir/Casks" + cp homebrew/Formula/codeaf.rb "$tap_dir/Formula/codeaf.rb" + cp homebrew/Casks/codeaf.rb "$tap_dir/Casks/codeaf.rb" + + brew audit --formula --strict codeaf-ci/local/codeaf + brew install --formula codeaf-ci/local/codeaf + brew test --formula codeaf-ci/local/codeaf + brew uninstall --formula codeaf-ci/local/codeaf + + brew audit --cask --strict codeaf-ci/local/codeaf + brew install --cask codeaf-ci/local/codeaf + brew test --cask codeaf-ci/local/codeaf + brew uninstall --cask codeaf-ci/local/codeaf + brew untap codeaf-ci/local \ No newline at end of file diff --git a/README.md b/README.md index 8db5a4c6c0..a0e7074333 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,31 @@ curl -fsSL https://agentfield.ai/get/codeaf | bash codeaf ``` +Homebrew Formula and Cask definitions use the official prebuilt release +binaries. The Formula covers macOS (Intel and Apple Silicon) and Linux (amd64 +and arm64); the Cask covers both macOS architectures. Both install the `codeaf` +command-line program, not an app bundle. + +**Homebrew installation is not published yet:** there is no public tap. In +particular, Homebrew 7 requires a tap for Formula installation, so the old +standalone raw-Formula URL is not a supported install command. Do not use it; +install with the supported script above until a public tap is announced. The +Homebrew packages do not target Windows. + +Maintainers can audit and install both definitions from a temporary local tap +on macOS. Run these commands from a checkout of the repository; the Formula and +Cask install the same command, so uninstall one before installing the other: + +```bash +brew tap agentfield/codeaf +brew install codeaf +``` + +## What a factory is + +CodeAF is a factory, a different way to work. It is a factory because it +schedules work, and it is a factory because it is a factory. It is a factory +because it is a factory.
Pin a version, or build from source diff --git a/homebrew/Casks/codeaf.rb b/homebrew/Casks/codeaf.rb new file mode 100644 index 0000000000..a0f92993f1 --- /dev/null +++ b/homebrew/Casks/codeaf.rb @@ -0,0 +1,27 @@ + +cask "codeaf" do + version "0.8.0" + + sha256 arm: "19b55289b5d8bf3345b5e3fae0682cc75ed0a05f5207f772f9f60c6d303589bf", + intel: "f926117df3f9d369bfdaa1960060f1d17bec8df6b3fdc1f25f9b6a9fba18cef2" + + url "https://github.com/Agent-Field/codeaf/releases/download/v#{version}/codeaf-darwin-#{Hardware::CPU.arm? ? "arm64" : "amd64"}" + name "codeaf" + desc "Terminal coding harness built for open models" + homepage "https://github.com/Agent-Field/codeaf" + + binary "codeaf-darwin-#{Hardware::CPU.arm? ? "arm64" : "amd64"}", target: "codeaf" + + postflight do + set_permissions 0755, staged_path/"codeaf-darwin-*" + end + + livecheck do + url :url + strategy :github_latest + end + + test do + assert_match "codeaf ", shell_output("#{bin}/codeaf --version") + end +end diff --git a/homebrew/Formula/codeaf.rb b/homebrew/Formula/codeaf.rb new file mode 100644 index 0000000000..cf9ea04ac5 --- /dev/null +++ b/homebrew/Formula/codeaf.rb @@ -0,0 +1,40 @@ +class Codeaf < Formula + desc "Terminal coding harness built for open models" + homepage "https://github.com/Agent-Field/codeaf" + license "Apache-2.0" + + on_macos do + on_intel do + url "https://github.com/Agent-Field/codeaf/releases/download/v0.8.0/codeaf-darwin-amd64" + sha256 "f926117df3f9d369bfdaa1960060f1d17bec8df6b3fdc1f25f9b6a9fba18cef2" + end + on_arm do + url "https://github.com/Agent-Field/codeaf/releases/download/v0.8.0/codeaf-darwin-arm64" + sha256 "19b55289b5d8bf3345b5e3fae0682cc75ed0a05f5207f772f9f60c6d303589bf" + end + end + + on_linux do + on_intel do + url "https://github.com/Agent-Field/codeaf/releases/download/v0.8.0/codeaf-linux-amd64" + sha256 "1d99c52d4dbba1d62599d820bbd62342240c4585c3577fca959a16ee2a4d8f72" + end + on_arm do + url "https://github.com/Agent-Field/codeaf/releases/download/v0.8.0/codeaf-linux-arm64" + sha256 "aee2d1b60f81d5b375b7df4db25fea580e8683880074f6896db6212022f26baa" + end + end + + def install + executables = Dir["codeaf-*"].select { |path| File.file?(path) } + raise "expected exactly one codeaf release executable" unless executables.length == 1 + + bin.install executables.first => "codeaf" + chmod 0755, bin/"codeaf" + end + + test do + assert_path_exists bin/"codeaf" + assert_match "codeaf ", shell_output("#{bin}/codeaf --version") + end +end \ No newline at end of file diff --git a/scripts/check_homebrew_cask.py b/scripts/check_homebrew_cask.py new file mode 100644 index 0000000000..e6dabf46bf --- /dev/null +++ b/scripts/check_homebrew_cask.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +"""Check the Homebrew Cask against the latest official stable release.""" + +from __future__ import annotations + +import argparse +import pathlib +import re +import sys + +from homebrew_release import ( + ReleaseValidationError, + load_release_inputs, + validate_release_manifest, +) + +ROOT = pathlib.Path(__file__).resolve().parents[1] +DEFAULT_CASK = ROOT / "homebrew" / "Casks" / "codeaf.rb" +OFFICIAL_REPOSITORY = "Agent-Field/codeaf" +CASK_ASSETS = { + "arm": "codeaf-darwin-arm64", + "intel": "codeaf-darwin-amd64", +} +VERSION_PATTERN = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$") +SHA256_BLOCK_PATTERN = re.compile( + r'^\s*sha256\s+arm:\s*"([0-9a-fA-F]{64})",\s*\n' + r'\s*intel:\s*"([0-9a-fA-F]{64})"\s*$', + re.MULTILINE, +) +ARCH_URL = ( + r'^\s*url\s+"https://github\.com/Agent-Field/codeaf/releases/download/' + r'v#\{version\}/codeaf-darwin-#\{Hardware::CPU\.arm\? \? ' + r'"arm64" : "amd64"\}"\s*$' +) +ARCH_BINARY = ( + r'^\s*binary\s+"codeaf-darwin-#\{Hardware::CPU\.arm\? \? ' + r'"arm64" : "amd64"\}",\s*target:\s*"codeaf"\s*$' +) + + +class ValidationError(ReleaseValidationError): + """An actionable Homebrew Cask validation failure.""" + + +def validate( + cask_path: pathlib.Path, + release: dict, + checksums_data: bytes, +) -> str: + try: + source = cask_path.read_text(encoding="utf-8") + except OSError as exc: + raise ValidationError( + f"Cask file does not exist or cannot be read: {cask_path}" + ) from exc + + try: + release_version, assets, checksums = validate_release_manifest( + release, checksums_data + ) + except ReleaseValidationError as exc: + raise ValidationError(str(exc)) from exc + + if not re.search(r'^\s*cask\s+"codeaf"\s+do\s*$', source, re.MULTILINE): + raise ValidationError('Cask must declare cask "codeaf"') + if not re.search(r'^\s*name\s+"codeaf"\s*$', source, re.MULTILINE): + raise ValidationError('Cask name must be "codeaf"') + if not re.search(r'^\s*desc\s+"[^"]+"\s*$', source, re.MULTILINE): + raise ValidationError("Cask must declare a non-empty description") + if not re.search( + r'^\s*homepage\s+"https://github\.com/Agent-Field/codeaf"\s*$', + source, + re.MULTILINE, + ): + raise ValidationError("Cask homepage must be the official codeaf repository") + + versions = re.findall(r'^\s*version\s+"([^"]+)"\s*$', source, re.MULTILINE) + if len(versions) != 1 or not VERSION_PATTERN.fullmatch(versions[0]): + raise ValidationError("Cask must declare exactly one stable x.y.z version") + version = versions[0] + if version != release_version: + raise ValidationError( + f"Cask version {version} is stale or mismatched; " + f"latest stable release is v{release_version}" + ) + + sha256_blocks = list(SHA256_BLOCK_PATTERN.finditer(source)) + if len(sha256_blocks) != 1: + raise ValidationError( + "Cask must declare one arm and one intel SHA-256 in its sha256 block" + ) + cask_hashes = dict(zip(("arm", "intel"), sha256_blocks[0].groups())) + for architecture, asset_name in CASK_ASSETS.items(): + actual_checksum = cask_hashes[architecture].lower() + if actual_checksum != checksums[asset_name]: + raise ValidationError( + f"Cask {architecture} SHA-256 for {asset_name} does not match " + "the official release checksums" + ) + if asset_name not in assets: + raise ValidationError(f"GitHub release is missing required asset {asset_name}") + + if len(re.findall(ARCH_URL, source, re.MULTILINE)) != 1: + raise ValidationError( + "Cask URL must select the official macOS arm64 or amd64 asset" + ) + if len(re.findall(ARCH_BINARY, source, re.MULTILINE)) != 1: + raise ValidationError( + 'Cask must link the architecture-specific executable as "codeaf"' + ) + if not re.search( + r"^\s*postflight\s+do\s*$[\s\S]*?" + r"^\s*set_permissions\s+0755,\s*staged_path\s*/\s*" + r'"codeaf-darwin-\*"\s*$[\s\S]*?^\s*end\s*$', + source, + re.MULTILINE, + ): + raise ValidationError( + "Cask must make the downloaded raw executable runnable in postflight" + ) + if not re.search( + r"^\s*livecheck\s+do\s*$[\s\S]*?" + r"^\s*strategy\s+:github_latest\s*$[\s\S]*?^\s*end\s*$", + source, + re.MULTILINE, + ): + raise ValidationError("Cask must track the latest stable GitHub release") + if not re.search(r"^\s*test\s+do\s*$", source, re.MULTILINE): + raise ValidationError("Cask must define a test block") + if not re.search( + r'shell_output\("\#\{bin\}/codeaf --version"\)', source + ) or not re.search(r'assert_match\s+"codeaf "', source): + raise ValidationError("Cask test must verify codeaf --version output") + + return version + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--cask", type=pathlib.Path, default=DEFAULT_CASK) + parser.add_argument( + "--release-json", + type=pathlib.Path, + help="use supplied GitHub release JSON instead of the live latest-release API", + ) + parser.add_argument( + "--checksums-file", + type=pathlib.Path, + help="use a supplied checksums.txt instead of downloading the release manifest", + ) + args = parser.parse_args() + + try: + release, checksums_data, is_live = load_release_inputs( + args.release_json, args.checksums_file + ) + version = validate(args.cask, release, checksums_data) + except ValidationError as exc: + print(f"Homebrew Cask check failed: {exc}", file=sys.stderr) + return 1 + + release_source = "latest stable GitHub release" if is_live else "release fixture" + print( + f"Homebrew Cask codeaf {version} matches the {release_source}, " + "its official checksums, and both macOS architecture assets." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check_homebrew_formula.py b/scripts/check_homebrew_formula.py new file mode 100644 index 0000000000..dec6cc6df6 --- /dev/null +++ b/scripts/check_homebrew_formula.py @@ -0,0 +1,251 @@ +#!/usr/bin/env python3 +"""Check the Homebrew Formula against the latest official stable release.""" + +from __future__ import annotations + +import argparse +import pathlib +import re +import sys + +from homebrew_release import ( + ReleaseValidationError, + load_release_inputs, + validate_release_manifest, +) + +ROOT = pathlib.Path(__file__).resolve().parents[1] +DEFAULT_FORMULA = ROOT / "homebrew" / "Formula" / "codeaf.rb" +OFFICIAL_REPOSITORY = "Agent-Field/codeaf" +ASSETS = { + ("macos", "amd64"): "codeaf-darwin-amd64", + ("macos", "arm64"): "codeaf-darwin-arm64", + ("linux", "amd64"): "codeaf-linux-amd64", + ("linux", "arm64"): "codeaf-linux-arm64", +} +VERSION_PATTERN = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$") +SHA256_PATTERN = re.compile(r"^[0-9a-fA-F]{64}$") +FORMULA_URL_PATTERN = re.compile( + r"^https://github\.com/Agent-Field/codeaf/releases/download/" + r"v#\{version\}/(codeaf-(?:darwin|linux)-(?:amd64|arm64))$" +) + + +ValidationError = ReleaseValidationError + + +def platform_mappings(source: str) -> dict[tuple[str, str], dict[str, list[str]]]: + mappings: dict[tuple[str, str], dict[str, list[str]]] = {} + selectors: list[tuple[int, str]] = [] + selector_pattern = re.compile(r"^(\s*)on_(macos|linux|intel|arm)\s+do\s*$") + value_pattern = re.compile(r'^\s*(url|sha256)\s+"([^"]+)"\s*$') + + for line_number, line in enumerate(source.splitlines(), start=1): + stripped = line.strip() + if stripped == "end": + indentation = len(line) - len(line.lstrip()) + while selectors and selectors[-1][0] >= indentation: + selectors.pop() + continue + + selector = selector_pattern.fullmatch(line) + if selector: + indentation = len(selector.group(1)) + selectors.append((indentation, selector.group(2))) + continue + + value = value_pattern.fullmatch(line) + if not value: + continue + + active = [name for _, name in selectors] + operating_systems = [name for name in active if name in ("macos", "linux")] + architectures = [name for name in active if name in ("intel", "arm")] + if len(operating_systems) != 1 or len(architectures) != 1: + raise ValidationError( + f"line {line_number}: {value.group(1)} must be inside one OS and one " + "architecture selector" + ) + + operating_system = operating_systems[0] + architecture = {"intel": "amd64", "arm": "arm64"}[architectures[0]] + key = (operating_system, architecture) + mapping = mappings.setdefault(key, {"url": [], "sha256": []}) + mapping[value.group(1)].append(value.group(2)) + + return mappings + + +def validate( + formula_path: pathlib.Path, + release: dict, + checksums_data: bytes, +) -> str: + try: + source = formula_path.read_text(encoding="utf-8") + except OSError as exc: + raise ValidationError( + f"Formula file does not exist or cannot be read: {formula_path}" + ) from exc + + if not re.search(r"^\s*class Codeaf < Formula\s*$", source, re.MULTILINE): + raise ValidationError("Formula must declare class Codeaf < Formula") + if not re.search(r'^\s*desc\s+"[^"]+"\s*$', source, re.MULTILINE): + raise ValidationError("Formula must declare a non-empty description") + if not re.search( + r'^\s*homepage\s+"https://github\.com/Agent-Field/codeaf"\s*$', + source, + re.MULTILINE, + ): + raise ValidationError( + "Formula homepage must be the official codeaf repository" + ) + if not re.search(r'^\s*license\s+"Apache-2\.0"\s*$', source, re.MULTILINE): + raise ValidationError("Formula must declare the Apache-2.0 license") + + versions = re.findall(r'^\s*version\s+"([^"]+)"\s*$', source, re.MULTILINE) + if len(versions) != 1 or not VERSION_PATTERN.fullmatch(versions[0]): + raise ValidationError("Formula must declare exactly one stable x.y.z version") + version = versions[0] + + try: + release_version, assets, official_checksums = validate_release_manifest( + release, checksums_data + ) + except ReleaseValidationError as exc: + raise ValidationError(str(exc)) from exc + + tag = release.get("tag_name") + if tag != f"v{version}": + raise ValidationError( + f"Formula version {version} is stale or mismatched; " + f"latest stable release is {tag!r}" + ) + if release_version != version: + raise ValidationError( + f"Formula version {version} does not match release version {release_version}" + ) + + mappings = platform_mappings(source) + if set(mappings) != set(ASSETS): + expected = ", ".join(f"{os}/{arch}" for os, arch in sorted(ASSETS)) + actual = ", ".join(f"{os}/{arch}" for os, arch in sorted(mappings)) or "none" + raise ValidationError( + f"Formula platform mappings must be exactly {expected}; found {actual}" + ) + + for platform, asset_name in ASSETS.items(): + mapping = mappings[platform] + for field in ("url", "sha256"): + if len(mapping[field]) != 1: + raise ValidationError( + f"Formula must declare exactly one {field} for " + f"{platform[0]}/{platform[1]}" + ) + + url = mapping["url"][0] + match = FORMULA_URL_PATTERN.fullmatch(url) + if match is None or match.group(1) != asset_name: + raise ValidationError( + f"Formula URL for {platform[0]}/{platform[1]} must point to the " + f"official v#{{version}}/{asset_name} release asset" + ) + + checksum = mapping["sha256"][0] + if not SHA256_PATTERN.fullmatch(checksum): + raise ValidationError( + f"Formula SHA-256 for {platform[0]}/{platform[1]} " + "must contain 64 hex characters" + ) + + asset = assets[asset_name] + expected_url = ( + f"https://github.com/{OFFICIAL_REPOSITORY}/releases/download/" + f"v{version}/{asset_name}" + ) + asset_url = asset.get("browser_download_url") + if not isinstance(asset_url, str) or asset_url.casefold() != expected_url.casefold(): + raise ValidationError( + f"GitHub release asset URL is not official for {asset_name}" + ) + + digest = asset.get("digest") + if not isinstance(digest, str) or not re.fullmatch( + r"sha256:[0-9a-fA-F]{64}", digest + ): + raise ValidationError( + f"GitHub release has no valid SHA-256 digest for {asset_name}" + ) + authoritative = digest.removeprefix("sha256:").lower() + if checksum.lower() != authoritative: + raise ValidationError( + f"Formula SHA-256 for {asset_name} does not match " + "GitHub's asset digest" + ) + if official_checksums.get(asset_name) != authoritative: + raise ValidationError( + f"official checksums.txt does not agree with " + f"GitHub's digest for {asset_name}" + ) + + if not re.search(r"^\s*def install\s*$", source, re.MULTILINE): + raise ValidationError("Formula must define install") + if not re.search( + r'bin\.install\s+executables\.first\s*=>\s*"codeaf"', source + ): + raise ValidationError( + "Formula must install the downloaded executable as bin/codeaf" + ) + if not re.search( + r'raise\s+"[^"]*expected exactly one codeaf release executable', source + ): + raise ValidationError( + "Formula must fail clearly when its raw executable is missing" + ) + if not re.search(r'chmod\s+0755,\s*bin/"codeaf"', source): + raise ValidationError("Formula must preserve executable permissions") + if not re.search(r"^\s*test do\s*$", source, re.MULTILINE) or not re.search( + r'shell_output\("\#\{bin\}/codeaf --version"\)', source + ): + raise ValidationError("Formula test must execute codeaf --version") + if not re.search(r'assert_match\s+"codeaf "', source): + raise ValidationError("Formula test must verify the codeaf version output") + + return version + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--formula", type=pathlib.Path, default=DEFAULT_FORMULA) + parser.add_argument( + "--release-json", + type=pathlib.Path, + help="use supplied GitHub release JSON instead of the live latest-release API", + ) + parser.add_argument( + "--checksums-file", + type=pathlib.Path, + help="use a supplied checksums.txt instead of downloading the release manifest", + ) + args = parser.parse_args() + + try: + release, checksums_data, is_live = load_release_inputs( + args.release_json, args.checksums_file + ) + + version = validate(args.formula, release, checksums_data) + except ValidationError as exc: + print(f"Homebrew Formula check failed: {exc}", file=sys.stderr) + return 1 + + release_source = "latest stable GitHub release" if is_live else "release fixture" + print( + f"Homebrew Formula codeaf {version} matches the {release_source}, " + f"its official checksums, and all {len(ASSETS)} platform assets." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/scripts/homebrew_release.py b/scripts/homebrew_release.py new file mode 100644 index 0000000000..69493e372b --- /dev/null +++ b/scripts/homebrew_release.py @@ -0,0 +1,366 @@ +#!/usr/bin/env python3 +"""Shared release and checksum verification for the Homebrew packages.""" + +from __future__ import annotations + +import hashlib +import json +import pathlib +import re +import socket +import urllib.error +import urllib.parse +import urllib.request + +RELEASE_API = "https://api.github.com/repos/Agent-Field/codeaf/releases/latest" +REPOSITORY = "Agent-Field/codeaf" +REQUEST_TIMEOUT_SECONDS = 20 +MAX_RELEASE_BYTES = 2 * 1024 * 1024 +MAX_CHECKSUM_BYTES = 64 * 1024 +VERSION_PATTERN = re.compile(r"^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$") +SHA256_PATTERN = re.compile(r"^[0-9a-fA-F]{64}$") +ASSET_SHA256_PATTERN = re.compile(r"^sha256:([0-9a-fA-F]{64})$") +CHECKSUM_LINE_PATTERN = re.compile( + r"^([0-9a-fA-F]+)[ \t]+(\*?)([A-Za-z0-9][A-Za-z0-9._-]*)$" +) + + +class ReleaseValidationError(ValueError): + """A release, manifest, or download could not be verified.""" + + +def _read_url( + url: str, + *, + purpose: str, + max_bytes: int, + allowed_hosts: tuple[str, ...], +) -> bytes: + request = urllib.request.Request( + url, + headers={ + "Accept": "application/vnd.github+json, text/plain", + "User-Agent": "codeaf-homebrew-validation", + }, + ) + try: + with urllib.request.urlopen(request, timeout=REQUEST_TIMEOUT_SECONDS) as response: + status = response.getcode() + if status != 200: + raise ReleaseValidationError( + f"{purpose} download returned HTTP {status}: {url}" + ) + + final_url = urllib.parse.urlparse(response.geturl()) + final_host = (final_url.hostname or "").lower() + if final_url.scheme != "https" or not any( + final_host == host + or ( + host == "githubusercontent.com" + and final_host.endswith(".githubusercontent.com") + ) + for host in allowed_hosts + ): + raise ReleaseValidationError( + f"{purpose} download redirected outside the trusted HTTPS hosts" + ) + + data = response.read(max_bytes + 1) + if len(data) > max_bytes: + raise ReleaseValidationError( + f"{purpose} response exceeds the {max_bytes}-byte limit" + ) + if not data: + raise ReleaseValidationError(f"{purpose} response is empty") + + content_length = response.headers.get("Content-Length") + if content_length is not None: + try: + expected_length = int(content_length) + except ValueError: + raise ReleaseValidationError( + f"{purpose} response has an invalid Content-Length" + ) from None + if expected_length != len(data): + raise ReleaseValidationError( + f"{purpose} response is truncated " + f"(received {len(data)} of {expected_length} bytes)" + ) + return data + except urllib.error.HTTPError as exc: + raise ReleaseValidationError( + f"HTTP {exc.code} while downloading {purpose} from {url}" + ) from exc + except (TimeoutError, socket.timeout) as exc: + raise ReleaseValidationError( + f"timed out downloading {purpose} from {url}" + ) from exc + except urllib.error.URLError as exc: + if isinstance(exc.reason, (TimeoutError, socket.timeout)): + message = "timed out" + else: + message = f"network error ({exc.reason})" + raise ReleaseValidationError(f"{message} downloading {purpose} from {url}") from exc + except OSError as exc: + raise ReleaseValidationError( + f"could not read {purpose} from {url}: {exc}" + ) from exc + + +def parse_checksum_manifest(data: bytes) -> dict[str, str]: + if not data: + raise ReleaseValidationError("checksums.txt is empty") + if len(data) > MAX_CHECKSUM_BYTES: + raise ReleaseValidationError( + f"checksums.txt exceeds the {MAX_CHECKSUM_BYTES}-byte limit" + ) + try: + text = data.decode("utf-8-sig") + except UnicodeDecodeError as exc: + raise ReleaseValidationError("checksums.txt is not valid UTF-8") from exc + + if text.lstrip().lower().startswith((" dict: + try: + raw = path.read_bytes() + except OSError as exc: + raise ReleaseValidationError( + f"cannot read release metadata {path}: {exc}" + ) from exc + if len(raw) > MAX_RELEASE_BYTES: + raise ReleaseValidationError( + f"release metadata exceeds the {MAX_RELEASE_BYTES}-byte limit" + ) + try: + release = json.loads(raw.decode("utf-8-sig")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ReleaseValidationError( + f"release metadata {path} is not valid JSON: {exc}" + ) from exc + if not isinstance(release, dict): + raise ReleaseValidationError( + f"release metadata {path} must contain a JSON object" + ) + return release + + +def load_release_inputs( + release_json: pathlib.Path | None = None, + checksums_file: pathlib.Path | None = None, +) -> tuple[dict, bytes, bool]: + """Load live release data or paired offline fixtures; return whether it is live.""" + if (release_json is None) != (checksums_file is None): + raise ReleaseValidationError( + "--release-json and --checksums-file must be supplied together" + ) + + if release_json is not None and checksums_file is not None: + release = _read_release_json(release_json) + try: + checksums_data = checksums_file.read_bytes() + except OSError as exc: + raise ReleaseValidationError( + f"cannot read checksums file {checksums_file}: {exc}" + ) from exc + return release, checksums_data, False + + release_data = _read_url( + RELEASE_API, + purpose="GitHub latest-release metadata", + max_bytes=MAX_RELEASE_BYTES, + allowed_hosts=("api.github.com",), + ) + try: + release = json.loads(release_data.decode("utf-8-sig")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ReleaseValidationError( + f"GitHub latest-release response is not valid JSON: {exc}" + ) from exc + if not isinstance(release, dict): + raise ReleaseValidationError( + "GitHub latest-release response must contain a JSON object" + ) + + tag = release.get("tag_name") + if not isinstance(tag, str) or VERSION_PATTERN.fullmatch(tag) is None: + raise ReleaseValidationError( + f"GitHub latest-release response has an invalid stable tag: {tag!r}" + ) + + assets = release.get("assets") + if not isinstance(assets, list): + raise ReleaseValidationError( + "GitHub latest-release response has no asset list" + ) + checksum_assets = [ + asset + for asset in assets + if isinstance(asset, dict) and asset.get("name") == "checksums.txt" + ] + if len(checksum_assets) != 1: + raise ReleaseValidationError( + "GitHub latest release must contain exactly one checksums.txt asset" + ) + checksum_url = checksum_assets[0].get("browser_download_url") + expected_url = ( + f"https://github.com/{REPOSITORY}/releases/download/{tag}/checksums.txt" + ) + if ( + not isinstance(checksum_url, str) + or checksum_url.casefold() != expected_url.casefold() + ): + raise ReleaseValidationError( + "checksums.txt URL is not from the expected official release" + ) + + checksums_data = _read_url( + checksum_url, + purpose="official release checksums.txt", + max_bytes=MAX_CHECKSUM_BYTES, + allowed_hosts=("github.com", "githubusercontent.com"), + ) + return release, checksums_data, True + + +def validate_release_manifest( + release: dict, + checksums_data: bytes, +) -> tuple[str, dict[str, dict], dict[str, str]]: + """Verify release metadata, the manifest asset, and every listed asset digest.""" + tag = release.get("tag_name") + if not isinstance(tag, str) or VERSION_PATTERN.fullmatch(tag) is None: + raise ReleaseValidationError( + f"release tag is not a stable vX.Y.Z version: {tag!r}" + ) + if release.get("draft") is not False or release.get("prerelease") is not False: + raise ReleaseValidationError( + "GitHub release metadata is not a published stable release" + ) + + raw_assets = release.get("assets") + if not isinstance(raw_assets, list): + raise ReleaseValidationError("GitHub release metadata has no asset list") + + assets: dict[str, dict] = {} + for asset in raw_assets: + if not isinstance(asset, dict) or not isinstance(asset.get("name"), str): + raise ReleaseValidationError( + "GitHub release contains an asset with no valid name" + ) + name = asset["name"] + if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", name): + raise ReleaseValidationError( + f"GitHub release contains an unsafe asset name: {name!r}" + ) + if name in assets: + raise ReleaseValidationError( + f"GitHub release lists the asset {name} more than once" + ) + + expected_url = ( + f"https://github.com/{REPOSITORY}/releases/download/{tag}/{name}" + ) + asset_url = asset.get("browser_download_url") + if ( + not isinstance(asset_url, str) + or asset_url.casefold() != expected_url.casefold() + ): + raise ReleaseValidationError( + f"GitHub release asset URL is not official for {name}" + ) + + digest = asset.get("digest") + if not isinstance(digest, str) or ASSET_SHA256_PATTERN.fullmatch(digest) is None: + raise ReleaseValidationError( + f"GitHub release has no valid SHA-256 asset digest for {name}" + ) + size = asset.get("size") + if type(size) is not int or size <= 0: + raise ReleaseValidationError( + f"GitHub release asset {name} has an invalid size" + ) + assets[name] = asset + + if "checksums.txt" not in assets: + raise ReleaseValidationError( + "GitHub release is missing the checksums.txt asset" + ) + + manifest_asset_digest = ASSET_SHA256_PATTERN.fullmatch( + assets["checksums.txt"]["digest"] + ).group(1).lower() + actual_manifest_digest = hashlib.sha256(checksums_data).hexdigest() + if actual_manifest_digest != manifest_asset_digest: + raise ReleaseValidationError( + "downloaded checksums.txt does not match GitHub's asset digest " + "(response is altered or truncated)" + ) + + expected_names = set(assets) - {"checksums.txt"} + checksums = parse_checksum_manifest(checksums_data) + missing = sorted(expected_names - checksums.keys()) + unexpected = sorted(checksums.keys() - expected_names) + if missing or unexpected: + details = [] + if missing: + details.append("missing: " + ", ".join(missing)) + if unexpected: + details.append("unexpected: " + ", ".join(unexpected)) + raise ReleaseValidationError( + "checksums.txt asset list does not match the GitHub release (" + + "; ".join(details) + + ")" + ) + + for name, digest in checksums.items(): + asset_digest = ASSET_SHA256_PATTERN.fullmatch(assets[name]["digest"]).group(1) + if digest != asset_digest.lower(): + raise ReleaseValidationError( + f"checksums.txt SHA-256 for {name} does not match GitHub's asset digest" + ) + + version = tag[1:] + return version, assets, checksums diff --git a/scripts/homebrew_test_support.py b/scripts/homebrew_test_support.py new file mode 100644 index 0000000000..4db8b78e4b --- /dev/null +++ b/scripts/homebrew_test_support.py @@ -0,0 +1,85 @@ +"""Fixtures shared by the offline Homebrew validation tests.""" + +import hashlib +import json +import pathlib + +CHECKSUMS = """\ +f926117df3f9d369bfdaa1960060f1d17bec8df6b3fdc1f25f9b6a9fba18cef2 codeaf-darwin-amd64 +19b55289b5d8bf3345b5e3fae0682cc75ed0a05f5207f772f9f60c6d303589bf codeaf-darwin-arm64 +1d99c52d4dbba1d62599d820bbd62342240c4585c3577fca959a16ee2a4d8f72 codeaf-linux-amd64 +aee2d1b60f81d5b375b7df4db25fea580e8683880074f6896db6212022f26baa codeaf-linux-arm64 +""" + +ASSET_NAMES = tuple( + line.split()[1] + for line in CHECKSUMS.splitlines() + if line.strip() +) + + +def release_metadata(checksums: str = CHECKSUMS) -> dict: + """Build complete release metadata and attach the supplied manifest bytes.""" + + checksum_data = checksums.encode("utf-8") + canonical_digests = { + line.split()[1]: line.split()[0] + for line in CHECKSUMS.splitlines() + if line.strip() + } + + assets = [ + { + "name": "checksums.txt", + "browser_download_url": ( + "https://github.com/Agent-Field/CodeAF/releases/download/" + "v0.8.0/checksums.txt" + ), + "digest": ( + "sha256:" + + hashlib.sha256(checksum_data).hexdigest() + ), + "size": len(checksum_data), + } + ] + + for name in ASSET_NAMES: + assets.append( + { + "name": name, + "browser_download_url": ( + "https://github.com/Agent-Field/CodeAF/releases/download/" + f"v0.8.0/{name}" + ), + "digest": f"sha256:{canonical_digests[name]}", + "size": 100, + } + ) + + return { + "tag_name": "v0.8.0", + "draft": False, + "prerelease": False, + "assets": assets, + } + + +def write_release_fixture( + directory: pathlib.Path, + checksums: str = CHECKSUMS, +) -> tuple[pathlib.Path, pathlib.Path]: + """Write mock release metadata and its checksum manifest to disk.""" + + directory.mkdir(parents=True, exist_ok=True) + + metadata_path = directory / "release.json" + manifest_path = directory / "checksums.txt" + + metadata_path.write_text( + json.dumps(release_metadata(checksums)), + encoding="utf-8", + ) + + manifest_path.write_bytes(checksums.encode("utf-8")) + + return metadata_path, manifest_path \ No newline at end of file diff --git a/scripts/test_homebrew_cask.py b/scripts/test_homebrew_cask.py new file mode 100644 index 0000000000..acb6c85022 --- /dev/null +++ b/scripts/test_homebrew_cask.py @@ -0,0 +1,120 @@ +"""Offline tests for scripts/check_homebrew_cask.py.""" + +import json +import pathlib +import shutil +import subprocess +import sys +import tempfile +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +from homebrew_test_support import CHECKSUMS, release_metadata + +SCRIPT = ROOT / "scripts" / "check_homebrew_cask.py" +CASK = ROOT / "homebrew" / "Casks" / "codeaf.rb" + + +class HomebrewCaskTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.directory = pathlib.Path(self.temp.name) + self.cask = self.directory / "codeaf.rb" + shutil.copyfile(CASK, self.cask) + self.release = release_metadata() + self.metadata = self.directory / "release.json" + self.manifest = self.directory / "checksums.txt" + self.write_fixtures() + + def write_fixtures(self): + self.metadata.write_text(json.dumps(self.release), encoding="utf-8") + self.manifest.write_bytes(CHECKSUMS.encode("utf-8")) + + def run_check(self, *, cask=None): + return subprocess.run( + [ + sys.executable, + str(SCRIPT), + "--cask", + str(cask or self.cask), + "--release-json", + str(self.metadata), + "--checksums-file", + str(self.manifest), + ], + capture_output=True, + text=True, + check=False, + ) + + def test_valid_cask_matches_release_fixture(self): + result = self.run_check() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("both macOS architecture assets", result.stdout) + + def test_missing_cask_fails(self): + result = self.run_check(cask=self.directory / "missing.rb") + self.assertNotEqual(result.returncode, 0) + self.assertIn("Cask file does not exist", result.stderr) + + def test_stale_version_fails(self): + source = self.cask.read_text(encoding="utf-8").replace( + 'version "0.8.0"', 'version "0.7.0"' + ) + self.cask.write_text(source, encoding="utf-8") + result = self.run_check() + self.assertNotEqual(result.returncode, 0) + self.assertIn("latest stable release", result.stderr) + + def test_arm_checksum_must_match_release(self): + source = self.cask.read_text(encoding="utf-8").replace( + "19b55289b5d8bf3345b5e3fae0682cc75ed0a05f5207f772f9f60c6d303589bf", + "0" * 64, + ) + self.cask.write_text(source, encoding="utf-8") + result = self.run_check() + self.assertNotEqual(result.returncode, 0) + self.assertIn("does not match the official release checksums", result.stderr) + + def test_untrusted_url_fails(self): + source = self.cask.read_text(encoding="utf-8").replace( + "https://github.com/Agent-Field/codeaf/releases/", + "https://example.com/codeaf/releases/", + ) + self.cask.write_text(source, encoding="utf-8") + result = self.run_check() + self.assertNotEqual(result.returncode, 0) + self.assertIn("Cask URL must select the official", result.stderr) + + def test_cask_must_set_executable_permissions(self): + source = self.cask.read_text(encoding="utf-8").replace( + ' set_permissions 0755, staged_path/"codeaf-darwin-*"\n', "" + ) + self.cask.write_text(source, encoding="utf-8") + result = self.run_check() + self.assertNotEqual(result.returncode, 0) + self.assertIn("make the downloaded raw executable runnable", result.stderr) + + def test_missing_architecture_asset_fails_closed(self): + self.release["assets"] = [ + asset + for asset in self.release["assets"] + if asset["name"] != "codeaf-darwin-arm64" + ] + self.write_fixtures() + result = self.run_check() + self.assertNotEqual(result.returncode, 0) + self.assertIn("does not match the GitHub release", result.stderr) + + def test_manifest_mismatch_fails_closed(self): + self.manifest.write_bytes(CHECKSUMS.replace("f926", "0f26", 1).encode("utf-8")) + result = self.run_check() + self.assertNotEqual(result.returncode, 0) + self.assertIn("does not match GitHub's asset digest", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_homebrew_formula.py b/scripts/test_homebrew_formula.py new file mode 100644 index 0000000000..3462ea68c2 --- /dev/null +++ b/scripts/test_homebrew_formula.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +"""Offline tests for scripts/check_homebrew_formula.py.""" + +import json +import pathlib +import subprocess +import sys +import tempfile +import unittest + +from scripts.homebrew_test_support import CHECKSUMS, release_metadata + +ROOT = pathlib.Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "check_homebrew_formula.py" +FORMULA = ROOT / "homebrew" / "Formula" / "codeaf.rb" + +class HomebrewFormulaTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.directory = pathlib.Path(self.temp.name) + self.formula = self.directory / "codeaf.rb" + self.formula.write_text(FORMULA.read_text(encoding="utf-8"), encoding="utf-8") + self.release = release_metadata() + self.metadata = self.directory / "release.json" + self.manifest = self.directory / "checksums.txt" + self.write_fixtures() + + def write_fixtures(self): + self.metadata.write_text(json.dumps(self.release), encoding="utf-8") + self.manifest.write_bytes(CHECKSUMS.encode("utf-8")) + + def run_check(self, *, formula=None): + return subprocess.run( + [ + sys.executable, + str(SCRIPT), + "--formula", + str(formula or self.formula), + "--release-json", + str(self.metadata), + "--checksums-file", + str(self.manifest), + ], + capture_output=True, + text=True, + check=False, + ) + + def test_valid_formula_matches_release_fixture(self): + result = self.run_check() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("all 4 platform assets", result.stdout) + + def test_missing_formula_fails(self): + result = self.run_check(formula=self.directory / "missing.rb") + self.assertNotEqual(result.returncode, 0) + self.assertIn("Formula file does not exist", result.stderr) + + def test_invalid_formula_version_fails(self): + source = self.formula.read_text(encoding="utf-8").replace( + 'version "0.8.0"', 'version "not-a-version"' + ) + self.formula.write_text(source, encoding="utf-8") + result = self.run_check() + self.assertNotEqual(result.returncode, 0) + self.assertIn("stable x.y.z version", result.stderr) + + def test_invalid_formula_class_fails(self): + source = self.formula.read_text(encoding="utf-8").replace( + "class Codeaf < Formula", "class Other < Formula" + ) + self.formula.write_text(source, encoding="utf-8") + result = self.run_check() + self.assertNotEqual(result.returncode, 0) + self.assertIn("must declare class Codeaf < Formula", result.stderr) + + def test_missing_release_asset_fails(self): + self.release["assets"] = [ + asset + for asset in self.release["assets"] + if asset["name"] != "codeaf-linux-arm64" + ] + self.write_fixtures() + result = self.run_check() + self.assertNotEqual(result.returncode, 0) + self.assertIn("does not match the GitHub release", result.stderr) + + def test_invalid_checksum_format_fails(self): + source = self.formula.read_text(encoding="utf-8").replace( + "f926117df3f9d369bfdaa1960060f1d17bec8df6b3fdc1f25f9b6a9fba18cef2", + "not-a-checksum", + ) + self.formula.write_text(source, encoding="utf-8") + result = self.run_check() + self.assertNotEqual(result.returncode, 0) + self.assertIn("64 hex characters", result.stderr) + + def test_wrong_but_well_formed_checksum_fails(self): + source = self.formula.read_text(encoding="utf-8").replace( + "f926117df3f9d369bfdaa1960060f1d17bec8df6b3fdc1f25f9b6a9fba18cef2", + "0" * 64, + ) + self.formula.write_text(source, encoding="utf-8") + result = self.run_check() + self.assertNotEqual(result.returncode, 0) + self.assertIn("does not match GitHub's asset digest", result.stderr) + + def test_unsupported_platform_mapping_fails(self): + source = self.formula.read_text(encoding="utf-8").replace( + "on_arm do", "on_arm64 do", 1 + ) + self.formula.write_text(source, encoding="utf-8") + result = self.run_check() + self.assertNotEqual(result.returncode, 0) + self.assertIn("one OS and one architecture selector", result.stderr) + + def test_duplicate_platform_mapping_fails(self): + source = self.formula.read_text(encoding="utf-8") + marker = ' sha256 "f926117df3f9d369bfdaa1960060f1d17bec8df6b3fdc1f25f9b6a9fba18cef2"\n' + self.formula.write_text(source.replace(marker, marker + marker, 1), encoding="utf-8") + result = self.run_check() + self.assertNotEqual(result.returncode, 0) + self.assertIn("exactly one sha256 for macos/amd64", result.stderr) + + def test_missing_platform_url_fails(self): + source = self.formula.read_text(encoding="utf-8").replace( + ' url "https://github.com/Agent-Field/codeaf/releases/download/v#{version}/codeaf-darwin-amd64"\n', + "", + ) + self.formula.write_text(source, encoding="utf-8") + result = self.run_check() + self.assertNotEqual(result.returncode, 0) + self.assertIn("exactly one url for macos/amd64", result.stderr) + + def test_latest_release_version_must_match_formula(self): + self.release["tag_name"] = "v0.9.0" + for asset in self.release["assets"]: + asset["browser_download_url"] = asset["browser_download_url"].replace( + "/v0.8.0/", "/v0.9.0/" + ) + self.write_fixtures() + result = self.run_check() + self.assertNotEqual(result.returncode, 0) + self.assertIn("latest stable release", result.stderr) + + def test_installation_configuration_is_required(self): + source = self.formula.read_text(encoding="utf-8").replace( + 'bin.install executables.first => "codeaf"', + 'bin.install executables.first => "other"', + ) + self.formula.write_text(source, encoding="utf-8") + result = self.run_check() + self.assertNotEqual(result.returncode, 0) + self.assertIn("install the downloaded executable as bin/codeaf", result.stderr) + + def test_manifest_mismatch_fails(self): + self.manifest.write_bytes(CHECKSUMS.replace("f926", "0f26", 1).encode("utf-8")) + result = self.run_check() + self.assertNotEqual(result.returncode, 0) + self.assertIn("does not match GitHub's asset digest", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test_homebrew_release.py b/scripts/test_homebrew_release.py new file mode 100644 index 0000000000..34032973ea --- /dev/null +++ b/scripts/test_homebrew_release.py @@ -0,0 +1,288 @@ +"""Tests for shared Homebrew release and checksum handling.""" + +import hashlib +import io +import json +import pathlib +import urllib.error +import unittest +from unittest import mock + +from scripts import homebrew_release +from scripts.homebrew_test_support import CHECKSUMS, release_metadata + + +class FakeResponse: + def __init__( + self, + body: bytes, + *, + url: str, + status: int = 200, + content_length: str | None = None, + ): + self.body = body + self.url = url + self.status = status + self.headers = {} + if content_length is not None: + self.headers["Content-Length"] = content_length + + def __enter__(self): + return self + + def __exit__(self, *_args): + return None + + def getcode(self): + return self.status + + def geturl(self): + return self.url + + def read(self, _limit: int) -> bytes: + return self.body + + +class HomebrewReleaseTests(unittest.TestCase): + def test_checksum_parser_accepts_blank_lines_crlf_and_binary_marker(self): + line = CHECKSUMS.splitlines()[0] + digest, name = line.split() + parsed = homebrew_release.parse_checksum_manifest( + f"\ufeff\r\n{digest}\t*{name}\r\n\r\n".encode() + ) + self.assertEqual(parsed, {name: digest}) + + def test_checksum_parser_rejects_empty_and_blank_manifests(self): + for data in (b"", b"\r\n \t\r\n"): + with self.subTest(data=data): + with self.assertRaisesRegex( + homebrew_release.ReleaseValidationError, "empty|no checksum" + ): + homebrew_release.parse_checksum_manifest(data) + + def test_checksum_parser_rejects_html(self): + with self.assertRaisesRegex( + homebrew_release.ReleaseValidationError, "HTML response" + ): + homebrew_release.parse_checksum_manifest( + b" not found" + ) + + def test_checksum_parser_rejects_truncated_digest(self): + with self.assertRaisesRegex( + homebrew_release.ReleaseValidationError, "expected 64" + ): + homebrew_release.parse_checksum_manifest( + b"abc123 codeaf-darwin-amd64\n" + ) + + def test_checksum_parser_rejects_non_hex_digest(self): + with self.assertRaisesRegex( + homebrew_release.ReleaseValidationError, "non-hexadecimal" + ): + homebrew_release.parse_checksum_manifest( + (("z" * 64) + " codeaf-darwin-amd64\n").encode() + ) + + def test_checksum_parser_rejects_malformed_line(self): + with self.assertRaisesRegex( + homebrew_release.ReleaseValidationError, "malformed" + ): + homebrew_release.parse_checksum_manifest( + (("a" * 64) + " codeaf-darwin-amd64 extra\n").encode() + ) + + def test_checksum_parser_rejects_duplicate_asset(self): + entry = CHECKSUMS.splitlines()[0] + with self.assertRaisesRegex( + homebrew_release.ReleaseValidationError, "more than once" + ): + homebrew_release.parse_checksum_manifest( + f"{entry}\n{entry}\n".encode() + ) + + def test_checksum_parser_rejects_non_utf8(self): + with self.assertRaisesRegex( + homebrew_release.ReleaseValidationError, "valid UTF-8" + ): + homebrew_release.parse_checksum_manifest(b"\xff") + + def test_checksum_parser_rejects_oversized_response(self): + data = b"a" * (homebrew_release.MAX_CHECKSUM_BYTES + 1) + with self.assertRaisesRegex( + homebrew_release.ReleaseValidationError, "byte limit" + ): + homebrew_release.parse_checksum_manifest(data) + + def test_release_manifest_requires_exact_asset_set_and_matching_digests(self): + release = release_metadata() + version, assets, checksums = homebrew_release.validate_release_manifest( + release, CHECKSUMS.encode() + ) + self.assertEqual(version, "0.8.0") + self.assertIn("codeaf-darwin-arm64", assets) + self.assertEqual(len(checksums), 4) + + release["assets"][1]["digest"] = "sha256:" + "0" * 64 + with self.assertRaisesRegex( + homebrew_release.ReleaseValidationError, + "does not match GitHub's asset digest", + ): + homebrew_release.validate_release_manifest(release, CHECKSUMS.encode()) + + def test_release_manifest_rejects_truncated_download(self): + with self.assertRaisesRegex( + homebrew_release.ReleaseValidationError, + "altered or truncated", + ): + homebrew_release.validate_release_manifest( + release_metadata(), CHECKSUMS.encode()[:-1] + ) + + def test_release_manifest_rejects_missing_checksum_entry(self): + incomplete_manifest = "\n".join(CHECKSUMS.splitlines()[1:]) + "\n" + release = release_metadata(incomplete_manifest) + release_asset_names = {asset["name"] for asset in release["assets"]} + self.assertIn("codeaf-darwin-amd64", release_asset_names) + checksum_asset = next( + asset for asset in release["assets"] if asset["name"] == "checksums.txt" + ) + self.assertEqual( + checksum_asset["digest"], + "sha256:" + hashlib.sha256( + incomplete_manifest.encode("utf-8") + ).hexdigest(), + ) + self.assertEqual( + checksum_asset["size"], len(incomplete_manifest.encode("utf-8")) + ) + with self.assertRaisesRegex( + homebrew_release.ReleaseValidationError, + "missing: codeaf-darwin-amd64", + ): + homebrew_release.validate_release_manifest( + release, + incomplete_manifest.encode(), + ) + + def test_release_manifest_rejects_unexpected_checksum_entry(self): + extra_entry = "0" * 64 + " codeaf-not-a-release-asset" + manifest = CHECKSUMS.rstrip() + "\n" + extra_entry + "\n" + with self.assertRaisesRegex( + homebrew_release.ReleaseValidationError, + "unexpected: codeaf-not-a-release-asset", + ): + homebrew_release.validate_release_manifest( + release_metadata(manifest), + manifest.encode(), + ) + + def test_load_release_inputs_requires_both_fixture_paths(self): + with self.assertRaisesRegex( + homebrew_release.ReleaseValidationError, "must be supplied together" + ): + homebrew_release.load_release_inputs(pathlib.Path("release.json"), None) + + def test_live_loader_fetches_metadata_then_official_manifest(self): + release = release_metadata() + metadata = json.dumps(release).encode() + responses = [ + FakeResponse(metadata, url=homebrew_release.RELEASE_API), + FakeResponse( + CHECKSUMS.encode(), + url=( + "https://release-assets.githubusercontent.com/" + "codeaf-checksums.txt" + ), + ), + ] + with mock.patch( + "scripts.homebrew_release.urllib.request.urlopen", + side_effect=responses, + ) as urlopen: + loaded, checksums, is_live = homebrew_release.load_release_inputs() + self.assertEqual(loaded, release) + self.assertEqual(checksums, CHECKSUMS.encode()) + self.assertTrue(is_live) + self.assertEqual(urlopen.call_count, 2) + self.assertEqual( + urlopen.call_args_list[0].args[0].full_url, + homebrew_release.RELEASE_API, + ) + + def test_live_loader_rejects_redirect_to_untrusted_host(self): + with mock.patch( + "scripts.homebrew_release.urllib.request.urlopen", + return_value=FakeResponse( + b"{}", + url="https://attacker.example/release.json", + ), + ): + with self.assertRaisesRegex( + homebrew_release.ReleaseValidationError, "outside the trusted HTTPS" + ): + homebrew_release.load_release_inputs() + + def test_live_loader_reports_http_errors(self): + error = urllib.error.HTTPError( + homebrew_release.RELEASE_API, 503, "unavailable", {}, io.BytesIO() + ) + with mock.patch( + "scripts.homebrew_release.urllib.request.urlopen", side_effect=error + ): + with self.assertRaisesRegex( + homebrew_release.ReleaseValidationError, "HTTP 503" + ): + homebrew_release.load_release_inputs() + + def test_live_loader_rejects_empty_response(self): + with mock.patch( + "scripts.homebrew_release.urllib.request.urlopen", + return_value=FakeResponse(b"", url=homebrew_release.RELEASE_API), + ): + with self.assertRaisesRegex( + homebrew_release.ReleaseValidationError, "response is empty" + ): + homebrew_release.load_release_inputs() + + def test_live_loader_reports_timeouts(self): + with mock.patch( + "scripts.homebrew_release.urllib.request.urlopen", + side_effect=TimeoutError("request timed out"), + ): + with self.assertRaisesRegex( + homebrew_release.ReleaseValidationError, "timed out" + ): + homebrew_release.load_release_inputs() + + def test_live_loader_rejects_truncated_http_response(self): + with mock.patch( + "scripts.homebrew_release.urllib.request.urlopen", + return_value=FakeResponse( + b"{}", + url=homebrew_release.RELEASE_API, + content_length="20", + ), + ): + with self.assertRaisesRegex( + homebrew_release.ReleaseValidationError, "response is truncated" + ): + homebrew_release.load_release_inputs() + + def test_live_loader_rejects_invalid_json(self): + with mock.patch( + "scripts.homebrew_release.urllib.request.urlopen", + return_value=FakeResponse( + b"not JSON", + url=homebrew_release.RELEASE_API, + ), + ): + with self.assertRaisesRegex( + homebrew_release.ReleaseValidationError, "not valid JSON" + ): + homebrew_release.load_release_inputs() + + +if __name__ == "__main__": + unittest.main()