diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d662ce5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,72 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + workflow_dispatch: + +jobs: + test: + name: Unit Tests (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ['3.12'] + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + + - name: Install package + run: python -m pip install --upgrade pip && python -m pip install . + + - name: Run tests + run: python -m unittest discover -v + + - name: Build distributions + run: python -m pip install build && python -m build + + - name: Verify built wheel in clean environment + shell: python + run: | + import glob + import os + import pathlib + import subprocess + import venv + + env_dir = pathlib.Path(".pkg-test") + venv.EnvBuilder(with_pip=True, clear=True).create(env_dir) + + if os.name == "nt": + env_python = env_dir / "Scripts" / "python.exe" + env_cli = env_dir / "Scripts" / "brave-updater.exe" + else: + env_python = env_dir / "bin" / "python" + env_cli = env_dir / "bin" / "brave-updater" + + wheel = sorted(glob.glob("dist/*.whl")) + if not wheel: + raise SystemExit("No wheel built in dist/") + + subprocess.check_call([str(env_python), "-m", "pip", "install", wheel[-1]]) + subprocess.check_call( + [ + str(env_python), + "-c", + ( + "import importlib.metadata, brave_updater; " + "assert importlib.metadata.version('brave-updater') == brave_updater.__version__" + ), + ] + ) + subprocess.check_call([str(env_cli), "--help"]) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b9e41cf --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,51 @@ +name: Release + +on: + push: + tags: + - "v*" + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Validate tag matches package version + shell: python + run: | + import os + import pathlib + import sys + + sys.path.insert(0, str(pathlib.Path("src").resolve())) + from brave_updater import __version__ + + tag = os.environ["GITHUB_REF_NAME"] + expected = tag[1:] if tag.startswith("v") else tag + if __version__ != expected: + raise SystemExit( + "Release tag {} does not match brave_updater.__version__ {}".format(tag, __version__) + ) + print("Validated release tag {} against package version {}".format(tag, __version__)) + + - name: Build distributions + run: | + python -m pip install --upgrade pip + python -m pip install build twine + python -m build + python -m twine check dist/* + + - name: Upload release assets + uses: softprops/action-gh-release@v2 + with: + files: dist/* diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml new file mode 100644 index 0000000..ac36df1 --- /dev/null +++ b/.github/workflows/smoke.yml @@ -0,0 +1,32 @@ +name: Live Smoke Test + +on: + workflow_dispatch: + schedule: + - cron: "0 8 * * 1" + +jobs: + smoke: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Install package + run: python -m pip install --upgrade pip && python -m pip install . + + - name: Legacy URL selection + shell: bash + run: | + URL=$(python braver.py --print-only) + echo "Selected URL: $URL" + [[ "$URL" =~ ^https:// ]] || exit 1 + + - name: JSON health check + run: brave-updater check --json --source github diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index f8315c0..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,139 +0,0 @@ -name: Test Braver Script - -on: - push: - branches: [ main, master ] - pull_request: - branches: [ main, master ] - workflow_dispatch: - -jobs: - test-linux: - name: Test on Linux - runs-on: ubuntu-latest - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.12' - - - name: Test print-only (URL selection) - run: | - URL=$(python3 braver.py --print-only) - echo "Selected URL: $URL" - if [[ -z "$URL" ]] || [[ ! "$URL" =~ ^https://.*github.* ]]; then - echo "ERROR: Invalid URL returned" - exit 1 - fi - - - name: Test download - run: | - python3 braver.py --dir ./test-downloads - FILE=$(ls ./test-downloads/*.deb | head -n 1) - if [ -z "$FILE" ] || [ ! -f "$FILE" ]; then - echo "ERROR: Download file not found" - exit 1 - fi - SIZE=$(stat -c%s "$FILE") - if [ "$SIZE" -lt 1000000 ]; then - echo "ERROR: File too small ($SIZE bytes)" - exit 1 - fi - echo "Download successful: $FILE ($SIZE bytes)" - - - name: Cleanup - if: always() - run: rm -rf ./test-downloads - - test-macos: - name: Test on macOS - runs-on: macos-latest - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.14' - - - name: Test print-only (URL selection) - run: | - URL=$(python3 braver.py --print-only) - echo "Selected URL: $URL" - if [[ -z "$URL" ]] || [[ ! "$URL" =~ ^https://.*github.* ]]; then - echo "ERROR: Invalid URL returned" - exit 1 - fi - - - name: Test download - run: | - python3 braver.py --dir ./test-downloads - FILE=$(ls ./test-downloads/*.dmg ./test-downloads/*.pkg 2>/dev/null | head -n 1) - if [ -z "$FILE" ] || [ ! -f "$FILE" ]; then - echo "ERROR: Download file not found" - exit 1 - fi - SIZE=$(stat -f%z "$FILE") - if [ "$SIZE" -lt 1000000 ]; then - echo "ERROR: File too small ($SIZE bytes)" - exit 1 - fi - echo "Download successful: $FILE ($SIZE bytes)" - - - name: Cleanup - if: always() - run: | - rm -rf ./test-downloads - hdiutil detach /Volumes/BraveTmpMount 2>/dev/null || true - - test-windows: - name: Test on Windows - runs-on: windows-latest - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.9' - - - name: Test print-only (URL selection) - run: | - $URL = python braver.py --print-only - Write-Host "Selected URL: $URL" - if ([string]::IsNullOrEmpty($URL) -or $URL -notmatch "^https://.*github.*") { - Write-Host "ERROR: Invalid URL returned" - exit 1 - } - - - name: Test download - run: | - $downloadDir = "$env:RUNNER_TEMP\brave-downloads" - python braver.py --dir "$downloadDir" - $file = Get-ChildItem "$downloadDir\*.exe" | Select-Object -First 1 - if ($null -eq $file) { - Write-Host "ERROR: Download file not found" - exit 1 - } - $size = $file.Length - if ($size -lt 1000000) { - Write-Host "ERROR: File too small ($size bytes)" - exit 1 - } - Write-Host "Download successful: $($file.Name) ($size bytes) from $downloadDir" - - - name: Cleanup - if: always() - run: Remove-Item -Recurse -Force "$env:RUNNER_TEMP\brave-downloads" -ErrorAction SilentlyContinue - diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d263db8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +.coverage +.DS_Store +.pytest_cache/ +.venv/ +build/ +dist/ +test-downloads/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ac290a..0b6d3dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,37 +7,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Added -- Automatic SHA256 hash verification using asset digest from GitHub API +### Changed +- Updated repository ownership references to `HackEAC/braver-tz` +- Removed Brave-logo-based branding from the documentation and repository root + +## [1.1.0] - 2026-03-16 -### Improved -- Enhanced installer selection logic with better fallback mechanisms -- Comprehensive error handling with retry logic, timeout handling, and validation -- Windows platform testing completed +### Added +- Packaged Python CLI with `pyproject.toml`, `setup.py`, and `src/brave_updater/` +- `brave-updater` console entrypoint +- Explicit `check`, `download`, `update`, and `install` commands +- Installed-version detection for macOS, Linux, and Windows +- Unit tests for provider logic, version detection, download safety, and CLI compatibility +- Deterministic CI workflow across Linux, macOS, and Windows +- Release workflow for building and publishing wheels/sdists +- Live smoke workflow for read-only endpoint validation +- Homebrew formula starter files and repository transfer checklist docs + +### Changed +- Retained `python3 braver.py ...` as a compatibility wrapper instead of the primary implementation +- Reworked download logic to use resumable `.part` files and atomic final replacement +- Added redirect validation against allowed hosts +- Added safer macOS app replacement flow using staged copies +- Updated documentation to describe the new CLI and the `HackEAC` ownership path + +### Fixed +- Resume downloads no longer append corrupt data when a server ignores `Range` and returns a full `200` +- Version comparisons now treat `1.2.3` and `1.2.3.0` as equivalent ## [1.0.0] - 2024-12-19 ### Added - - Cross-platform Python script to download latest stable Brave Browser from GitHub Releases - Automatic OS detection (macOS, Windows, Linux) - Automatic CPU architecture detection (x64, ARM64, x86) - Linux distribution family detection (Debian-based, RHEL-based, Arch-based) - Intelligent installer selection: - - `.dmg` / `.pkg` for macOS (prefers universal DMG, falls back to arch-specific) - - `.exe` for Windows (prefers StandaloneSetup offline installer) + - `.dmg` / `.pkg` for macOS + - `.exe` for Windows - `.deb` for Debian-based Linux distributions - `.rpm` for RHEL-based Linux distributions - Download functionality with progress indication - Optional installation step via `--install` flag - `--print-only` flag to output download URL for automation - `--dir` flag to specify custom download directory -- GitHub API token support via `GITHUB_TOKEN` environment variable -- macOS installation support (DMG mounting and app copying to /Applications) -- Windows installation support (launches installer) -- Linux installation support (dpkg for .deb, dnf/yum/rpm for .rpm) -- Interactive prompts before destructive operations -- Error handling for missing assets and unsupported systems -- Uses only Python standard library (no external dependencies) -- Python 3.8+ compatibility - +- GitHub API token support via `GITHUB_TOKEN` +- Basic macOS, Windows, and Linux installation support +- Uses only Python standard library diff --git a/README.md b/README.md index cc50fd8..e698898 100644 --- a/README.md +++ b/README.md @@ -1,146 +1,152 @@ -# Braver-tz +# brave-updater -

- braver-tz banner showing Brave shield, network nodes, and clock -

+`brave-updater` is an unofficial cross-platform CLI for checking, downloading, and updating Brave Browser from Brave-controlled release sources. -A cross-platform Python script that automatically downloads the **latest stable release of Brave Browser** directly from **GitHub Releases**, without relying on brave.com. +This project is intended for environments where the usual Brave installation flow is inconvenient or blocked, but GitHub-hosted Brave releases are still reachable. -This project is useful in environments where the official Brave website is blocked or inaccessible, but GitHub remains reachable. +Important: +- This project is not affiliated with or endorsed by Brave Software. +- It does not rehost Brave binaries. +- It downloads Brave installers directly from Brave-controlled GitHub release assets at runtime. +- Homebrew already ships the official macOS `brave-browser` cask. This project is a separate updater/downloader CLI, not a replacement for that cask. ---- +## Current status -## ✨ Features +- Packaged Python CLI with a `src/` layout and console entrypoint: `brave-updater` +- Backward-compatible wrapper retained: `python3 braver.py ...` +- True updater behavior: + - Detects installed Brave version when possible + - Compares it to the latest stable release + - Skips update work when the installed version is already current +- Cross-platform installer selection: + - macOS: `.dmg` / `.pkg` + - Windows: `.exe` + - Linux: `.deb` / `.rpm` on Debian-like and RHEL-like systems +- Safer downloads: + - SHA256 verification + - redirect host validation + - temp-file download + atomic replace + - safe recovery when resume requests are ignored -- Fetches the **latest stable Brave release** from the official GitHub repository -- Automatically detects: - - Operating system (macOS, Windows, Linux) - - CPU architecture (x64, ARM64, etc.) - - Linux distribution family (Debian-based, RHEL-based when possible) -- Selects the most appropriate installer: - - `.dmg` / `.pkg` for macOS - - `.exe` for Windows - - `.deb` or `.rpm` for Linux -- Downloads the installer automatically -- **Automatic SHA256 hash verification** for download integrity -- Optional interactive installation step -- Uses only Python standard library (no external dependencies) +## Repository transfer ---- +The canonical repository is now `HackEAC/braver-tz`. -## 🚀 Usage +Post-transfer notes: +- keep `origin` pointed at `git@github.com:HackEAC/braver-tz.git` +- do not recreate the previous repository path after the transfer, because that would break GitHub's redirect +- follow the post-transfer checklist in [`docs/repository-transfer.md`](docs/repository-transfer.md) -Download the script: +## Installation + +### From source ```bash -git clone https://github.com/maotora/braver-tz.git +git clone git@github.com:HackEAC/braver-tz.git cd braver-tz +python3 -m pip install . ``` -Run to download only: +After installation: ```bash -python3 braver.py +brave-updater check ``` -Download **and** install (you will be prompted): +### Legacy wrapper + +You can still use the old entrypoint directly from the repo: ```bash +python3 braver.py --print-only python3 braver.py --install ``` -Print the direct download URL only (useful for automation): +### Homebrew tap + +The intended tap command once the tap is published is: ```bash -python3 braver.py --print-only +brew install HackEAC/brave-updater/brave-updater ``` -Specify a custom download directory: +The starter formula and tap notes live under [`packaging/homebrew`](packaging/homebrew). -```bash -python3 braver.py --dir /path/to/downloads -``` +## Usage ---- +### Check installed vs latest version -## 🔧 Requirements +```bash +brave-updater check +brave-updater check --json +``` -- Python **3.8+** -- Internet access to `api.github.com` -- Administrator privileges **only if installing** (platform-dependent) +### Download the latest installer -No third-party Python packages are required. +```bash +brave-updater download +brave-updater download --dir ~/Downloads +``` ---- +### Update only when needed -## 🖥️ Platform Support Status +```bash +brave-updater update +brave-updater update --yes +``` -| Platform | Status | -|--------|--------| -| macOS | ✅ Tested | -| Windows | ✅ Tested | -| Linux (Ubuntu) | ✅ Tested | -| Linux (Other distros) | ⚠️ Untested | +### Install a package you already downloaded -> **Important:** -> This script has been tested on **macOS, Windows, and Ubuntu**. -> Testing on other Linux distributions (Debian, Fedora, Arch, etc.) is encouraged. +```bash +brave-updater install /path/to/Brave-Browser-universal.dmg +``` -Please open an issue if you encounter: -- Incorrect installer selection -- Architecture mismatches -- Installation failures -- Any unexpected behavior +### Source strategy ---- +`brave-updater` currently defaults to GitHub Releases because that is the supported product direction for this project. -## 🧠 How It Works +Available source modes: +- `github` +- `official` +- `auto` -1. Detects the local system OS and CPU architecture -2. Queries the GitHub Releases API for: - ``` - brave/brave-browser - ``` -3. Selects the **latest stable release** -4. Chooses the best-matching installer asset -5. Downloads the installer -6. Optionally runs the installer using native OS mechanisms +At the moment, `official` and `auto` resolve through Brave's official GitHub release feed. The code is structured so distinct official non-GitHub providers can be added later without redesigning the CLI. -The script intentionally avoids: -- Beta releases -- Nightly releases -- Release candidates +### Linux support ---- +Linux installs are currently supported on: +- Debian-like distributions +- RHEL-like distributions -## 🔐 GitHub API Rate Limits +Arch-like and other unsupported Linux families fail fast with a clear error instead of downloading the wrong package format. -GitHub enforces unauthenticated rate limits. +## Development -If you hit a rate limit, set a token: +Run the test suite: ```bash -export GITHUB_TOKEN=your_token_here -python3 braver.py +python3 -m unittest discover -v ``` ---- +Build distributions: -## 🤝 Contributing +```bash +python3 -m pip install build +python3 -m build +``` -Contributions are welcome! +## CI and release flow -Especially needed: -- Linux distro compatibility testing +- CI runs deterministic unit tests on Linux, macOS, and Windows. +- A scheduled smoke workflow hits the live Brave release endpoint with read-only checks. +- Tagging `v*` builds and uploads sdist/wheel artifacts to GitHub Releases. -Please: -1. Open an issue describing the problem -2. Include OS, architecture, and installer name -3. Attach logs or error output when possible +## Legal and policy notes ---- +- This project should avoid Brave logos and branded artwork unless you have explicit permission. +- The project should continue to present itself as unofficial and non-affiliated. +- The project should continue to download installers from Brave-controlled endpoints rather than mirror them. -## 📄 License +## License -This project is licensed under the **MIT License**. -See the `LICENSE` file for details. +MIT. See [`LICENSE`](LICENSE). diff --git a/braver-tz-banner.png b/braver-tz-banner.png deleted file mode 100644 index 32a1f5c..0000000 Binary files a/braver-tz-banner.png and /dev/null differ diff --git a/braver.py b/braver.py index 3ba8836..9fbcb73 100644 --- a/braver.py +++ b/braver.py @@ -1,707 +1,23 @@ #!/usr/bin/env python3 """ -brave_stable_fetch.py +Compatibility wrapper for the packaged brave-updater CLI. -Cross-platform downloader (and optional installer) for the latest *stable* Brave release -from GitHub Releases: brave/brave-browser. - -- Detects OS + CPU arch -- Chooses best-matching asset (.dmg, .exe, .deb, .rpm) -- Downloads it -- Optional install step: --install (prompts before doing anything destructive) - -Notes: -- GitHub API is rate-limited. If you hit limits, set env var GITHUB_TOKEN to a personal token. +This keeps `python3 braver.py ...` working during the transition away from the +original single-file script layout. """ -from __future__ import annotations - -import argparse -import hashlib -import json -import os -import platform -import re -import shutil -import socket -import subprocess -import sys -import time -import urllib.parse -import urllib.request -from dataclasses import dataclass from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple -from urllib.error import URLError, HTTPError - -GITHUB_API_LATEST = "https://api.github.com/repos/brave/brave-browser/releases/latest" -ALLOWED_DOMAINS = {"github.com", "githubusercontent.com"} - - -@dataclass -class SystemInfo: - os_name: str # "macos" | "windows" | "linux" - arch: str # "arm64" | "x64" | "x86" | "unknown" - linux_family: str # "debian" | "rhel" | "arch" | "unknown" (only used on linux) - - -# Global flag to control log output destination -_PRINT_ONLY_MODE = False - -def log(msg: str) -> None: - """Log message to stdout (or stderr if in print-only mode).""" - if _PRINT_ONLY_MODE: - print(msg, file=sys.stderr, flush=True) - else: - print(msg, flush=True) - - -def validate_url(url: str) -> bool: - """Validate that URL is from allowed domain.""" - try: - parsed = urllib.parse.urlparse(url) - domain = parsed.netloc.lower() - # Remove port if present - if ":" in domain: - domain = domain.split(":")[0] - return any(domain.endswith(f".{allowed}") or domain == allowed for allowed in ALLOWED_DOMAINS) - except Exception: - return False - - -def sanitize_filename(filename: str) -> str: - """Sanitize filename to prevent path traversal.""" - # Remove path components - filename = os.path.basename(filename) - # Remove dangerous characters - dangerous = ["..", "/", "\\", "\x00"] - for d in dangerous: - filename = filename.replace(d, "") - # Limit length - if len(filename) > 255: - name, ext = os.path.splitext(filename) - filename = name[:255-len(ext)] + ext - return filename - - -def verify_file_hash(file_path: Path, expected_hash: str) -> bool: - """Verify file SHA256 hash.""" - if not expected_hash: - return False - sha256 = hashlib.sha256() - try: - with open(file_path, "rb") as f: - while True: - chunk = f.read(8192) - if not chunk: - break - sha256.update(chunk) - computed = sha256.hexdigest().lower() - expected = expected_hash.lower().strip() - return computed == expected - except Exception as e: - log(f"Error computing hash: {e}") - return False - - -def http_get_json(url: str, timeout: int = 30, max_retries: int = 3) -> Dict[str, Any]: - """Fetch JSON with retry logic and timeout.""" - headers = { - "Accept": "application/vnd.github+json", - "User-Agent": "brave-stable-fetch/1.0", - } - token = os.environ.get("GITHUB_TOKEN") - if token: - # Basic validation - token should not contain spaces or newlines - token = token.strip() - if not token or "\n" in token or " " in token: - log("Warning: GITHUB_TOKEN appears invalid, ignoring.") - else: - headers["Authorization"] = f"Bearer {token}" - - last_error = None - for attempt in range(max_retries): - try: - req = urllib.request.Request(url, headers=headers) - with urllib.request.urlopen(req, timeout=timeout) as resp: - if resp.status != 200: - raise URLError(f"HTTP {resp.status}: {resp.reason}") - data = resp.read().decode("utf-8") - result = json.loads(data) - # Validate response structure - if not isinstance(result, dict): - raise ValueError("API response is not a JSON object") - return result - except (URLError, socket.timeout, socket.error, json.JSONDecodeError, ValueError) as e: - last_error = e - if attempt < max_retries - 1: - wait = 2 ** attempt # Exponential backoff - log(f"Attempt {attempt + 1} failed: {e}. Retrying in {wait}s...") - time.sleep(wait) - else: - log(f"All {max_retries} attempts failed.") - - raise RuntimeError(f"Failed to fetch {url} after {max_retries} attempts: {last_error}") - - -def validate_release_data(rel: Dict[str, Any]) -> None: - """Validate GitHub release API response structure.""" - required_fields = ["tag_name", "assets"] - for field in required_fields: - if field not in rel: - raise ValueError(f"Invalid API response: missing field '{field}'") - - if not isinstance(rel["assets"], list): - raise ValueError("Invalid API response: 'assets' must be a list") - - for asset in rel["assets"]: - if not isinstance(asset, dict): - raise ValueError("Invalid API response: asset must be a dict") - if "name" not in asset or "browser_download_url" not in asset: - raise ValueError("Invalid API response: asset missing required fields") - - -def detect_linux_family() -> str: - # Best-effort detection using /etc/os-release - osr = Path("/etc/os-release") - if not osr.exists(): - return "unknown" - - text = osr.read_text(errors="ignore") - # Parse like KEY=VALUE - vals = {} - for line in text.splitlines(): - line = line.strip() - if not line or line.startswith("#") or "=" not in line: - continue - k, v = line.split("=", 1) - vals[k.strip()] = v.strip().strip('"') - - distro_id = (vals.get("ID") or "").lower() - like = (vals.get("ID_LIKE") or "").lower() - - def has_any(s: str, words: List[str]) -> bool: - return any(w in s for w in words) - - if has_any(distro_id, ["debian", "ubuntu", "linuxmint", "pop"]) or has_any(like, ["debian", "ubuntu"]): - return "debian" - if has_any(distro_id, ["fedora", "rhel", "centos", "rocky", "almalinux", "opensuse", "sles"]) or has_any(like, ["rhel", "fedora", "suse"]): - return "rhel" - if has_any(distro_id, ["arch", "manjaro", "endeavouros"]) or has_any(like, ["arch"]): - return "arch" - - return "unknown" - - -def detect_system() -> SystemInfo: - sys_plat = sys.platform.lower() - machine = platform.machine().lower() - - # OS - if sys_plat.startswith("darwin"): - os_name = "macos" - elif sys_plat.startswith("win"): - os_name = "windows" - elif sys_plat.startswith("linux"): - os_name = "linux" - else: - os_name = "unknown" - - # Arch - # Normalize common values - if machine in ("arm64", "aarch64"): - arch = "arm64" - elif machine in ("x86_64", "amd64"): - arch = "x64" - elif machine in ("i386", "i686", "x86"): - arch = "x86" - else: - arch = "unknown" - - linux_family = detect_linux_family() if os_name == "linux" else "unknown" - return SystemInfo(os_name=os_name, arch=arch, linux_family=linux_family) - - -def pick_asset(assets: List[Dict[str, Any]], si: SystemInfo) -> Tuple[Dict[str, Any], str]: - """ - Returns (asset, reason). - Picks conservatively and robustly by filename patterns. - """ - names = [a.get("name", "") for a in assets] - - def find_by_pred(pred) -> Optional[Dict[str, Any]]: - for a in assets: - n = (a.get("name") or "").lower() - if pred(n): - return a - return None - - # macOS: prefer universal dmg, then arch-specific dmg, then pkg - if si.os_name == "macos": - # Brave commonly uses "Brave-Browser-universal.dmg" in releases. - a = find_by_pred(lambda n: n.endswith(".dmg") and "universal" in n) - if a: - return a, "macOS: chose universal .dmg" - if si.arch == "arm64": - a = find_by_pred(lambda n: n.endswith(".dmg") and ("arm64" in n or "aarch64" in n)) - if a: - return a, "macOS ARM: chose arm64 .dmg" - if si.arch == "x64": - a = find_by_pred(lambda n: n.endswith(".dmg") and ("x64" in n or "amd64" in n or "x86_64" in n)) - if a: - return a, "macOS Intel: chose x64 .dmg" - a = find_by_pred(lambda n: n.endswith(".dmg")) - if a: - return a, "macOS: chose first available .dmg" - a = find_by_pred(lambda n: n.endswith(".pkg")) - if a: - return a, "macOS: chose .pkg (no .dmg found)" - - # Windows: prefer StandaloneSetup (offline installer), then any .exe - if si.os_name == "windows": - a = find_by_pred(lambda n: n.endswith(".exe") and "standalone" in n and "setup" in n) - if a: - return a, "Windows: chose StandaloneSetup.exe (offline installer)" - a = find_by_pred(lambda n: n.endswith(".exe") and "setup" in n) - if a: - return a, "Windows: chose Setup.exe" - a = find_by_pred(lambda n: n.endswith(".exe")) - if a: - return a, "Windows: chose first available .exe" - - # Linux: prefer .deb for Debian-like, .rpm for RHEL-like; try to match arch - if si.os_name == "linux": - arch_tokens = [] - if si.arch == "x64": - arch_tokens = ["amd64", "x86_64"] - elif si.arch == "arm64": - arch_tokens = ["arm64", "aarch64"] - - if si.linux_family == "debian": - # Prefer matching arch if present in filename - for tok in arch_tokens: - a = find_by_pred(lambda n, t=tok: n.endswith(".deb") and t in n) - if a: - return a, f"Linux Debian-like: chose .deb matching {tok}" - a = find_by_pred(lambda n: n.endswith(".deb")) - if a: - return a, "Linux Debian-like: chose first available .deb" - - if si.linux_family == "rhel": - for tok in arch_tokens: - a = find_by_pred(lambda n, t=tok: n.endswith(".rpm") and t in n) - if a: - return a, f"Linux RHEL-like: chose .rpm matching {tok}" - a = find_by_pred(lambda n: n.endswith(".rpm")) - if a: - return a, "Linux RHEL-like: chose first available .rpm" - - # Fallbacks: try .deb then .rpm then .tar.gz/.zip - a = find_by_pred(lambda n: n.endswith(".deb")) - if a: - return a, "Linux: fallback to .deb" - a = find_by_pred(lambda n: n.endswith(".rpm")) - if a: - return a, "Linux: fallback to .rpm" - a = find_by_pred(lambda n: n.endswith(".tar.gz") or n.endswith(".zip")) - if a: - return a, "Linux: fallback to archive" - - raise RuntimeError( - "Could not find a suitable asset in this release.\n" - f"Detected system: {si}\n" - f"Assets seen (first 30): {names[:30]}" - ) - - -def download_file(url: str, dest: Path, expected_hash: Optional[str] = None, - timeout: int = 60, max_retries: int = 3) -> None: - """Download file with retry, resume, and integrity verification.""" - if not validate_url(url): - raise ValueError(f"URL not from allowed domain: {url}") - - dest.parent.mkdir(parents=True, exist_ok=True) - - # Check disk space (rough estimate - need at least 500MB free) - try: - stat = shutil.disk_usage(dest.parent) - free_mb = stat.free / (1024 * 1024) - if free_mb < 500: - raise RuntimeError(f"Insufficient disk space: {free_mb:.0f}MB free (need at least 500MB)") - except Exception as e: - log(f"Warning: Could not check disk space: {e}") - - # Resume partial download - resume_pos = 0 - if dest.exists(): - resume_pos = dest.stat().st_size - if resume_pos > 0: - log(f"Resuming download from byte {resume_pos}...") - - last_error = None - for attempt in range(max_retries): - try: - headers = {} - if resume_pos > 0: - headers["Range"] = f"bytes={resume_pos}-" - - req = urllib.request.Request(url, headers=headers) - with urllib.request.urlopen(req, timeout=timeout) as resp: - # Check if server supports resume - if resume_pos > 0 and resp.status not in (206, 200): - log("Server doesn't support resume, starting from beginning...") - resume_pos = 0 - dest.unlink() - headers.pop("Range", None) - req = urllib.request.Request(url, headers=headers) - resp = urllib.request.urlopen(req, timeout=timeout) - - total = int(resp.headers.get("Content-Length", 0)) or (resp.length or 0) - if resume_pos > 0 and total > 0: - total += resume_pos - - mode = "ab" if resume_pos > 0 else "wb" - log(f"Downloading:\n {url}\n-> {dest}") - with open(dest, mode) as f: - read = resume_pos - chunk = 1024 * 256 - while True: - b = resp.read(chunk) - if not b: - break - f.write(b) - read += len(b) - if total: - pct = (read / total) * 100 - print(f"\r{pct:6.2f}% ({read}/{total} bytes)", end="", flush=True) - if total: - print() - - # Verify file size if Content-Length was provided - actual_size = dest.stat().st_size - if total > 0 and actual_size != total: - raise RuntimeError(f"Size mismatch: expected {total}, got {actual_size}") - - # Verify hash if provided - if expected_hash: - log("Verifying download integrity...") - if not verify_file_hash(dest, expected_hash): - raise RuntimeError("Download integrity check failed! File may be corrupted.") - log("Integrity check passed.") - - log("Download complete.") - return - - except HTTPError as e: - # Handle 416 Range Not Satisfiable - file may have changed or range invalid - if e.code == 416: - log(f"Range request failed (416), deleting partial file and restarting...") - if dest.exists(): - dest.unlink() - resume_pos = 0 - last_error = e - if attempt < max_retries - 1: - wait = 2 ** attempt - log(f"Download attempt {attempt + 1} failed: {e}. Retrying in {wait}s...") - time.sleep(wait) - continue - else: - last_error = e - if attempt < max_retries - 1: - wait = 2 ** attempt - log(f"Download attempt {attempt + 1} failed: {e}. Retrying in {wait}s...") - time.sleep(wait) - else: - # Clean up partial download on final failure if empty - if dest.exists() and dest.stat().st_size == 0: - dest.unlink() - except (URLError, socket.timeout, socket.error, OSError) as e: - last_error = e - if attempt < max_retries - 1: - wait = 2 ** attempt - log(f"Download attempt {attempt + 1} failed: {e}. Retrying in {wait}s...") - time.sleep(wait) - else: - # Clean up partial download on final failure if empty - if dest.exists() and dest.stat().st_size == 0: - dest.unlink() - - raise RuntimeError(f"Failed to download {url} after {max_retries} attempts: {last_error}") - - -def prompt_yes_no(question: str, default_no: bool = True) -> bool: - # Check if stdin is available (interactive terminal) - if not sys.stdin.isatty(): - # Non-interactive environment (CI, pipes, etc.) - return default - return not default_no - suffix = " [y/N]: " if default_no else " [Y/n]: " - ans = input(question + suffix).strip().lower() - if not ans: - return not default_no - return ans in ("y", "yes") - - -def install_macos(pkg_or_dmg: Path) -> None: - """Install with better error handling and cleanup.""" - if not pkg_or_dmg.exists(): - raise FileNotFoundError(f"Install file not found: {pkg_or_dmg}") - - if pkg_or_dmg.suffix.lower() == ".pkg": - log("Installing .pkg via macOS installer (will prompt for admin password if needed)...") - try: - subprocess.check_call( - ["sudo", "installer", "-pkg", str(pkg_or_dmg), "-target", "/"], - timeout=600 # 10 minute timeout - ) - log("Install finished.") - return - except subprocess.TimeoutExpired: - raise RuntimeError("Installation timed out after 10 minutes") - except subprocess.CalledProcessError as e: - raise RuntimeError(f"Installation failed with exit code {e.returncode}") - - if pkg_or_dmg.suffix.lower() != ".dmg": - raise RuntimeError("macOS install expects .dmg or .pkg") - - mountpoint = Path("/Volumes/BraveTmpMount") - mounted = False - - try: - # Clean up any existing mount - if mountpoint.exists(): - try: - subprocess.run(["hdiutil", "detach", str(mountpoint), "-quiet"], - capture_output=True, timeout=5) - except Exception: - pass - - mountpoint.mkdir(parents=True, exist_ok=True) - log("Mounting DMG...") - subprocess.check_call( - ["hdiutil", "attach", str(pkg_or_dmg), "-mountpoint", str(mountpoint), - "-nobrowse", "-quiet"], - timeout=60 - ) - mounted = True - - app = mountpoint / "Brave Browser.app" - if not app.exists(): - apps = list(mountpoint.glob("*.app")) - if apps: - app = apps[0] - else: - raise RuntimeError("Could not find .app inside the DMG.") - - target = Path("/Applications") / app.name - log(f"Copying {app.name} to /Applications ...") - - if target.exists(): - subprocess.check_call( - ["sudo", "rm", "-rf", str(target)], - timeout=30 - ) - - subprocess.check_call( - ["sudo", "cp", "-R", str(app), str(target)], - timeout=300 - ) - log("Brave copied to /Applications.") - - except subprocess.TimeoutExpired: - raise RuntimeError("Operation timed out") - except subprocess.CalledProcessError as e: - raise RuntimeError(f"Installation command failed: {e}") - finally: - if mounted: - log("Unmounting DMG...") - for _ in range(3): # Try up to 3 times - try: - subprocess.run( - ["hdiutil", "detach", str(mountpoint), "-quiet"], - timeout=10, - capture_output=True - ) - break - except Exception as e: - if _ == 2: - log(f"Warning: Could not unmount DMG: {e}") - else: - time.sleep(1) - - -def install_windows(exe_path: Path) -> None: - """Runs the installer normally.""" - if not exe_path.exists(): - raise FileNotFoundError(f"Install file not found: {exe_path}") - log("Launching installer...") - os.startfile(str(exe_path)) # type: ignore[attr-defined] - - -def install_linux(pkg: Path, family: str) -> None: - """Install Linux package with better error handling.""" - if not pkg.exists(): - raise FileNotFoundError(f"Install file not found: {pkg}") - - if pkg.suffix.lower() == ".deb": - log("Installing .deb (will prompt for sudo password if needed)...") - try: - subprocess.check_call( - ["sudo", "dpkg", "-i", str(pkg)], - timeout=600 - ) - log("Install finished. (If dependencies failed, run: sudo apt -f install)") - return - except subprocess.TimeoutExpired: - raise RuntimeError("Installation timed out after 10 minutes") - except subprocess.CalledProcessError as e: - raise RuntimeError(f"Installation failed with exit code {e.returncode}") - - if pkg.suffix.lower() == ".rpm": - log("Installing .rpm (will prompt for sudo password if needed)...") - # dnf is preferred if available; otherwise rpm - try: - if shutil.which("dnf"): - subprocess.check_call( - ["sudo", "dnf", "install", "-y", str(pkg)], - timeout=600 - ) - elif shutil.which("yum"): - subprocess.check_call( - ["sudo", "yum", "install", "-y", str(pkg)], - timeout=600 - ) - else: - subprocess.check_call( - ["sudo", "rpm", "-Uvh", str(pkg)], - timeout=600 - ) - log("Install finished.") - return - except subprocess.TimeoutExpired: - raise RuntimeError("Installation timed out after 10 minutes") - except subprocess.CalledProcessError as e: - raise RuntimeError(f"Installation failed with exit code {e.returncode}") - - raise RuntimeError(f"Don't know how to install {pkg.name} on Linux (family={family}).") - - -def main() -> int: - global _PRINT_ONLY_MODE - ap = argparse.ArgumentParser(description="Download latest stable Brave from GitHub Releases (and optionally install).") - ap.add_argument("--dir", default=str(Path.home() / "Downloads"), help="Download directory (default: ~/Downloads)") - ap.add_argument("--install", action="store_true", help="After download, prompt and attempt to install") - ap.add_argument("--print-only", action="store_true", help="Only print chosen version and asset URL; do not download") - ap.add_argument("--skip-verify", action="store_true", help="Skip integrity verification (not recommended)") - args = ap.parse_args() - - # Set global flag for log output redirection - _PRINT_ONLY_MODE = args.print_only - - try: - si = detect_system() - log(f"Detected system: os={si.os_name}, arch={si.arch}, linux_family={si.linux_family}") - - rel = http_get_json(GITHUB_API_LATEST) - validate_release_data(rel) - - tag = rel.get("tag_name", "") - name = rel.get("name", "") - body = rel.get("body", "") - assets = rel.get("assets", []) - - if not assets: - log("Release has no assets. Exiting.") - return 2 - - asset, reason = pick_asset(assets, si) - asset_name = asset.get("name", "unknown") - url = asset.get("browser_download_url", "") - - # Get hash if available from asset's digest field - expected_hash = None - if not args.skip_verify: - # Extract SHA256 from asset's digest field (format: "sha256:hash") - digest = asset.get("digest", "") - if digest and digest.startswith("sha256:"): - expected_hash = digest[7:] # Remove "sha256:" prefix - log(f"Found SHA256 hash for verification: {expected_hash[:16]}...") - else: - # Fallback: Look for checksums file in release assets - for a in assets: - name_lower = a.get("name", "").lower() - if "checksum" in name_lower or "sha256" in name_lower: - # Could download and parse checksums file here if needed - pass - - # Sanitize filename - asset_name = sanitize_filename(asset_name) - - log(f"Latest stable release: {tag} ({name})") - log(f"Chosen asset: {asset_name}") - log(f"Reason: {reason}") - if body: - m = re.search(r"Chromium:\s*([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)", body) - if m: - log(f"Chromium base: {m.group(1)}") - - if not url: - log("No download URL found for chosen asset. Exiting.") - return 3 - - if not validate_url(url): - log(f"ERROR: Download URL failed validation: {url}") - return 5 - - if args.print_only: - print(url) - return 0 +import sys - dest_dir = Path(args.dir).expanduser().resolve() - # Ensure destination is within user's home, current working directory, or explicitly allowed absolute path - cwd = Path.cwd() - is_in_home = str(dest_dir).startswith(str(Path.home())) - is_in_cwd = str(dest_dir).startswith(str(cwd)) - is_absolute_allowed = args.dir.startswith("/") or (os.name == "nt" and len(args.dir) > 1 and args.dir[1] == ":") - - if not (is_in_home or is_in_cwd or is_absolute_allowed): - log(f"Warning: Download directory is outside home and current directory: {dest_dir}") - if not prompt_yes_no("Continue anyway?", default_no=True): - return 1 - - dest = dest_dir / asset_name - download_file(url, dest, expected_hash=expected_hash if not args.skip_verify else None) - if args.install: - if not prompt_yes_no(f"Install {asset_name} now?", default_no=True): - log("Install skipped.") - return 0 +ROOT = Path(__file__).resolve().parent +SRC = ROOT / "src" - try: - if si.os_name == "macos": - install_macos(dest) - elif si.os_name == "windows": - install_windows(dest) - elif si.os_name == "linux": - install_linux(dest, si.linux_family) - else: - log("Unknown OS; cannot install automatically.") - return 4 +if str(SRC) not in sys.path: + sys.path.insert(0, str(SRC)) - log("Done.") - except Exception as e: - log(f"Installation failed: {e}") - return 6 - - return 0 - except KeyboardInterrupt: - log("\nInterrupted by user.") - return 130 - except Exception as e: - log(f"Error: {e}") - import traceback - traceback.print_exc() - return 1 +from brave_updater.cli import main if __name__ == "__main__": - raise SystemExit(main()) \ No newline at end of file + raise SystemExit(main()) diff --git a/docs/repository-transfer.md b/docs/repository-transfer.md new file mode 100644 index 0000000..c559fe4 --- /dev/null +++ b/docs/repository-transfer.md @@ -0,0 +1,34 @@ +# Repository Transfer Checklist + +This repository now lives at `HackEAC/braver-tz`. + +## Current remote + +```bash +git remote set-url origin git@github.com:HackEAC/braver-tz.git +git remote -v +``` + +## Post-transfer checks + +1. Confirm that GitHub redirects the old URL to the new owner. +2. Verify org-side repository settings: + - GitHub Actions enabled + - branch protection rules + - required secrets and variables + - release permissions + - admin access for the intended maintainers +3. Do not recreate the previous repository path, because that breaks GitHub's redirect from the transferred repository. +4. Update any remaining references that still point to the old owner, including: +- clone commands +- release links +- Homebrew tap references +- badges or status URLs + +Run these local sanity checks: + +```bash +git fetch origin +python3 braver.py --help +python3 -m unittest discover -v +``` diff --git a/packaging/homebrew/README.md b/packaging/homebrew/README.md new file mode 100644 index 0000000..8928d56 --- /dev/null +++ b/packaging/homebrew/README.md @@ -0,0 +1,32 @@ +# Homebrew Tap Starter + +This repository is not itself the Homebrew tap. + +The intended tap repository is: + +- `HackEAC/homebrew-brave-updater` + +## Intended install command + +```bash +brew install HackEAC/brave-updater/brave-updater +``` + +## How to use these files + +1. Create the tap repository `HackEAC/homebrew-brave-updater`. +2. Copy [`brave-updater.rb`](brave-updater.rb) into the tap repo's `Formula/` directory. +3. Replace the release URL and `sha256` with the actual tagged release archive and checksum. +4. Test with: + +```bash +brew tap HackEAC/brave-updater /path/to/homebrew-brave-updater +brew install brave-updater +brew test brave-updater +``` + +## Notes + +- Homebrew already provides the official `brave-browser` cask on macOS. +- This formula packages the updater CLI, not the browser itself. +- The formula uses Homebrew-managed Python so end users do not need to manually install Python first. diff --git a/packaging/homebrew/brave-updater.rb b/packaging/homebrew/brave-updater.rb new file mode 100644 index 0000000..d83d860 --- /dev/null +++ b/packaging/homebrew/brave-updater.rb @@ -0,0 +1,20 @@ +class BraveUpdater < Formula + include Language::Python::Virtualenv + + desc "Unofficial CLI to download and update Brave Browser from Brave-controlled release sources" + homepage "https://github.com/HackEAC/braver-tz" + url "https://github.com/HackEAC/braver-tz/archive/refs/tags/v1.1.0.tar.gz" + sha256 "REPLACE_WITH_RELEASE_SHA256" + license "MIT" + + depends_on "python@3.12" + + def install + virtualenv_install_with_resources + end + + test do + output = shell_output("#{bin}/brave-updater --help") + assert_match "update", output + end +end diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..11c9967 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,44 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "brave-updater" +dynamic = ["version"] +description = "Unofficial CLI to download and update Brave Browser from official Brave-controlled release sources." +readme = "README.md" +requires-python = ">=3.8" +license = "MIT" +authors = [ + { name = "HackEAC" } +] +keywords = ["brave", "browser", "updater", "installer", "cli"] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: End Users/Desktop", + "Operating System :: MacOS", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX :: Linux", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Internet :: WWW/HTTP", + "Topic :: System :: Installation/Setup", +] + +[project.scripts] +brave-updater = "brave_updater.cli:main" + +[tool.setuptools] +package-dir = {"" = "src"} + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.dynamic] +version = {attr = "brave_updater.__version__"} diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..b024da8 --- /dev/null +++ b/setup.py @@ -0,0 +1,4 @@ +from setuptools import setup + + +setup() diff --git a/src/brave_updater/__init__.py b/src/brave_updater/__init__.py new file mode 100644 index 0000000..ba23d19 --- /dev/null +++ b/src/brave_updater/__init__.py @@ -0,0 +1,3 @@ +"""brave_updater package.""" + +__version__ = "1.1.0" diff --git a/src/brave_updater/__main__.py b/src/brave_updater/__main__.py new file mode 100644 index 0000000..3c3fe91 --- /dev/null +++ b/src/brave_updater/__main__.py @@ -0,0 +1,5 @@ +from brave_updater.cli import main + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/brave_updater/cli.py b/src/brave_updater/cli.py new file mode 100644 index 0000000..558c836 --- /dev/null +++ b/src/brave_updater/cli.py @@ -0,0 +1,176 @@ +"""CLI entrypoint for brave-updater.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import List, Optional + +from brave_updater.console import make_logger, prompt_yes_no +from brave_updater.download import sanitize_filename +from brave_updater.providers import extract_chromium_version, pick_asset +from brave_updater.service import BraveUpdater +from brave_updater.system import detect_system + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Download and update Brave Browser from Brave-controlled release sources." + ) + parser.add_argument("--dir", default=str(Path.home() / "Downloads"), help="Download directory (default: ~/Downloads)") + parser.add_argument("--install", action="store_true", help="Legacy mode: prompt to install after download") + parser.add_argument("--print-only", action="store_true", help="Legacy mode: print the selected download URL only") + parser.add_argument("--skip-verify", action="store_true", help="Skip integrity verification (not recommended)") + parser.add_argument("--source", default="github", choices=("github", "official", "auto"), help="Release source strategy") + + subparsers = parser.add_subparsers(dest="command") + + check_parser = subparsers.add_parser("check", help="Inspect the installed version and the latest available release") + check_parser.add_argument("--json", action="store_true", help="Emit machine-readable output") + check_parser.add_argument("--source", default="github", choices=("github", "official", "auto"), help="Release source strategy") + + download_parser = subparsers.add_parser("download", help="Download the best installer for this system") + download_parser.add_argument("--dir", default=str(Path.home() / "Downloads"), help="Download directory") + download_parser.add_argument("--skip-verify", action="store_true", help="Skip integrity verification") + download_parser.add_argument("--force", action="store_true", help="Re-download even if the target file already exists") + download_parser.add_argument("--source", default="github", choices=("github", "official", "auto"), help="Release source strategy") + + update_parser = subparsers.add_parser("update", help="Download and optionally install a new version when available") + update_parser.add_argument("--dir", default=str(Path.home() / "Downloads"), help="Download directory") + update_parser.add_argument("--skip-verify", action="store_true", help="Skip integrity verification") + update_parser.add_argument("--force", action="store_true", help="Update even if the installed version matches the latest") + update_parser.add_argument("--yes", action="store_true", help="Install without prompting") + update_parser.add_argument("--source", default="github", choices=("github", "official", "auto"), help="Release source strategy") + + install_parser = subparsers.add_parser("install", help="Install an already-downloaded Brave package") + install_parser.add_argument("path", help="Path to a downloaded installer package") + + return parser + + +def main(argv: Optional[List[str]] = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + if args.command == "check": + return run_check(args) + if args.command == "download": + return run_download(args) + if args.command == "update": + return run_update(args) + if args.command == "install": + return run_install(args) + return run_legacy(args) + + +def run_check(args: argparse.Namespace) -> int: + logger = make_logger(print_only_mode=args.json) + updater = BraveUpdater(source=args.source, log=logger) + result = updater.check() + + if args.json: + payload = { + "os": result.system.os_name, + "arch": result.system.arch, + "linux_family": result.system.linux_family, + "installed_version": result.installed_version, + "latest_version": result.latest_version, + "update_available": result.update_available, + "asset_name": result.selected_asset.name, + "asset_url": result.selected_asset.url, + "selection_reason": result.selection_reason, + "release_source": result.release_source, + "chromium_version": result.chromium_version, + } + print(json.dumps(payload, indent=2, sort_keys=True)) + return 0 + + logger( + "Detected system: os={}, arch={}, linux_family={}".format( + result.system.os_name, + result.system.arch, + result.system.linux_family, + ) + ) + logger("Installed version: {}".format(result.installed_version or "not found")) + logger("Latest stable release: {}".format(result.latest_version)) + logger("Chosen asset: {}".format(result.selected_asset.name)) + logger("Reason: {}".format(result.selection_reason)) + if result.chromium_version: + logger("Chromium base: {}".format(result.chromium_version)) + if result.update_available: + logger("Update available.") + else: + logger("Already up to date.") + return 0 + + +def run_download(args: argparse.Namespace) -> int: + logger = make_logger() + updater = BraveUpdater(source=args.source, log=logger) + destination = updater.download_latest( + directory=Path(args.dir).expanduser().resolve(), + skip_verify=args.skip_verify, + force=args.force, + ) + logger("Saved installer to {}".format(destination)) + return 0 + + +def run_update(args: argparse.Namespace) -> int: + logger = make_logger() + updater = BraveUpdater(source=args.source, log=logger) + updater.update( + directory=Path(args.dir).expanduser().resolve(), + skip_verify=args.skip_verify, + force=args.force, + assume_yes=args.yes, + ) + return 0 + + +def run_install(args: argparse.Namespace) -> int: + logger = make_logger() + updater = BraveUpdater(source="github", log=logger) + updater.install(Path(args.path).expanduser().resolve()) + return 0 + + +def run_legacy(args: argparse.Namespace) -> int: + logger = make_logger(print_only_mode=args.print_only) + updater = BraveUpdater(source=args.source, log=logger) + release = updater.load_release() + system_info = detect_system() + asset, reason = pick_asset(release.assets, system_info) + + logger( + "Detected system: os={}, arch={}, linux_family={}".format( + system_info.os_name, system_info.arch, system_info.linux_family + ) + ) + logger("Latest stable release: {} ({})".format(release.version, release.name)) + logger("Chosen asset: {}".format(asset.name)) + logger("Reason: {}".format(reason)) + chromium_version = extract_chromium_version(release.body) + if chromium_version: + logger("Chromium base: {}".format(chromium_version)) + + if args.print_only: + print(asset.url) + return 0 + + destination = Path(args.dir).expanduser().resolve() / sanitize_filename(asset.name) + updater.download_latest( + directory=Path(args.dir).expanduser().resolve(), + skip_verify=args.skip_verify, + force=False, + ) + + if args.install: + if prompt_yes_no("Install {} now?".format(destination.name), default_no=True): + updater.install(destination) + logger("Done.") + else: + logger("Install skipped.") + return 0 diff --git a/src/brave_updater/console.py b/src/brave_updater/console.py new file mode 100644 index 0000000..eef0b34 --- /dev/null +++ b/src/brave_updater/console.py @@ -0,0 +1,30 @@ +"""Console helpers shared by the CLI and service layer.""" + +from __future__ import annotations + +import sys +from typing import Callable + +LogFn = Callable[[str], None] + + +def make_logger(print_only_mode: bool = False) -> LogFn: + """Create a simple line-oriented logger.""" + + def log(message: str) -> None: + stream = sys.stderr if print_only_mode else sys.stdout + print(message, file=stream, flush=True) + + return log + + +def prompt_yes_no(question: str, default_no: bool = True) -> bool: + """Prompt for confirmation when stdin is interactive.""" + if not sys.stdin.isatty(): + return not default_no + + suffix = " [y/N]: " if default_no else " [Y/n]: " + answer = input(question + suffix).strip().lower() + if not answer: + return not default_no + return answer in ("y", "yes") diff --git a/src/brave_updater/download.py b/src/brave_updater/download.py new file mode 100644 index 0000000..e94c9b5 --- /dev/null +++ b/src/brave_updater/download.py @@ -0,0 +1,15 @@ +"""Local file helpers used by the updater service.""" + +from __future__ import annotations + +import os + + +def sanitize_filename(filename: str) -> str: + filename = os.path.basename(filename) + for item in ("..", "/", "\\", "\x00"): + filename = filename.replace(item, "") + if len(filename) > 255: + name, extension = os.path.splitext(filename) + filename = name[:255 - len(extension)] + extension + return filename diff --git a/src/brave_updater/installers.py b/src/brave_updater/installers.py new file mode 100644 index 0000000..c74855b --- /dev/null +++ b/src/brave_updater/installers.py @@ -0,0 +1,170 @@ +"""Platform-specific installer adapters.""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import tempfile +import time +from pathlib import Path +from typing import Protocol + +from brave_updater.console import LogFn +from brave_updater.models import SystemInfo + + +class Installer(Protocol): + def install(self, package_path: Path) -> None: + raise NotImplementedError + + +class MacOSInstaller: + def __init__(self, log: LogFn): + self.log = log + + def install(self, package_path: Path) -> None: + if not package_path.exists(): + raise FileNotFoundError("Install file not found: {}".format(package_path)) + + suffix = package_path.suffix.lower() + if suffix == ".pkg": + self.log("Installing .pkg via macOS installer...") + subprocess.check_call(["sudo", "installer", "-pkg", str(package_path), "-target", "/"], timeout=600) + self.log("Install finished.") + return + + if suffix != ".dmg": + raise RuntimeError("macOS install expects .dmg or .pkg") + + mountpoint = Path("/Volumes/BraveTmpMount") + mounted = False + try: + if mountpoint.exists(): + subprocess.run(["hdiutil", "detach", str(mountpoint), "-quiet"], capture_output=True, timeout=5, check=False) + + mountpoint.mkdir(parents=True, exist_ok=True) + self.log("Mounting DMG...") + subprocess.check_call( + ["hdiutil", "attach", str(package_path), "-mountpoint", str(mountpoint), "-nobrowse", "-quiet"], + timeout=60, + ) + mounted = True + + app_path = mountpoint / "Brave Browser.app" + if not app_path.exists(): + apps = list(mountpoint.glob("*.app")) + if not apps: + raise RuntimeError("Could not find .app inside the DMG") + app_path = apps[0] + + target = Path("/Applications") / app_path.name + with tempfile.TemporaryDirectory(prefix="brave-updater-") as temp_dir: + staged_app = Path(temp_dir) / app_path.name + self.log("Staging application bundle...") + subprocess.check_call(["cp", "-R", str(app_path), str(staged_app)], timeout=300) + + temp_target = Path("/Applications") / (app_path.name + ".new") + if temp_target.exists(): + subprocess.check_call(["sudo", "rm", "-rf", str(temp_target)], timeout=30) + subprocess.check_call(["sudo", "cp", "-R", str(staged_app), str(temp_target)], timeout=300) + _replace_macos_app_bundle(target, temp_target) + + self.log("Brave copied to /Applications.") + finally: + if mounted: + self.log("Unmounting DMG...") + for _ in range(3): + try: + subprocess.run(["hdiutil", "detach", str(mountpoint), "-quiet"], timeout=10, capture_output=True, check=False) + break + except Exception: + time.sleep(1) + + +class WindowsInstaller: + def __init__(self, log: LogFn): + self.log = log + + def install(self, package_path: Path) -> None: + if not package_path.exists(): + raise FileNotFoundError("Install file not found: {}".format(package_path)) + self.log("Launching installer...") + os.startfile(str(package_path)) # type: ignore[attr-defined] + + +class LinuxInstaller: + def __init__(self, log: LogFn, family: str): + self.log = log + self.family = family + + def install(self, package_path: Path) -> None: + if not package_path.exists(): + raise FileNotFoundError("Install file not found: {}".format(package_path)) + + if self.family not in {"debian", "rhel"}: + raise RuntimeError( + "Unsupported Linux distribution family: {}. Only Debian-like and RHEL-like systems are supported.".format( + self.family + ) + ) + + suffix = package_path.suffix.lower() + if suffix == ".deb": + self.log("Installing .deb package...") + if shutil.which("apt-get"): + subprocess.check_call(["sudo", "apt-get", "install", "-y", str(package_path)], timeout=600) + else: + subprocess.check_call(["sudo", "dpkg", "-i", str(package_path)], timeout=600) + self.log("Install finished.") + return + + if suffix == ".rpm": + self.log("Installing .rpm package...") + if shutil.which("dnf"): + subprocess.check_call(["sudo", "dnf", "install", "-y", str(package_path)], timeout=600) + elif shutil.which("yum"): + subprocess.check_call(["sudo", "yum", "install", "-y", str(package_path)], timeout=600) + else: + subprocess.check_call(["sudo", "rpm", "-Uvh", str(package_path)], timeout=600) + self.log("Install finished.") + return + + raise RuntimeError("Unsupported Linux installer: {} ({})".format(package_path.name, self.family)) + + +def get_installer(system_info: SystemInfo, log: LogFn) -> Installer: + if system_info.os_name == "macos": + return MacOSInstaller(log) + if system_info.os_name == "windows": + return WindowsInstaller(log) + if system_info.os_name == "linux": + return LinuxInstaller(log, system_info.linux_family) + raise RuntimeError("Unsupported operating system: {}".format(system_info.os_name)) + + +def _replace_macos_app_bundle(target: Path, temp_target: Path) -> None: + """Replace an app bundle while preserving a rollback path on failure.""" + if not target.exists(): + subprocess.check_call(["sudo", "mv", str(temp_target), str(target)], timeout=60) + return + + backup_target = Path("/Applications") / (target.name + ".old") + if backup_target.exists(): + subprocess.check_call(["sudo", "rm", "-rf", str(backup_target)], timeout=30) + + subprocess.check_call(["sudo", "mv", str(target), str(backup_target)], timeout=60) + try: + subprocess.check_call(["sudo", "mv", str(temp_target), str(target)], timeout=60) + except Exception as exc: + try: + subprocess.check_call(["sudo", "mv", str(backup_target), str(target)], timeout=60) + except Exception as restore_exc: + raise RuntimeError( + "Failed to replace {} and restore the previous Brave app. Backup remains at {}.".format( + target, backup_target + ) + ) from restore_exc + raise RuntimeError("Failed to replace {}; restored the previous Brave app.".format(target)) from exc + + subprocess.check_call(["sudo", "rm", "-rf", str(backup_target)], timeout=30) diff --git a/src/brave_updater/models.py b/src/brave_updater/models.py new file mode 100644 index 0000000..85ffdf1 --- /dev/null +++ b/src/brave_updater/models.py @@ -0,0 +1,47 @@ +"""Data models for brave_updater.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import List, Optional + + +@dataclass(frozen=True) +class SystemInfo: + os_name: str + arch: str + linux_family: str = "unknown" + + +@dataclass(frozen=True) +class ReleaseAsset: + name: str + url: str + digest: Optional[str] = None + + +@dataclass(frozen=True) +class ReleaseInfo: + version: str + name: str + body: str + assets: List[ReleaseAsset] + source: str = "github" + + def find_asset(self, asset_name: str) -> Optional[ReleaseAsset]: + for asset in self.assets: + if asset.name == asset_name: + return asset + return None + + +@dataclass(frozen=True) +class UpdateCheckResult: + system: SystemInfo + installed_version: Optional[str] + latest_version: str + update_available: bool + selected_asset: ReleaseAsset + selection_reason: str + release_source: str + chromium_version: Optional[str] = None diff --git a/src/brave_updater/network.py b/src/brave_updater/network.py new file mode 100644 index 0000000..65967b4 --- /dev/null +++ b/src/brave_updater/network.py @@ -0,0 +1,247 @@ +"""Network and download helpers.""" + +from __future__ import annotations + +import hashlib +import json +import os +import socket +import time +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Any, Dict, Iterable, Optional +from urllib.error import HTTPError, URLError +import shutil + +from brave_updater import __version__ +from brave_updater.console import LogFn + + +DEFAULT_ALLOWED_DOMAINS = { + "github.com", + "githubusercontent.com", +} +USER_AGENT = "brave-updater/{}".format(__version__) + + +def validate_url(url: str, allowed_domains: Optional[Iterable[str]] = None) -> bool: + """Validate a URL against the allowlist.""" + allowed = tuple((allowed_domains or DEFAULT_ALLOWED_DOMAINS)) + try: + parsed = urllib.parse.urlparse(url) + domain = parsed.netloc.lower().split(":", 1)[0] + return any(domain == item or domain.endswith("." + item) for item in allowed) + except Exception: + return False + + +def verify_file_hash(file_path: Path, expected_hash: str) -> bool: + """Verify a file SHA256 hash.""" + sha256 = hashlib.sha256() + with file_path.open("rb") as handle: + while True: + chunk = handle.read(8192) + if not chunk: + break + sha256.update(chunk) + return sha256.hexdigest().lower() == expected_hash.lower().strip() + + +def http_get_text( + url: str, + headers: Optional[Dict[str, str]] = None, + timeout: int = 30, + max_retries: int = 3, +) -> str: + """Fetch text content with retry logic.""" + request_headers = {"User-Agent": USER_AGENT} + if headers: + request_headers.update(headers) + + last_error = None + for attempt in range(max_retries): + try: + request = urllib.request.Request(url, headers=request_headers) + with urllib.request.urlopen(request, timeout=timeout) as response: + return response.read().decode("utf-8") + except (URLError, socket.timeout, socket.error, UnicodeDecodeError) as exc: + last_error = exc + if attempt < max_retries - 1: + time.sleep(2 ** attempt) + raise RuntimeError("Failed to fetch {}: {}".format(url, last_error)) + + +def http_get_json(url: str, timeout: int = 30, max_retries: int = 3) -> Dict[str, Any]: + """Fetch JSON from an API endpoint.""" + headers = { + "Accept": "application/vnd.github+json", + "User-Agent": USER_AGENT, + } + token = os.environ.get("GITHUB_TOKEN") + if token: + token = token.strip() + if token and "\n" not in token and " " not in token: + headers["Authorization"] = "Bearer {}".format(token) + + text = http_get_text(url, headers=headers, timeout=timeout, max_retries=max_retries) + payload = json.loads(text) + if not isinstance(payload, dict): + raise ValueError("Expected a JSON object from {}".format(url)) + return payload + + +def partial_download_path(dest: Path) -> Path: + """Return the sidecar path used for resumable downloads.""" + return dest.parent / (dest.name + ".part") + + +def _extract_total_size(content_range: Optional[str], content_length: int, resume_pos: int) -> int: + if content_range: + parts = content_range.rsplit("/", 1) + if len(parts) == 2 and parts[1].isdigit(): + return int(parts[1]) + if content_length > 0: + return content_length + resume_pos + return 0 + + +def download_file( + url: str, + dest: Path, + log: LogFn, + expected_hash: Optional[str] = None, + timeout: int = 60, + max_retries: int = 3, + force: bool = False, + allowed_domains: Optional[Iterable[str]] = None, +) -> Path: + """Download a file safely with checksum verification and resumable partials.""" + if not validate_url(url, allowed_domains=allowed_domains): + raise ValueError("URL not from allowed domain: {}".format(url)) + + dest.parent.mkdir(parents=True, exist_ok=True) + partial = partial_download_path(dest) + + if force: + if dest.exists(): + dest.unlink() + if partial.exists(): + partial.unlink() + + if dest.exists() and not force: + if expected_hash: + if verify_file_hash(dest, expected_hash): + log("Using existing verified download: {}".format(dest)) + return dest + log("Existing file checksum mismatch, re-downloading: {}".format(dest)) + else: + log("Using existing download: {}".format(dest)) + return dest + + free_bytes = shutil.disk_usage(dest.parent).free + if free_bytes < 500 * 1024 * 1024: + raise RuntimeError("Insufficient disk space: need at least 500MB free") + + last_error = None + for attempt in range(max_retries): + resume_pos = partial.stat().st_size if partial.exists() else 0 + headers = {} + if resume_pos > 0: + headers["Range"] = "bytes={}-".format(resume_pos) + log("Resuming download from byte {}...".format(resume_pos)) + + try: + request = urllib.request.Request(url, headers=headers) + with urllib.request.urlopen(request, timeout=timeout) as response: + final_url = response.geturl() + if not validate_url(final_url, allowed_domains=allowed_domains): + raise ValueError("Redirected to disallowed host: {}".format(final_url)) + + status = getattr(response, "status", 200) + if resume_pos > 0 and status == 200: + log("Server ignored resume request, restarting from byte 0...") + partial.unlink() + resume_pos = 0 + request = urllib.request.Request(url) + response.close() + with urllib.request.urlopen(request, timeout=timeout) as fresh_response: + final_url = fresh_response.geturl() + if not validate_url(final_url, allowed_domains=allowed_domains): + raise ValueError("Redirected to disallowed host: {}".format(final_url)) + _write_download_stream( + fresh_response, + partial, + dest, + log, + expected_hash=expected_hash, + resume_pos=resume_pos, + ) + return dest + + _write_download_stream( + response, + partial, + dest, + log, + expected_hash=expected_hash, + resume_pos=resume_pos, + ) + return dest + except HTTPError as exc: + last_error = exc + if exc.code == 416 and partial.exists(): + log("Partial download no longer matches remote file, restarting...") + partial.unlink() + continue + if attempt < max_retries - 1: + log("Download attempt {} failed: {}. Retrying...".format(attempt + 1, exc)) + time.sleep(2 ** attempt) + except (URLError, socket.timeout, socket.error, OSError, ValueError, RuntimeError) as exc: + last_error = exc + if attempt < max_retries - 1: + log("Download attempt {} failed: {}. Retrying...".format(attempt + 1, exc)) + time.sleep(2 ** attempt) + + raise RuntimeError("Failed to download {} after {} attempts: {}".format(url, max_retries, last_error)) + + +def _write_download_stream( + response: Any, + partial: Path, + dest: Path, + log: LogFn, + expected_hash: Optional[str], + resume_pos: int, +) -> None: + content_length = int(response.headers.get("Content-Length", 0) or 0) + total = _extract_total_size(response.headers.get("Content-Range"), content_length, resume_pos) + mode = "ab" if resume_pos > 0 else "wb" + log("Downloading:\n {}\n-> {}".format(response.geturl(), dest)) + + read = resume_pos + with partial.open(mode) as handle: + while True: + chunk = response.read(1024 * 256) + if not chunk: + break + handle.write(chunk) + read += len(chunk) + if total: + pct = (read / total) * 100 + print("\r{:6.2f}% ({}/{})".format(pct, read, total), end="", flush=True) + if total: + print() + + actual_size = partial.stat().st_size + if total and actual_size != total: + raise RuntimeError("Size mismatch: expected {}, got {}".format(total, actual_size)) + + if expected_hash: + log("Verifying download integrity...") + if not verify_file_hash(partial, expected_hash): + raise RuntimeError("Download integrity check failed") + log("Integrity check passed.") + + partial.replace(dest) + log("Download complete.") diff --git a/src/brave_updater/providers.py b/src/brave_updater/providers.py new file mode 100644 index 0000000..d8982ec --- /dev/null +++ b/src/brave_updater/providers.py @@ -0,0 +1,198 @@ +"""Release-provider implementations and asset selection.""" + +from __future__ import annotations + +import re +from typing import List, Optional, Protocol, Tuple + +from brave_updater.console import LogFn +from brave_updater.models import ReleaseAsset, ReleaseInfo, SystemInfo +from brave_updater.network import http_get_json, http_get_text + + +GITHUB_API_LATEST = "https://api.github.com/repos/brave/brave-browser/releases/latest" + + +class ReleaseProvider(Protocol): + source_name: str + + def fetch_latest_release(self) -> ReleaseInfo: + raise NotImplementedError + + +class GitHubReleaseProvider: + source_name = "github" + + def fetch_latest_release(self) -> ReleaseInfo: + payload = http_get_json(GITHUB_API_LATEST) + return release_from_payload(payload, source=self.source_name) + + +class OfficialReleaseProvider(GitHubReleaseProvider): + source_name = "official-github" + + +def resolve_provider(source: str, log: LogFn) -> ReleaseProvider: + normalized = source.lower() + if normalized == "github": + return GitHubReleaseProvider() + if normalized in ("official", "auto"): + log("Using Brave's official GitHub release feed for source={!r}.".format(source)) + return OfficialReleaseProvider() + raise ValueError("Unsupported source: {}".format(source)) + + +def validate_release_data(payload: dict) -> None: + required_fields = ("tag_name", "assets") + for field in required_fields: + if field not in payload: + raise ValueError("Invalid release payload: missing {}".format(field)) + if not isinstance(payload["assets"], list): + raise ValueError("Invalid release payload: assets must be a list") + + +def release_from_payload(payload: dict, source: str) -> ReleaseInfo: + validate_release_data(payload) + assets: List[ReleaseAsset] = [] + for raw_asset in payload["assets"]: + if not isinstance(raw_asset, dict): + raise ValueError("Invalid release payload: asset must be a mapping") + name = raw_asset.get("name") + url = raw_asset.get("browser_download_url") + if not name or not url: + raise ValueError("Invalid release payload: asset missing name or URL") + assets.append( + ReleaseAsset( + name=name, + url=url, + digest=raw_asset.get("digest"), + ) + ) + return ReleaseInfo( + version=str(payload.get("tag_name", "")), + name=str(payload.get("name", "")), + body=str(payload.get("body", "")), + assets=assets, + source=source, + ) + + +def pick_asset(assets: List[ReleaseAsset], system_info: SystemInfo) -> Tuple[ReleaseAsset, str]: + """Pick the best installer asset for the current platform.""" + names = [asset.name for asset in assets] + + def find(predicate) -> Optional[ReleaseAsset]: + for asset in assets: + name = asset.name.lower() + if predicate(name): + return asset + return None + + if system_info.os_name == "macos": + asset = find(lambda name: name.endswith(".dmg") and "universal" in name) + if asset: + return asset, "macOS: chose universal .dmg" + if system_info.arch == "arm64": + asset = find(lambda name: name.endswith(".dmg") and ("arm64" in name or "aarch64" in name)) + if asset: + return asset, "macOS ARM: chose arm64 .dmg" + if system_info.arch == "x64": + asset = find(lambda name: name.endswith(".dmg") and ("x64" in name or "amd64" in name or "x86_64" in name)) + if asset: + return asset, "macOS Intel: chose x64 .dmg" + asset = find(lambda name: name.endswith(".dmg")) + if asset: + return asset, "macOS: chose first available .dmg" + asset = find(lambda name: name.endswith(".pkg")) + if asset: + return asset, "macOS: chose .pkg (no .dmg found)" + + if system_info.os_name == "windows": + asset = find(lambda name: name.endswith(".exe") and "standalone" in name and "setup" in name) + if asset: + return asset, "Windows: chose StandaloneSetup.exe (offline installer)" + asset = find(lambda name: name.endswith(".exe") and "setup" in name) + if asset: + return asset, "Windows: chose Setup.exe" + asset = find(lambda name: name.endswith(".exe")) + if asset: + return asset, "Windows: chose first available .exe" + + if system_info.os_name == "linux": + if system_info.linux_family not in {"debian", "rhel"}: + raise RuntimeError( + "Unsupported Linux distribution family: {}. Only Debian-like and RHEL-like systems are currently supported.".format( + system_info.linux_family + ) + ) + + arch_tokens: List[str] = [] + if system_info.arch == "x64": + arch_tokens = ["amd64", "x86_64"] + elif system_info.arch == "arm64": + arch_tokens = ["arm64", "aarch64"] + + if system_info.linux_family == "debian": + for token in arch_tokens: + asset = find(lambda name, item=token: name.endswith(".deb") and item in name) + if asset: + return asset, "Linux Debian-like: chose .deb matching {}".format(token) + asset = find(lambda name: name.endswith(".deb")) + if asset: + return asset, "Linux Debian-like: chose first available .deb" + + if system_info.linux_family == "rhel": + for token in arch_tokens: + asset = find(lambda name, item=token: name.endswith(".rpm") and item in name) + if asset: + return asset, "Linux RHEL-like: chose .rpm matching {}".format(token) + asset = find(lambda name: name.endswith(".rpm")) + if asset: + return asset, "Linux RHEL-like: chose first available .rpm" + + raise RuntimeError( + "Could not find a suitable asset for {}. Assets seen: {}".format(system_info, names[:30]) + ) + + +def extract_chromium_version(body: str) -> Optional[str]: + match = re.search(r"Chromium:\s*([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)", body) + if match: + return match.group(1) + return None + + +def parse_sha256_digest(digest: Optional[str]) -> Optional[str]: + if digest and digest.startswith("sha256:"): + return digest.split(":", 1)[1] + return None + + +def parse_checksum_text(text: str, asset_name: str) -> Optional[str]: + for raw_line in text.splitlines(): + line = raw_line.strip() + if not line: + continue + match = re.match(r"([a-fA-F0-9]{64})\s+[* ]?(.+)$", line) + if not match: + continue + checksum, name = match.groups() + if name.strip() == asset_name: + return checksum.lower() + return None + + +def resolve_expected_hash(asset: ReleaseAsset, release: ReleaseInfo, log: LogFn) -> Optional[str]: + digest = parse_sha256_digest(asset.digest) + if digest: + return digest + + checksum_asset = release.find_asset(asset.name + ".sha256") + if not checksum_asset: + checksum_asset = release.find_asset(asset.name + ".sha256.txt") + if not checksum_asset: + return None + + log("Fetching checksum sidecar for {}...".format(asset.name)) + checksum_text = http_get_text(checksum_asset.url) + return parse_checksum_text(checksum_text, asset.name) diff --git a/src/brave_updater/service.py b/src/brave_updater/service.py new file mode 100644 index 0000000..aedc339 --- /dev/null +++ b/src/brave_updater/service.py @@ -0,0 +1,116 @@ +"""High-level update orchestration.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Optional, Tuple + +from brave_updater.console import LogFn, prompt_yes_no +from brave_updater.download import sanitize_filename +from brave_updater.installers import get_installer +from brave_updater.models import ReleaseAsset, ReleaseInfo, SystemInfo, UpdateCheckResult +from brave_updater.network import download_file +from brave_updater.providers import ( + extract_chromium_version, + pick_asset, + resolve_expected_hash, + resolve_provider, +) +from brave_updater.system import compare_versions, detect_installed_version, detect_system + + +class BraveUpdater: + def __init__(self, source: str, log: LogFn): + self.log = log + self.provider = resolve_provider(source, log) + + def load_release(self) -> ReleaseInfo: + return self.provider.fetch_latest_release() + + def _resolve_target(self) -> Tuple[SystemInfo, ReleaseInfo, ReleaseAsset, str]: + system_info = detect_system() + release = self.load_release() + asset, reason = pick_asset(release.assets, system_info) + return system_info, release, asset, reason + + def check(self) -> UpdateCheckResult: + system_info, release, asset, reason = self._resolve_target() + installed_version = detect_installed_version(system_info) + latest_version = release.version.lstrip("v") + update_available = installed_version is None or compare_versions(installed_version, latest_version) < 0 + return UpdateCheckResult( + system=system_info, + installed_version=installed_version, + latest_version=latest_version, + update_available=update_available, + selected_asset=asset, + selection_reason=reason, + release_source=release.source, + chromium_version=extract_chromium_version(release.body), + ) + + def download_latest( + self, + directory: Path, + skip_verify: bool = False, + force: bool = False, + ) -> Path: + _, release, asset, _ = self._resolve_target() + expected_hash = None if skip_verify else resolve_expected_hash(asset, release, self.log) + destination = directory / sanitize_filename(asset.name) + return download_file( + asset.url, + destination, + log=self.log, + expected_hash=expected_hash, + force=force, + ) + + def update( + self, + directory: Path, + skip_verify: bool = False, + force: bool = False, + assume_yes: bool = False, + ) -> Optional[Path]: + system_info, release, asset, reason = self._resolve_target() + installed_version = detect_installed_version(system_info) + latest_version = release.version.lstrip("v") + update_available = installed_version is None or compare_versions(installed_version, latest_version) < 0 + + check = UpdateCheckResult( + system=system_info, + installed_version=installed_version, + latest_version=latest_version, + update_available=update_available, + selected_asset=asset, + selection_reason=reason, + release_source=release.source, + chromium_version=extract_chromium_version(release.body), + ) + if not force and not check.update_available: + self.log("Brave is already up to date ({}).".format(check.latest_version)) + return None + + expected_hash = None if skip_verify else resolve_expected_hash(check.selected_asset, release, self.log) + destination = directory / sanitize_filename(check.selected_asset.name) + download_file( + check.selected_asset.url, + destination, + log=self.log, + expected_hash=expected_hash, + force=force, + ) + + if assume_yes or prompt_yes_no("Install {} now?".format(destination.name), default_no=True): + installer = get_installer(check.system, self.log) + installer.install(destination) + self.log("Done.") + else: + self.log("Install skipped.") + + return destination + + def install(self, package_path: Path) -> None: + installer = get_installer(detect_system(), self.log) + installer.install(package_path) diff --git a/src/brave_updater/system.py b/src/brave_updater/system.py new file mode 100644 index 0000000..b9d0e16 --- /dev/null +++ b/src/brave_updater/system.py @@ -0,0 +1,189 @@ +"""System detection and installed-version helpers.""" + +from __future__ import annotations + +import os +import platform +import plistlib +import re +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Iterable, Optional, Sequence, Tuple + +from brave_updater.models import SystemInfo + + +def detect_linux_family(os_release_path: Path = Path("/etc/os-release")) -> str: + if not os_release_path.exists(): + return "unknown" + + text = os_release_path.read_text(errors="ignore") + values = {} + for line in text.splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + values[key.strip()] = value.strip().strip('"') + + distro_id = values.get("ID", "").lower() + distro_like = values.get("ID_LIKE", "").lower() + + def contains_any(haystack: str, words: Sequence[str]) -> bool: + return any(word in haystack for word in words) + + if contains_any(distro_id, ("debian", "ubuntu", "linuxmint", "pop")) or contains_any(distro_like, ("debian", "ubuntu")): + return "debian" + if contains_any(distro_id, ("fedora", "rhel", "centos", "rocky", "almalinux", "opensuse", "sles")) or contains_any(distro_like, ("rhel", "fedora", "suse")): + return "rhel" + if contains_any(distro_id, ("arch", "manjaro", "endeavouros")) or contains_any(distro_like, ("arch",)): + return "arch" + return "unknown" + + +def detect_system() -> SystemInfo: + sys_platform = sys.platform.lower() + machine = platform.machine().lower() + + if sys_platform.startswith("darwin"): + os_name = "macos" + elif sys_platform.startswith("win"): + os_name = "windows" + elif sys_platform.startswith("linux"): + os_name = "linux" + else: + os_name = "unknown" + + if machine in ("arm64", "aarch64"): + arch = "arm64" + elif machine in ("x86_64", "amd64"): + arch = "x64" + elif machine in ("i386", "i686", "x86"): + arch = "x86" + else: + arch = "unknown" + + linux_family = detect_linux_family() if os_name == "linux" else "unknown" + return SystemInfo(os_name=os_name, arch=arch, linux_family=linux_family) + + +def parse_version_string(text: str) -> Optional[str]: + match = re.search(r"(\d+(?:\.\d+)+)", text) + if not match: + return None + return match.group(1) + + +def version_tuple(version: Optional[str]) -> Tuple[int, ...]: + if not version: + return tuple() + parsed = parse_version_string(version) or version + return tuple(int(part) for part in parsed.strip().lstrip("v").split(".") if part.isdigit()) + + +def compare_versions(current: Optional[str], latest: Optional[str]) -> int: + left = version_tuple(current) + right = version_tuple(latest) + max_len = max(len(left), len(right)) + padded_left = left + (0,) * (max_len - len(left)) + padded_right = right + (0,) * (max_len - len(right)) + if padded_left == padded_right: + return 0 + if padded_left < padded_right: + return -1 + return 1 + + +def read_macos_version(plist_paths: Optional[Iterable[Path]] = None) -> Optional[str]: + candidates = list(plist_paths or [ + Path("/Applications/Brave Browser.app/Contents/Info.plist"), + Path.home() / "Applications/Brave Browser.app/Contents/Info.plist", + ]) + for plist_path in candidates: + if not plist_path.exists(): + continue + with plist_path.open("rb") as handle: + data = plistlib.load(handle) + version = normalize_macos_app_version( + data.get("CFBundleShortVersionString"), + data.get("CFBundleVersion"), + ) + if version: + return str(version) + return None + + +def normalize_macos_app_version(short_version: Optional[str], bundle_version: Optional[str]) -> Optional[str]: + short_text = str(short_version) if short_version else None + bundle_text = str(bundle_version) if bundle_version else None + + short_tuple = version_tuple(short_text) + if short_tuple and short_tuple[0] >= 100 and bundle_text: + match = re.fullmatch(r"(\d+)\.(\d+)", bundle_text) + if match: + major_minor, patch = match.groups() + if len(major_minor) > 1: + major = int(major_minor[0]) + minor = int(major_minor[1:]) + return "{}.{}.{}".format(major, minor, int(patch)) + + if short_text: + return short_text + if bundle_text: + return bundle_text + return None + + +def read_linux_version( + commands: Optional[Iterable[Sequence[str]]] = None, + runner=subprocess.run, + which=shutil.which, +) -> Optional[str]: + for command in commands or (("brave-browser", "--version"), ("brave-browser-stable", "--version")): + executable = command[0] + if which(executable) is None: + continue + result = runner(command, capture_output=True, text=True, check=False) + version = parse_version_string(result.stdout or result.stderr or "") + if version: + return version + return None + + +def read_windows_version( + executable_paths: Optional[Iterable[Path]] = None, + runner=subprocess.run, + powershell: str = "powershell", +) -> Optional[str]: + candidates = list(executable_paths or [ + Path(os.environ.get("ProgramFiles", "")) / "BraveSoftware/Brave-Browser/Application/brave.exe", + Path(os.environ.get("ProgramFiles(x86)", "")) / "BraveSoftware/Brave-Browser/Application/brave.exe", + Path(os.environ.get("LOCALAPPDATA", "")) / "BraveSoftware/Brave-Browser/Application/brave.exe", + ]) + for executable in candidates: + if not executable.exists(): + continue + command = [ + powershell, + "-NoProfile", + "-Command", + "(Get-Item '{}').VersionInfo.ProductVersion".format(str(executable).replace("'", "''")), + ] + result = runner(command, capture_output=True, text=True, check=False) + version = parse_version_string(result.stdout or result.stderr or "") + if version: + return version + return None + + +def detect_installed_version(system_info: Optional[SystemInfo] = None) -> Optional[str]: + info = system_info or detect_system() + if info.os_name == "macos": + return read_macos_version() + if info.os_name == "linux": + return read_linux_version() + if info.os_name == "windows": + return read_windows_version() + return None diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..f823a39 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,9 @@ +from pathlib import Path +import sys + + +ROOT = Path(__file__).resolve().parents[1] +SRC = ROOT / "src" + +if str(SRC) not in sys.path: + sys.path.insert(0, str(SRC)) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..f8ad20b --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,103 @@ +from contextlib import redirect_stderr, redirect_stdout +from io import StringIO +import unittest +from unittest import mock + +from brave_updater import cli +from brave_updater.models import ReleaseAsset, ReleaseInfo, SystemInfo + + +class CliTests(unittest.TestCase): + def test_legacy_print_only_outputs_url(self) -> None: + release = ReleaseInfo( + version="v1.2.3", + name="Release v1.2.3", + body="Chromium: 120.0.0.1", + assets=[ReleaseAsset(name="Brave-Browser-universal.dmg", url="https://github.com/example.dmg", digest="sha256:" + "a" * 64)], + ) + + stdout = StringIO() + stderr = StringIO() + with redirect_stdout(stdout), redirect_stderr(stderr): + with mock.patch("brave_updater.cli.detect_system", return_value=SystemInfo("macos", "arm64", "unknown")): + with mock.patch("brave_updater.cli.pick_asset", return_value=(release.assets[0], "macOS: chose universal .dmg")): + with mock.patch("brave_updater.cli.BraveUpdater") as updater_cls: + updater = updater_cls.return_value + updater.load_release.return_value = release + exit_code = cli.main(["--print-only"]) + + self.assertEqual(exit_code, 0) + self.assertEqual(stdout.getvalue().strip(), "https://github.com/example.dmg") + self.assertIn("Chosen asset", stderr.getvalue()) + + def test_update_subcommand_delegates_to_service(self) -> None: + with mock.patch("brave_updater.cli.BraveUpdater") as updater_cls: + exit_code = cli.main(["update", "--dir", "/tmp", "--yes"]) + + self.assertEqual(exit_code, 0) + updater_cls.return_value.update.assert_called_once() + + def test_download_subcommand_delegates_to_service(self) -> None: + stdout = StringIO() + with redirect_stdout(stdout): + with mock.patch("brave_updater.cli.BraveUpdater") as updater_cls: + exit_code = cli.main(["download", "--dir", "/tmp", "--force"]) + + self.assertEqual(exit_code, 0) + updater_cls.return_value.download_latest.assert_called_once() + + def test_install_subcommand_delegates_to_service(self) -> None: + with mock.patch("brave_updater.cli.BraveUpdater") as updater_cls: + exit_code = cli.main(["install", "/tmp/brave.pkg"]) + + self.assertEqual(exit_code, 0) + updater_cls.return_value.install.assert_called_once() + + def test_check_json_emits_machine_readable_output(self) -> None: + result = mock.Mock( + system=SystemInfo("linux", "x64", "debian"), + installed_version="1.2.2", + latest_version="1.2.3", + update_available=True, + selected_asset=ReleaseAsset(name="brave-browser.deb", url="https://github.com/example.deb"), + selection_reason="Linux Debian-like: chose first available .deb", + release_source="github", + chromium_version="120.0.0.1", + ) + + stdout = StringIO() + with redirect_stdout(stdout): + with mock.patch("brave_updater.cli.BraveUpdater") as updater_cls: + updater_cls.return_value.check.return_value = result + exit_code = cli.main(["check", "--json"]) + + self.assertEqual(exit_code, 0) + output = stdout.getvalue() + self.assertIn('"latest_version": "1.2.3"', output) + self.assertIn('"update_available": true', output) + + def test_legacy_install_skips_when_prompt_declined(self) -> None: + release = ReleaseInfo( + version="v1.2.3", + name="Release v1.2.3", + body="", + assets=[ReleaseAsset(name="Brave-Browser-universal.dmg", url="https://github.com/example.dmg")], + ) + + stdout = StringIO() + with redirect_stdout(stdout): + with mock.patch("brave_updater.cli.detect_system", return_value=SystemInfo("macos", "arm64", "unknown")): + with mock.patch("brave_updater.cli.pick_asset", return_value=(release.assets[0], "reason")): + with mock.patch("brave_updater.cli.prompt_yes_no", return_value=False): + with mock.patch("brave_updater.cli.BraveUpdater") as updater_cls: + updater = updater_cls.return_value + updater.load_release.return_value = release + exit_code = cli.main(["--install", "--dir", "/tmp"]) + + self.assertEqual(exit_code, 0) + updater.download_latest.assert_called_once() + updater.install.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_console.py b/tests/test_console.py new file mode 100644 index 0000000..428720c --- /dev/null +++ b/tests/test_console.py @@ -0,0 +1,54 @@ +from contextlib import redirect_stderr, redirect_stdout +from io import StringIO +import unittest +from unittest import mock + +from brave_updater.console import make_logger, prompt_yes_no + + +class ConsoleTests(unittest.TestCase): + def test_make_logger_writes_to_stdout_by_default(self) -> None: + output = StringIO() + logger = make_logger() + + with redirect_stdout(output): + logger("hello") + + self.assertEqual(output.getvalue().strip(), "hello") + + def test_make_logger_writes_to_stderr_in_print_only_mode(self) -> None: + output = StringIO() + logger = make_logger(print_only_mode=True) + + with redirect_stderr(output): + logger("hello") + + self.assertEqual(output.getvalue().strip(), "hello") + + def test_prompt_yes_no_uses_default_for_non_interactive_stdin(self) -> None: + fake_stdin = mock.Mock() + fake_stdin.isatty.return_value = False + + with mock.patch("sys.stdin", fake_stdin): + self.assertFalse(prompt_yes_no("Continue?", default_no=True)) + self.assertTrue(prompt_yes_no("Continue?", default_no=False)) + + def test_prompt_yes_no_accepts_yes_in_interactive_mode(self) -> None: + fake_stdin = mock.Mock() + fake_stdin.isatty.return_value = True + + with mock.patch("sys.stdin", fake_stdin): + with mock.patch("builtins.input", return_value="yes"): + self.assertTrue(prompt_yes_no("Continue?", default_no=True)) + + def test_prompt_yes_no_rejects_no_in_interactive_mode(self) -> None: + fake_stdin = mock.Mock() + fake_stdin.isatty.return_value = True + + with mock.patch("sys.stdin", fake_stdin): + with mock.patch("builtins.input", return_value="n"): + self.assertFalse(prompt_yes_no("Continue?", default_no=False)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_download.py b/tests/test_download.py new file mode 100644 index 0000000..f1f8140 --- /dev/null +++ b/tests/test_download.py @@ -0,0 +1,193 @@ +import hashlib +import threading +import unittest +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest import mock +from urllib.error import HTTPError + +from brave_updater.download import sanitize_filename +from brave_updater.network import download_file, http_get_json, partial_download_path, validate_url + + +CONTENT = b"brave-updater-test-payload" * 128 + + +class DownloadHandler(BaseHTTPRequestHandler): + def do_GET(self) -> None: # noqa: N802 + if self.path == "/resume-ignored": + self.send_response(200) + self.send_header("Content-Length", str(len(CONTENT))) + self.end_headers() + self.wfile.write(CONTENT) + return + + if self.path == "/resume-supported": + header = self.headers.get("Range") + if header and header.startswith("bytes="): + start = int(header.split("=", 1)[1].split("-", 1)[0]) + body = CONTENT[start:] + self.send_response(206) + self.send_header("Content-Length", str(len(body))) + self.send_header("Content-Range", "bytes {}-{}/{}".format(start, len(CONTENT) - 1, len(CONTENT))) + self.end_headers() + self.wfile.write(body) + return + + self.send_response(200) + self.send_header("Content-Length", str(len(CONTENT))) + self.end_headers() + self.wfile.write(CONTENT) + + def log_message(self, format: str, *args) -> None: + return + + +class FakeResponse: + def __init__(self, final_url: str) -> None: + self.status = 200 + self.headers = {"Content-Length": "3"} + self._final_url = final_url + self._sent = False + + def geturl(self) -> str: + return self._final_url + + def read(self, size: int = -1) -> bytes: + if self._sent: + return b"" + self._sent = True + return b"abc" + + def close(self) -> None: + return + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb) -> None: + return None + + +class DownloadTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.server = ThreadingHTTPServer(("127.0.0.1", 0), DownloadHandler) + cls.server_thread = threading.Thread(target=cls.server.serve_forever, daemon=True) + cls.server_thread.start() + cls.base_url = "http://127.0.0.1:{}".format(cls.server.server_port) + + @classmethod + def tearDownClass(cls) -> None: + cls.server.shutdown() + cls.server.server_close() + + def test_resume_ignored_restarts_from_zero(self) -> None: + checksum = hashlib.sha256(CONTENT).hexdigest() + with TemporaryDirectory() as temp_dir: + dest = Path(temp_dir) / "brave-browser.zip" + partial = partial_download_path(dest) + partial.write_bytes(CONTENT[:32]) + + download_file( + self.base_url + "/resume-ignored", + dest, + log=lambda message: None, + expected_hash=checksum, + allowed_domains={"127.0.0.1", "localhost"}, + ) + + self.assertEqual(dest.read_bytes(), CONTENT) + self.assertFalse(partial.exists()) + + def test_resume_supported_keeps_partial_and_finishes(self) -> None: + checksum = hashlib.sha256(CONTENT).hexdigest() + with TemporaryDirectory() as temp_dir: + dest = Path(temp_dir) / "brave-browser.zip" + partial = partial_download_path(dest) + partial.write_bytes(CONTENT[:64]) + + download_file( + self.base_url + "/resume-supported", + dest, + log=lambda message: None, + expected_hash=checksum, + allowed_domains={"127.0.0.1", "localhost"}, + ) + + self.assertEqual(dest.read_bytes(), CONTENT) + + def test_rejects_disallowed_redirect_target(self) -> None: + with TemporaryDirectory() as temp_dir: + dest = Path(temp_dir) / "blocked.zip" + with mock.patch("urllib.request.urlopen", return_value=FakeResponse("https://example.com/file.zip")): + with self.assertRaises(RuntimeError): + download_file( + "https://github.com/example/file.zip", + dest, + log=lambda message: None, + max_retries=1, + ) + + def test_validate_url_accepts_allowed_subdomains_and_rejects_other_hosts(self) -> None: + self.assertTrue(validate_url("https://github.com/file.zip")) + self.assertTrue(validate_url("https://objects.githubusercontent.com/file.zip")) + self.assertFalse(validate_url("https://example.com/file.zip")) + + def test_download_reuses_existing_verified_file_without_network(self) -> None: + checksum = hashlib.sha256(CONTENT).hexdigest() + with TemporaryDirectory() as temp_dir: + dest = Path(temp_dir) / "brave-browser.zip" + dest.write_bytes(CONTENT) + + with mock.patch("urllib.request.urlopen") as urlopen: + result = download_file( + self.base_url + "/resume-supported", + dest, + log=lambda message: None, + expected_hash=checksum, + allowed_domains={"127.0.0.1", "localhost"}, + ) + + self.assertEqual(result, dest) + urlopen.assert_not_called() + + def test_http_get_json_rejects_non_object_payload(self) -> None: + with mock.patch("brave_updater.network.http_get_text", return_value="[]"): + with self.assertRaises(ValueError): + http_get_json("https://api.github.com/repos/brave/brave-browser/releases/latest") + + def test_download_retries_after_416_and_clears_partial(self) -> None: + with TemporaryDirectory() as temp_dir: + dest = Path(temp_dir) / "retry.zip" + partial = partial_download_path(dest) + partial.write_bytes(b"old-data") + + response = FakeResponse("https://github.com/example/file.zip") + error = HTTPError( + url="https://github.com/example/file.zip", + code=416, + msg="Range Not Satisfiable", + hdrs=None, + fp=None, + ) + + with mock.patch("urllib.request.urlopen", side_effect=[error, response]): + result = download_file( + "https://github.com/example/file.zip", + dest, + log=lambda message: None, + max_retries=2, + ) + + self.assertEqual(result.read_bytes(), b"abc") + self.assertFalse(partial.exists()) + + def test_sanitize_filename_strips_path_traversal(self) -> None: + self.assertEqual(sanitize_filename("../Brave-Browser.dmg"), "Brave-Browser.dmg") + self.assertEqual(sanitize_filename("..\\Brave-Browser.dmg"), "Brave-Browser.dmg") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_installers.py b/tests/test_installers.py new file mode 100644 index 0000000..1d8d8f7 --- /dev/null +++ b/tests/test_installers.py @@ -0,0 +1,235 @@ +from pathlib import Path +import subprocess +from tempfile import TemporaryDirectory +import unittest +from unittest import mock + +from brave_updater.installers import ( + LinuxInstaller, + MacOSInstaller, + WindowsInstaller, + _replace_macos_app_bundle, + get_installer, +) +from brave_updater.models import SystemInfo + + +class InstallerTests(unittest.TestCase): + def test_get_installer_returns_platform_specific_adapter(self) -> None: + self.assertIsInstance(get_installer(SystemInfo("macos", "arm64", "unknown"), lambda _: None), MacOSInstaller) + self.assertIsInstance(get_installer(SystemInfo("windows", "x64", "unknown"), lambda _: None), WindowsInstaller) + self.assertIsInstance(get_installer(SystemInfo("linux", "x64", "debian"), lambda _: None), LinuxInstaller) + + def test_get_installer_rejects_unknown_platform(self) -> None: + with self.assertRaises(RuntimeError): + get_installer(SystemInfo("plan9", "x64", "unknown"), lambda _: None) + + def test_macos_pkg_install_runs_installer(self) -> None: + with TemporaryDirectory() as temp_dir: + pkg = Path(temp_dir) / "Brave.pkg" + pkg.write_text("stub") + logger = mock.Mock() + installer = MacOSInstaller(logger) + + with mock.patch("subprocess.check_call") as check_call: + installer.install(pkg) + + check_call.assert_called_once_with( + ["sudo", "installer", "-pkg", str(pkg), "-target", "/"], + timeout=600, + ) + logger.assert_any_call("Install finished.") + + def test_macos_dmg_install_stages_and_moves_app(self) -> None: + with TemporaryDirectory() as temp_dir: + dmg = Path(temp_dir) / "Brave.dmg" + dmg.write_text("stub") + logger = mock.Mock() + installer = MacOSInstaller(logger) + mountpoint = Path("/Volumes/BraveTmpMount") + app_path = mountpoint / "Brave Browser.app" + target = Path("/Applications/Brave Browser.app") + temp_target = Path("/Applications/Brave Browser.app.new") + + def fake_exists(path_obj): + path = str(path_obj) + mapping = { + str(dmg): True, + str(mountpoint): False, + str(app_path): True, + str(target): False, + str(temp_target): False, + } + return mapping.get(path, False) + + with mock.patch("pathlib.Path.exists", fake_exists): + with mock.patch("pathlib.Path.mkdir") as mkdir: + with mock.patch("subprocess.check_call") as check_call: + with mock.patch("subprocess.run") as run: + installer.install(dmg) + + mkdir.assert_called_once() + self.assertEqual( + check_call.call_args_list[0].args[0], + ["hdiutil", "attach", str(dmg), "-mountpoint", str(mountpoint), "-nobrowse", "-quiet"], + ) + self.assertEqual( + check_call.call_args_list[-1].args[0], + ["sudo", "mv", str(temp_target), str(target)], + ) + run.assert_called_once_with( + ["hdiutil", "detach", str(mountpoint), "-quiet"], + timeout=10, + capture_output=True, + check=False, + ) + + def test_macos_replace_restores_backup_if_swap_fails(self) -> None: + target = Path("/Applications/Brave Browser.app") + temp_target = Path("/Applications/Brave Browser.app.new") + backup_target = Path("/Applications/Brave Browser.app.old") + state = { + str(target): True, + str(temp_target): True, + str(backup_target): False, + } + + def fake_exists(path_obj): + return state.get(str(path_obj), False) + + def fake_check_call(command, timeout): + if command == ["sudo", "mv", str(target), str(backup_target)]: + state[str(target)] = False + state[str(backup_target)] = True + return 0 + if command == ["sudo", "mv", str(temp_target), str(target)]: + raise subprocess.CalledProcessError(1, command) + if command == ["sudo", "mv", str(backup_target), str(target)]: + state[str(target)] = True + state[str(backup_target)] = False + return 0 + return 0 + + with mock.patch("pathlib.Path.exists", fake_exists): + with mock.patch("subprocess.check_call", side_effect=fake_check_call) as check_call: + with self.assertRaises(RuntimeError) as error: + _replace_macos_app_bundle(target, temp_target) + + self.assertIn("restored the previous Brave app", str(error.exception)) + self.assertIn(mock.call(["sudo", "mv", str(backup_target), str(target)], timeout=60), check_call.call_args_list) + + def test_macos_replace_raises_clear_error_if_restore_also_fails(self) -> None: + target = Path("/Applications/Brave Browser.app") + temp_target = Path("/Applications/Brave Browser.app.new") + backup_target = Path("/Applications/Brave Browser.app.old") + state = { + str(target): True, + str(temp_target): True, + str(backup_target): False, + } + + def fake_exists(path_obj): + return state.get(str(path_obj), False) + + def fake_check_call(command, timeout): + if command == ["sudo", "mv", str(target), str(backup_target)]: + state[str(target)] = False + state[str(backup_target)] = True + return 0 + if command in ( + ["sudo", "mv", str(temp_target), str(target)], + ["sudo", "mv", str(backup_target), str(target)], + ): + raise subprocess.CalledProcessError(1, command) + return 0 + + with mock.patch("pathlib.Path.exists", fake_exists): + with mock.patch("subprocess.check_call", side_effect=fake_check_call): + with self.assertRaises(RuntimeError) as error: + _replace_macos_app_bundle(target, temp_target) + + self.assertIn("Backup remains at", str(error.exception)) + + def test_windows_install_launches_startfile(self) -> None: + with TemporaryDirectory() as temp_dir: + exe = Path(temp_dir) / "BraveSetup.exe" + exe.write_text("stub") + installer = WindowsInstaller(lambda _: None) + + with mock.patch("os.startfile", create=True) as startfile: + installer.install(exe) + + startfile.assert_called_once_with(str(exe)) + + def test_linux_deb_prefers_apt_get(self) -> None: + with TemporaryDirectory() as temp_dir: + pkg = Path(temp_dir) / "brave.deb" + pkg.write_text("stub") + installer = LinuxInstaller(lambda _: None, "debian") + + with mock.patch("shutil.which", side_effect=lambda name: "/usr/bin/apt-get" if name == "apt-get" else None): + with mock.patch("subprocess.check_call") as check_call: + installer.install(pkg) + + check_call.assert_called_once_with(["sudo", "apt-get", "install", "-y", str(pkg)], timeout=600) + + def test_linux_deb_falls_back_to_dpkg(self) -> None: + with TemporaryDirectory() as temp_dir: + pkg = Path(temp_dir) / "brave.deb" + pkg.write_text("stub") + installer = LinuxInstaller(lambda _: None, "debian") + + with mock.patch("shutil.which", return_value=None): + with mock.patch("subprocess.check_call") as check_call: + installer.install(pkg) + + check_call.assert_called_once_with(["sudo", "dpkg", "-i", str(pkg)], timeout=600) + + def test_linux_rpm_prefers_dnf_then_yum_then_rpm(self) -> None: + with TemporaryDirectory() as temp_dir: + pkg = Path(temp_dir) / "brave.rpm" + pkg.write_text("stub") + + installer = LinuxInstaller(lambda _: None, "rhel") + with mock.patch("shutil.which", side_effect=lambda name: "/usr/bin/dnf" if name == "dnf" else None): + with mock.patch("subprocess.check_call") as check_call: + installer.install(pkg) + check_call.assert_called_once_with(["sudo", "dnf", "install", "-y", str(pkg)], timeout=600) + + installer = LinuxInstaller(lambda _: None, "rhel") + with mock.patch("shutil.which", side_effect=lambda name: "/usr/bin/yum" if name == "yum" else None): + with mock.patch("subprocess.check_call") as check_call: + installer.install(pkg) + check_call.assert_called_once_with(["sudo", "yum", "install", "-y", str(pkg)], timeout=600) + + installer = LinuxInstaller(lambda _: None, "rhel") + with mock.patch("shutil.which", return_value=None): + with mock.patch("subprocess.check_call") as check_call: + installer.install(pkg) + check_call.assert_called_once_with(["sudo", "rpm", "-Uvh", str(pkg)], timeout=600) + + def test_linux_installer_rejects_unknown_file_type(self) -> None: + with TemporaryDirectory() as temp_dir: + pkg = Path(temp_dir) / "brave.zip" + pkg.write_text("stub") + installer = LinuxInstaller(lambda _: None, "unknown") + + with self.assertRaises(RuntimeError): + installer.install(pkg) + + def test_linux_installer_rejects_unsupported_distribution_family(self) -> None: + with TemporaryDirectory() as temp_dir: + pkg = Path(temp_dir) / "brave.deb" + pkg.write_text("stub") + installer = LinuxInstaller(lambda _: None, "arch") + + with mock.patch("subprocess.check_call") as check_call: + with self.assertRaises(RuntimeError) as error: + installer.install(pkg) + + self.assertIn("Unsupported Linux distribution family", str(error.exception)) + check_call.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_packaging.py b/tests/test_packaging.py new file mode 100644 index 0000000..550f1d1 --- /dev/null +++ b/tests/test_packaging.py @@ -0,0 +1,29 @@ +from pathlib import Path +import unittest + +from brave_updater import __version__ +from brave_updater.network import USER_AGENT + + +ROOT = Path(__file__).resolve().parents[1] + + +class PackagingTests(unittest.TestCase): + def test_pyproject_uses_dynamic_version_from_package(self) -> None: + pyproject = (ROOT / "pyproject.toml").read_text() + + self.assertIn('dynamic = ["version"]', pyproject) + self.assertIn('version = {attr = "brave_updater.__version__"}', pyproject) + + def test_setup_py_defers_metadata_to_pyproject(self) -> None: + setup_py = (ROOT / "setup.py").read_text() + + self.assertIn("setup()", setup_py) + self.assertNotIn("version=", setup_py) + + def test_network_user_agent_tracks_package_version(self) -> None: + self.assertEqual(USER_AGENT, "brave-updater/{}".format(__version__)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_providers.py b/tests/test_providers.py new file mode 100644 index 0000000..ac677aa --- /dev/null +++ b/tests/test_providers.py @@ -0,0 +1,121 @@ +import unittest +from unittest import mock + +from brave_updater.models import ReleaseAsset, SystemInfo +from brave_updater.providers import ( + OfficialReleaseProvider, + extract_chromium_version, + parse_checksum_text, + parse_sha256_digest, + pick_asset, + release_from_payload, + resolve_expected_hash, + resolve_provider, +) + + +class ProviderTests(unittest.TestCase): + def test_pick_asset_prefers_macos_universal_dmg(self) -> None: + assets = [ + ReleaseAsset("Brave-Browser-arm64.dmg", "https://github.com/arm64.dmg"), + ReleaseAsset("Brave-Browser-universal.dmg", "https://github.com/universal.dmg"), + ] + + asset, reason = pick_asset(assets, SystemInfo("macos", "arm64", "unknown")) + + self.assertEqual(asset.name, "Brave-Browser-universal.dmg") + self.assertIn("universal", reason) + + def test_pick_asset_prefers_deb_for_debian(self) -> None: + assets = [ + ReleaseAsset("brave-browser-1.2.3-1.x86_64.rpm", "https://github.com/brave.rpm"), + ReleaseAsset("brave-browser_1.2.3_amd64.deb", "https://github.com/brave.deb"), + ] + + asset, reason = pick_asset(assets, SystemInfo("linux", "x64", "debian")) + + self.assertEqual(asset.name, "brave-browser_1.2.3_amd64.deb") + self.assertIn(".deb", reason) + + def test_pick_asset_rejects_unsupported_linux_family(self) -> None: + assets = [ + ReleaseAsset("brave-browser-1.2.3-1.x86_64.rpm", "https://github.com/brave.rpm"), + ReleaseAsset("brave-browser_1.2.3_amd64.deb", "https://github.com/brave.deb"), + ] + + with self.assertRaises(RuntimeError) as error: + pick_asset(assets, SystemInfo("linux", "x64", "arch")) + + self.assertIn("Unsupported Linux distribution family", str(error.exception)) + + def test_parse_sha256_digest(self) -> None: + digest = parse_sha256_digest("sha256:" + "a" * 64) + self.assertEqual(digest, "a" * 64) + + def test_parse_checksum_text(self) -> None: + checksum = parse_checksum_text( + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef Brave-Browser.dmg", + "Brave-Browser.dmg", + ) + self.assertEqual(checksum, "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + + def test_resolve_provider_uses_official_provider_and_logs(self) -> None: + logger = mock.Mock() + provider = resolve_provider("official", logger) + + self.assertIsInstance(provider, OfficialReleaseProvider) + logger.assert_called_once() + + def test_resolve_provider_rejects_unknown_source(self) -> None: + with self.assertRaises(ValueError): + resolve_provider("mirror", lambda _: None) + + def test_release_from_payload_validates_assets(self) -> None: + with self.assertRaises(ValueError): + release_from_payload({"tag_name": "v1.2.3", "assets": [{"name": "foo"}]}, source="github") + + def test_resolve_expected_hash_prefers_digest_and_falls_back_to_checksum_sidecar(self) -> None: + asset = ReleaseAsset("Brave-Browser.dmg", "https://github.com/file.dmg", digest="sha256:" + "a" * 64) + release = release_from_payload( + { + "tag_name": "v1.2.3", + "name": "Release", + "body": "", + "assets": [ + {"name": "Brave-Browser.dmg", "browser_download_url": "https://github.com/file.dmg", "digest": "sha256:" + "a" * 64}, + {"name": "Brave-Browser.dmg.sha256", "browser_download_url": "https://github.com/file.dmg.sha256"}, + ], + }, + source="github", + ) + self.assertEqual(resolve_expected_hash(asset, release, lambda _: None), "a" * 64) + + asset = ReleaseAsset("Brave-Browser.dmg", "https://github.com/file.dmg") + with mock.patch( + "brave_updater.providers.http_get_text", + return_value="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb Brave-Browser.dmg", + ): + checksum = resolve_expected_hash(asset, release, lambda _: None) + self.assertEqual(checksum, "b" * 64) + + def test_extract_chromium_version_returns_none_when_missing(self) -> None: + self.assertIsNone(extract_chromium_version("No chromium metadata here")) + + def test_resolve_expected_hash_returns_none_without_digest_or_checksum_asset(self) -> None: + asset = ReleaseAsset("Brave-Browser.dmg", "https://github.com/file.dmg") + release = release_from_payload( + { + "tag_name": "v1.2.3", + "name": "Release", + "body": "", + "assets": [ + {"name": "Brave-Browser.dmg", "browser_download_url": "https://github.com/file.dmg"}, + ], + }, + source="github", + ) + self.assertIsNone(resolve_expected_hash(asset, release, lambda _: None)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_service.py b/tests/test_service.py new file mode 100644 index 0000000..f8d375d --- /dev/null +++ b/tests/test_service.py @@ -0,0 +1,127 @@ +from pathlib import Path +import unittest +from unittest import mock + +from brave_updater.models import ReleaseAsset, ReleaseInfo, SystemInfo +from brave_updater.service import BraveUpdater + + +class ServiceTests(unittest.TestCase): + def make_updater(self) -> BraveUpdater: + with mock.patch("brave_updater.service.resolve_provider", return_value=mock.Mock()): + return BraveUpdater("github", lambda _: None) + + def make_release(self) -> ReleaseInfo: + return ReleaseInfo( + version="v1.2.3", + name="Release v1.2.3", + body="Chromium: 120.0.0.1", + assets=[ReleaseAsset(name="Brave-Browser-universal.dmg", url="https://github.com/example.dmg", digest="sha256:" + "a" * 64)], + source="github", + ) + + def test_check_reports_up_to_date_when_versions_match(self) -> None: + updater = self.make_updater() + system_info = SystemInfo("macos", "arm64", "unknown") + release = self.make_release() + asset = release.assets[0] + + with mock.patch.object(updater, "_resolve_target", return_value=(system_info, release, asset, "reason")): + with mock.patch("brave_updater.service.detect_installed_version", return_value="1.2.3"): + result = updater.check() + + self.assertFalse(result.update_available) + self.assertEqual(result.chromium_version, "120.0.0.1") + + def test_download_latest_uses_resolved_hash_and_sanitized_destination(self) -> None: + updater = self.make_updater() + release = ReleaseInfo( + version="v1.2.3", + name="Release", + body="", + assets=[ReleaseAsset(name="../Brave-Browser-universal.dmg", url="https://github.com/example.dmg")], + source="github", + ) + system_info = SystemInfo("macos", "arm64", "unknown") + asset = release.assets[0] + + with mock.patch.object(updater, "_resolve_target", return_value=(system_info, release, asset, "reason")): + with mock.patch("brave_updater.service.resolve_expected_hash", return_value="b" * 64) as resolve_hash: + with mock.patch("brave_updater.service.download_file", return_value=Path("/tmp/Brave-Browser-universal.dmg")) as download: + result = updater.download_latest(Path("/tmp")) + + resolve_hash.assert_called_once_with(asset, release, updater.log) + download.assert_called_once() + self.assertEqual(download.call_args.kwargs["expected_hash"], "b" * 64) + self.assertEqual(download.call_args.args[1], Path("/tmp/Brave-Browser-universal.dmg")) + self.assertEqual(result, Path("/tmp/Brave-Browser-universal.dmg")) + + def test_update_noops_when_latest_is_already_installed(self) -> None: + logs = [] + with mock.patch("brave_updater.service.resolve_provider", return_value=mock.Mock()): + updater = BraveUpdater("github", logs.append) + system_info = SystemInfo("macos", "arm64", "unknown") + release = self.make_release() + asset = release.assets[0] + + with mock.patch.object(updater, "_resolve_target", return_value=(system_info, release, asset, "reason")): + with mock.patch("brave_updater.service.detect_installed_version", return_value="1.2.3"): + with mock.patch("brave_updater.service.download_file") as download: + result = updater.update(Path("/tmp")) + + self.assertIsNone(result) + download.assert_not_called() + self.assertTrue(any("already up to date" in message for message in logs)) + + def test_update_downloads_and_installs_when_confirmed(self) -> None: + updater = self.make_updater() + system_info = SystemInfo("macos", "arm64", "unknown") + release = self.make_release() + asset = release.assets[0] + installer = mock.Mock() + + with mock.patch.object(updater, "_resolve_target", return_value=(system_info, release, asset, "reason")): + with mock.patch("brave_updater.service.detect_installed_version", return_value="1.2.2"): + with mock.patch("brave_updater.service.resolve_expected_hash", return_value="c" * 64): + with mock.patch("brave_updater.service.download_file") as download: + with mock.patch("brave_updater.service.get_installer", return_value=installer): + result = updater.update(Path("/tmp"), assume_yes=True) + + self.assertEqual(result, Path("/tmp/Brave-Browser-universal.dmg")) + installer.install.assert_called_once_with(Path("/tmp/Brave-Browser-universal.dmg")) + self.assertEqual(download.call_args.kwargs["expected_hash"], "c" * 64) + + def test_update_downloads_and_skips_install_when_declined(self) -> None: + logs = [] + with mock.patch("brave_updater.service.resolve_provider", return_value=mock.Mock()): + updater = BraveUpdater("github", logs.append) + system_info = SystemInfo("macos", "arm64", "unknown") + release = self.make_release() + asset = release.assets[0] + + with mock.patch.object(updater, "_resolve_target", return_value=(system_info, release, asset, "reason")): + with mock.patch("brave_updater.service.detect_installed_version", return_value="1.2.2"): + with mock.patch("brave_updater.service.resolve_expected_hash", return_value=None): + with mock.patch("brave_updater.service.download_file") as download: + with mock.patch("brave_updater.service.prompt_yes_no", return_value=False): + with mock.patch("brave_updater.service.get_installer") as get_installer: + result = updater.update(Path("/tmp")) + + self.assertEqual(result, Path("/tmp/Brave-Browser-universal.dmg")) + download.assert_called_once() + get_installer.assert_not_called() + self.assertIn("Install skipped.", logs) + + def test_install_delegates_to_platform_installer(self) -> None: + updater = self.make_updater() + installer = mock.Mock() + + with mock.patch("brave_updater.service.detect_system", return_value=SystemInfo("linux", "x64", "debian")): + with mock.patch("brave_updater.service.get_installer", return_value=installer): + updater.install(Path("/tmp/brave.deb")) + + installer.install.assert_called_once_with(Path("/tmp/brave.deb")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_system.py b/tests/test_system.py new file mode 100644 index 0000000..9c5d3ce --- /dev/null +++ b/tests/test_system.py @@ -0,0 +1,89 @@ +import plistlib +import subprocess +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest import mock + +from brave_updater.system import ( + compare_versions, + detect_installed_version, + detect_linux_family, + normalize_macos_app_version, + read_linux_version, + read_macos_version, + read_windows_version, +) + + +class FakeResult: + def __init__(self, stdout: str = "", stderr: str = "") -> None: + self.stdout = stdout + self.stderr = stderr + + +class SystemTests(unittest.TestCase): + def test_compare_versions_treats_missing_patch_as_equal_zero(self) -> None: + self.assertEqual(compare_versions("1.2.3", "v1.2.3.0"), 0) + self.assertEqual(compare_versions("1.2.2", "1.2.3"), -1) + self.assertEqual(compare_versions("1.2.4", "1.2.3"), 1) + + def test_read_macos_version_from_plist(self) -> None: + with TemporaryDirectory() as temp_dir: + plist_path = Path(temp_dir) / "Info.plist" + with plist_path.open("wb") as handle: + plistlib.dump({"CFBundleShortVersionString": "1.2.3"}, handle) + + version = read_macos_version([plist_path]) + + self.assertEqual(version, "1.2.3") + + def test_normalize_macos_app_version_prefers_brave_bundle_version(self) -> None: + self.assertEqual( + normalize_macos_app_version("143.1.85.118", "185.118"), + "1.85.118", + ) + + def test_read_linux_version_uses_binary_output(self) -> None: + def fake_runner(command, capture_output, text, check): + self.assertEqual(command, ("brave-browser", "--version")) + return FakeResult(stdout="Brave Browser 1.2.3") + + version = read_linux_version(commands=[("brave-browser", "--version")], runner=fake_runner, which=lambda _: "/usr/bin/brave-browser") + + self.assertEqual(version, "1.2.3") + + def test_detect_linux_family_reads_os_release(self) -> None: + with TemporaryDirectory() as temp_dir: + os_release = Path(temp_dir) / "os-release" + os_release.write_text('ID=ubuntu\nID_LIKE="debian"\n') + + family = detect_linux_family(os_release) + + self.assertEqual(family, "debian") + + def test_read_windows_version_uses_powershell_metadata(self) -> None: + with TemporaryDirectory() as temp_dir: + executable = Path(temp_dir) / "brave.exe" + executable.write_text("stub") + + def fake_runner(command, capture_output, text, check): + self.assertEqual(command[0], "powershell") + return FakeResult(stdout="1.2.3.4") + + version = read_windows_version([executable], runner=fake_runner) + + self.assertEqual(version, "1.2.3.4") + + def test_detect_installed_version_delegates_by_platform(self) -> None: + with mock.patch("brave_updater.system.read_macos_version", return_value="1.2.3"): + self.assertEqual(detect_installed_version(system_info=mock.Mock(os_name="macos")), "1.2.3") + with mock.patch("brave_updater.system.read_linux_version", return_value="1.2.3"): + self.assertEqual(detect_installed_version(system_info=mock.Mock(os_name="linux")), "1.2.3") + with mock.patch("brave_updater.system.read_windows_version", return_value="1.2.3"): + self.assertEqual(detect_installed_version(system_info=mock.Mock(os_name="windows")), "1.2.3") + self.assertIsNone(detect_installed_version(system_info=mock.Mock(os_name="unknown"))) + + +if __name__ == "__main__": + unittest.main()