diff --git a/packages/browseros/.env.example b/packages/browseros/.env.example index 0f301b5e..baf152d3 100644 --- a/packages/browseros/.env.example +++ b/packages/browseros/.env.example @@ -16,6 +16,7 @@ PROD_MACOS_NOTARIZATION_PWD= R2_ACCOUNT_ID= R2_ACCESS_KEY_ID= R2_SECRET_ACCESS_KEY= +CLOUDFLARE_API_TOKEN= R2_BUCKET=browseros R2_CDN_BASE_URL=http://cdn.browseros.com @@ -24,6 +25,8 @@ SLACK_WEBHOOK_URL= # Sparkle Signing (macOS only) SPARKLE_PRIVATE_KEY= +# Path to sign_update tool +SPARKLE_SIGN_UPDATE_PATH= # Optional # CHROMIUM_SRC=C:/src/chromium/src diff --git a/packages/browseros/build/browseros.py b/packages/browseros/build/browseros.py index 7f6bbb0b..138fa11b 100755 --- a/packages/browseros/build/browseros.py +++ b/packages/browseros/build/browseros.py @@ -40,6 +40,10 @@ app.add_typer(dev.app, name="dev", help="Dev patch management") from .cli import release app.add_typer(release.app, name="release", help="Release automation") +# OTA update commands +from .cli import ota +app.add_typer(ota.app, name="ota", help="OTA update automation") + if __name__ == "__main__": app() diff --git a/packages/browseros/build/cli/ota.py b/packages/browseros/build/cli/ota.py new file mode 100644 index 00000000..6126be72 --- /dev/null +++ b/packages/browseros/build/cli/ota.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 +"""OTA CLI - Over-The-Air update automation for BrowserOS""" + +from pathlib import Path +from typing import Optional + +import typer + +from ..common.context import Context +from ..common.env import EnvConfig +from ..common.module import ValidationError +from ..common.sparkle import sparkle_sign_file +from ..common.utils import log_info, log_error, log_success + +from ..modules.ota import ServerOTAModule +from ..modules.ota.common import ( + get_appcast_path, + SERVER_PLATFORMS, +) +from ..modules.upload import get_r2_client, upload_file_to_r2 + +app = typer.Typer( + help="OTA (Over-The-Air) update automation", + pretty_exceptions_enable=False, + pretty_exceptions_show_locals=False, +) + +server_app = typer.Typer( + help="BrowserOS Server OTA commands", + pretty_exceptions_enable=False, + pretty_exceptions_show_locals=False, +) +app.add_typer(server_app, name="server") + + +def create_ota_context() -> Context: + """Create Context for OTA operations""" + return Context( + chromium_src=Path(), + architecture="", + build_type="release", + ) + + +def execute_module(ctx: Context, module) -> None: + """Execute a single module with validation""" + try: + module.validate(ctx) + module.execute(ctx) + except ValidationError as e: + log_error(f"Validation failed: {e}") + raise typer.Exit(1) + except Exception as e: + log_error(f"Module failed: {e}") + raise typer.Exit(1) + + +@server_app.command("release") +def server_release( + version: str = typer.Option( + ..., "--version", "-v", help="Version to release (e.g., 0.0.36)" + ), + channel: str = typer.Option( + "alpha", "--channel", "-c", help="Release channel: alpha or prod" + ), + binaries: Optional[Path] = typer.Option( + None, "--binaries", "-b", help="Directory containing server binaries" + ), + platform: Optional[str] = typer.Option( + None, "--platform", "-p", + help="Platform(s) to process, comma-separated (darwin_arm64, darwin_x64, linux_arm64, linux_x64, windows_x64)" + ), +): + """Release BrowserOS Server OTA update + + \b + Full Release (all platforms): + browseros ota server release --version 0.0.36 --channel alpha + + \b + Single Platform: + browseros ota server release --version 0.0.36 --platform darwin_arm64 + + \b + Multiple Platforms: + browseros ota server release --version 0.0.36 --platform darwin_arm64,darwin_x64 + """ + log_info(f"šŸš€ BrowserOS Server OTA v{version}") + log_info("=" * 70) + + ctx = create_ota_context() + + module = ServerOTAModule( + version=version, + channel=channel, + binaries_dir=binaries, + platform_filter=platform, + ) + + execute_module(ctx, module) + + +@server_app.command("release-appcast") +def server_release_appcast( + channel: str = typer.Option( + "alpha", "--channel", "-c", help="Release channel: alpha or prod" + ), + appcast_file: Optional[Path] = typer.Option( + None, "--file", "-f", help="Custom appcast file to upload" + ), +): + """Publish appcast XML to make the release live + + This is the final step after 'release' uploads artifacts. + Publishing the appcast makes the update available to clients. + + \b + Release alpha appcast: + browseros ota server release-appcast --channel alpha + + \b + Release production appcast: + browseros ota server release-appcast --channel prod + + \b + Release custom appcast file: + browseros ota server release-appcast --file /path/to/appcast.xml + """ + if appcast_file: + if not appcast_file.exists(): + log_error(f"Appcast file not found: {appcast_file}") + raise typer.Exit(1) + source_path = appcast_file + else: + source_path = get_appcast_path(channel) + if not source_path.exists(): + log_error(f"Appcast file not found: {source_path}") + log_error("Run 'browseros ota server release' first to generate the appcast") + raise typer.Exit(1) + + if channel == "alpha": + r2_key = "appcast-server.alpha.xml" + else: + r2_key = "appcast-server.xml" + + log_info(f"šŸ“¤ Uploading {source_path.name} to {r2_key}...") + + env = EnvConfig() + if not env.has_r2_config(): + log_error("R2 configuration not set. Required env vars: R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY") + raise typer.Exit(1) + + r2_client = get_r2_client(env) + if not r2_client: + log_error("Failed to create R2 client") + raise typer.Exit(1) + + if upload_file_to_r2(r2_client, source_path, r2_key, env.r2_bucket): + cdn_url = f"https://cdn.browseros.com/{r2_key}" + log_success(f"āœ… Published: {cdn_url}") + else: + log_error("Upload failed") + raise typer.Exit(1) + + +@server_app.command("list-platforms") +def server_list_platforms(): + """List available server platforms""" + log_info("\nšŸ“¦ Available Server Platforms:") + log_info("-" * 50) + for p in SERVER_PLATFORMS: + log_info(f" {p['name']:<15} {p['os']:<10} {p['arch']}") + log_info("-" * 50) + + +@app.command("test-signing") +def test_signing( + file_path: Path = typer.Argument(..., help="File to sign for testing"), +): + """Test Sparkle Ed25519 signing on a file + + \b + Example: + browseros ota test-signing /path/to/file.zip + """ + if not file_path.exists(): + log_error(f"File not found: {file_path}") + raise typer.Exit(1) + + env = EnvConfig() + if not env.has_sparkle_key(): + log_error("SPARKLE_PRIVATE_KEY not set") + raise typer.Exit(1) + + log_info(f"\nšŸ” Testing Sparkle Ed25519 signing") + log_info(f"File: {file_path}") + log_info("-" * 60) + + sig, length = sparkle_sign_file(file_path, env) + if not sig: + log_error("Signing failed") + raise typer.Exit(1) + + log_success(f"āœ… Signed successfully") + log_info(f" Signature: {sig[:50]}...") + log_info(f" Length: {length}") + + +@server_app.callback(invoke_without_command=True) +def server_main(ctx: typer.Context): + """BrowserOS Server OTA commands + + \b + Release (upload artifacts): + browseros ota server release --version 0.0.36 + + \b + Release Appcast (make live): + browseros ota server release-appcast --channel alpha + + \b + List Platforms: + browseros ota server list-platforms + """ + if ctx.invoked_subcommand is None: + typer.echo("Use --help for usage information") + typer.echo("Available commands: release, release-appcast, list-platforms") + raise typer.Exit(0) + + +@app.callback(invoke_without_command=True) +def main(ctx: typer.Context): + """OTA update automation for BrowserOS + + \b + Server OTA: + browseros ota server release --version 0.0.36 + browseros ota server release-appcast --channel alpha + browseros ota server list-platforms + """ + if ctx.invoked_subcommand is None: + typer.echo("Use --help for usage information") + typer.echo("Available subcommands: server") + raise typer.Exit(0) + + +if __name__ == "__main__": + app() diff --git a/packages/browseros/build/common/env.py b/packages/browseros/build/common/env.py index d0488a8f..ecdcb5f7 100644 --- a/packages/browseros/build/common/env.py +++ b/packages/browseros/build/common/env.py @@ -165,6 +165,11 @@ class EnvConfig: """Base64-encoded Sparkle Ed25519 private key for macOS auto-update signing""" return os.environ.get("SPARKLE_PRIVATE_KEY") + @property + def sparkle_sign_update_path(self) -> Optional[str]: + """Path to Sparkle sign_update tool (overrides auto-detection)""" + return os.environ.get("SPARKLE_SIGN_UPDATE_PATH") + # === Notifications === @property diff --git a/packages/browseros/build/common/sparkle.py b/packages/browseros/build/common/sparkle.py new file mode 100644 index 00000000..6f580851 --- /dev/null +++ b/packages/browseros/build/common/sparkle.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Sparkle Ed25519 signing for auto-update + +Cross-platform Ed25519 signing compatible with Sparkle framework. +Uses Python cryptography library - works on macOS, Windows, and Linux. +""" + +import base64 +from pathlib import Path +from typing import Optional, Tuple + +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from .env import EnvConfig +from .utils import log_error + + +def _parse_sparkle_private_key(key_data: str) -> Optional[Ed25519PrivateKey]: + """Parse Sparkle Ed25519 private key from various formats + + Sparkle key formats: + - Raw 64-byte key (32-byte seed + 32-byte public key) + - Raw 32-byte seed + - Base64 encoded versions of above + + Returns: + Ed25519PrivateKey or None on failure + """ + try: + # Try base64 decode first (env var might be base64 encoded) + try: + key_bytes = base64.b64decode(key_data) + except Exception: + # Not base64, try as raw bytes + key_bytes = key_data.encode("latin-1") + + # Sparkle uses 64-byte format: 32-byte seed + 32-byte public key + if len(key_bytes) == 64: + seed = key_bytes[:32] + return Ed25519PrivateKey.from_private_bytes(seed) + elif len(key_bytes) == 32: + return Ed25519PrivateKey.from_private_bytes(key_bytes) + else: + log_error(f"Invalid Sparkle key length: {len(key_bytes)} bytes (expected 32 or 64)") + return None + + except Exception as e: + log_error(f"Failed to parse Sparkle private key: {e}") + return None + + +def sparkle_sign_file( + file_path: Path, + env: Optional[EnvConfig] = None, +) -> Tuple[Optional[str], int]: + """Sign a file with Sparkle Ed25519 key + + Args: + file_path: Path to file to sign (typically a zip or dmg) + env: Environment config with Sparkle key + + Returns: + (signature, length) tuple, or (None, 0) on failure + """ + if env is None: + env = EnvConfig() + + if not env.has_sparkle_key(): + log_error("SPARKLE_PRIVATE_KEY not set") + return None, 0 + + key_data = env.sparkle_private_key + if not key_data: + log_error("SPARKLE_PRIVATE_KEY is empty") + return None, 0 + + private_key = _parse_sparkle_private_key(key_data) + if not private_key: + return None, 0 + + try: + file_data = file_path.read_bytes() + file_length = len(file_data) + + signature_bytes = private_key.sign(file_data) + signature_b64 = base64.b64encode(signature_bytes).decode("ascii") + + return signature_b64, file_length + + except Exception as e: + log_error(f"Error signing {file_path.name}: {e}") + return None, 0 diff --git a/packages/browseros/build/config/appcast/appcast-server.alpha.xml b/packages/browseros/build/config/appcast/appcast-server.alpha.xml new file mode 100644 index 00000000..1a002fdc --- /dev/null +++ b/packages/browseros/build/config/appcast/appcast-server.alpha.xml @@ -0,0 +1,51 @@ + + + + BrowserOS Server (Alpha) + https://cdn.browseros.com/appcast-server.alpha.xml + BrowserOS Server binary updates + en + + + 0.0.37 + Tue, 06 Jan 2026 20:01:05 +0000 + + + + + + + + + + + + + + + + diff --git a/packages/browseros/build/config/appcast/appcast-server.xml b/packages/browseros/build/config/appcast/appcast-server.xml new file mode 100644 index 00000000..008cb8f1 --- /dev/null +++ b/packages/browseros/build/config/appcast/appcast-server.xml @@ -0,0 +1,9 @@ + + + + BrowserOS Server + https://cdn.browseros.com/appcast-server.xml + BrowserOS Server binary updates + en + + diff --git a/packages/browseros/build/modules/ota/__init__.py b/packages/browseros/build/modules/ota/__init__.py new file mode 100644 index 00000000..b1f614c6 --- /dev/null +++ b/packages/browseros/build/modules/ota/__init__.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +"""OTA (Over-The-Air) update modules for BrowserOS Server and Browser""" + +from .common import ( + sparkle_sign_file, + generate_server_appcast, + parse_existing_appcast, + ExistingAppcast, + SignedArtifact, + SERVER_PLATFORMS, + APPCAST_TEMPLATE, +) +from .sign_binary import ( + sign_macos_binary, + notarize_macos_binary, + sign_windows_binary, +) +from .server import ServerOTAModule + +AVAILABLE_MODULES = { + "server_ota": ServerOTAModule, +} + +__all__ = [ + "AVAILABLE_MODULES", + "ServerOTAModule", + "sparkle_sign_file", + "generate_server_appcast", + "parse_existing_appcast", + "ExistingAppcast", + "SignedArtifact", + "sign_macos_binary", + "notarize_macos_binary", + "sign_windows_binary", + "SERVER_PLATFORMS", + "APPCAST_TEMPLATE", +] diff --git a/packages/browseros/build/modules/ota/common.py b/packages/browseros/build/modules/ota/common.py new file mode 100644 index 00000000..233e7649 --- /dev/null +++ b/packages/browseros/build/modules/ota/common.py @@ -0,0 +1,275 @@ +#!/usr/bin/env python3 +"""Common utilities for OTA update modules""" + +import os +import re +import shutil +import zipfile +import xml.etree.ElementTree as ET +from datetime import datetime, timezone +from pathlib import Path +from typing import Dict, List, Optional +from dataclasses import dataclass + +from ...common.utils import log_error, log_info, log_success + +# Re-export sparkle_sign_file from common module +from ...common.sparkle import sparkle_sign_file + +# Sparkle XML namespace +SPARKLE_NS = "http://www.andymatuschak.org/xml-namespaces/sparkle" +ET.register_namespace("sparkle", SPARKLE_NS) + +SERVER_PLATFORMS = [ + {"name": "darwin_arm64", "binary": "browseros-server-darwin-arm64", "os": "macos", "arch": "arm64"}, + {"name": "darwin_x64", "binary": "browseros-server-darwin-x64", "os": "macos", "arch": "x86_64"}, + {"name": "linux_arm64", "binary": "browseros-server-linux-arm64", "os": "linux", "arch": "arm64"}, + {"name": "linux_x64", "binary": "browseros-server-linux-x64", "os": "linux", "arch": "x86_64"}, + {"name": "windows_x64", "binary": "browseros-server-windows-x64.exe", "os": "windows", "arch": "x86_64"}, +] + +APPCAST_TEMPLATE = """ + + + {title} + {appcast_url} + BrowserOS Server binary updates + en + + + {version} + {pub_date} + +{enclosures} + + + + +""" + +ENCLOSURE_TEMPLATE = """ + """ + + +@dataclass +class SignedArtifact: + """Represents a signed artifact with Sparkle signature""" + platform: str + zip_path: Path + signature: str + length: int + os: str + arch: str + + +@dataclass +class ExistingAppcast: + """Parsed data from an existing appcast file""" + version: str + pub_date: str + artifacts: Dict[str, SignedArtifact] + + +def parse_existing_appcast(appcast_path: Path) -> Optional[ExistingAppcast]: + """Parse existing appcast XML file. + + Args: + appcast_path: Path to existing appcast XML file + + Returns: + ExistingAppcast with version, pubDate, and artifacts, or None if parsing fails + """ + if not appcast_path.exists(): + return None + + try: + tree = ET.parse(appcast_path) + root = tree.getroot() + + # Find the item element (we only support single-item appcasts) + channel = root.find("channel") + if channel is None: + return None + + item = channel.find("item") + if item is None: + return None + + # Extract version + version_elem = item.find(f"{{{SPARKLE_NS}}}version") + if version_elem is None or version_elem.text is None: + return None + version = version_elem.text + + # Extract pubDate + pub_date_elem = item.find("pubDate") + pub_date = pub_date_elem.text if pub_date_elem is not None and pub_date_elem.text else "" + + # Extract enclosures + artifacts: Dict[str, SignedArtifact] = {} + for enclosure in item.findall("enclosure"): + url = enclosure.get("url", "") + os_type = enclosure.get(f"{{{SPARKLE_NS}}}os", "") + arch = enclosure.get(f"{{{SPARKLE_NS}}}arch", "") + signature = enclosure.get(f"{{{SPARKLE_NS}}}edSignature", "") + length_str = enclosure.get("length", "0") + + if not all([url, os_type, arch, signature]): + continue + + # Extract platform from URL (e.g., browseros_server_0.0.37_darwin_arm64.zip) + filename = url.split("/")[-1] + # Match pattern like _darwin_arm64.zip or _windows_x64.zip + platform_match = re.search(r"_([a-z]+_[a-z0-9]+)\.zip$", filename) + if not platform_match: + continue + + platform = platform_match.group(1) + artifacts[platform] = SignedArtifact( + platform=platform, + zip_path=Path(filename), + signature=signature, + length=int(length_str), + os=os_type, + arch=arch, + ) + + return ExistingAppcast(version=version, pub_date=pub_date, artifacts=artifacts) + + except ET.ParseError as e: + log_error(f"Malformed appcast XML: {e}") + return None + except Exception as e: + log_error(f"Failed to parse existing appcast: {e}") + return None + + +def generate_server_appcast( + version: str, + artifacts: List[SignedArtifact], + channel: str = "alpha", + existing: Optional[ExistingAppcast] = None, +) -> str: + """Generate appcast XML for server OTA, merging with existing if same version. + + Args: + version: Version string (e.g., "0.0.36") + artifacts: List of new SignedArtifact with signature info + channel: "alpha" or "prod" + existing: Previously parsed appcast to merge with (if same version) + + Returns: + Complete appcast XML string + + Merge behavior: + - If existing has same version: merge platforms, keep original pubDate + - If existing has different version or is None: use only new artifacts + """ + if channel == "alpha": + title = "BrowserOS Server (Alpha)" + appcast_url = "https://cdn.browseros.com/appcast-server.alpha.xml" + else: + title = "BrowserOS Server" + appcast_url = "https://cdn.browseros.com/appcast-server.xml" + + # Determine pubDate and merged artifacts + if existing is not None and existing.version == version: + # Same version: merge artifacts, keep original pubDate + pub_date = existing.pub_date + merged_artifacts = dict(existing.artifacts) # Copy existing + for artifact in artifacts: + merged_artifacts[artifact.platform] = artifact # New overrides existing + final_artifacts = list(merged_artifacts.values()) + log_info(f"Merging with existing appcast (kept {len(existing.artifacts)} existing, added/updated {len(artifacts)} platforms)") + else: + # Different version or no existing: start fresh + pub_date = datetime.now(timezone.utc).strftime("%a, %d %b %Y %H:%M:%S +0000") + final_artifacts = artifacts + if existing is not None: + log_info(f"Version changed ({existing.version} -> {version}), replacing appcast") + + # Sort artifacts by platform name for consistent output + final_artifacts = sorted(final_artifacts, key=lambda a: a.platform) + + enclosures = [] + for artifact in final_artifacts: + comment = f"{artifact.os.capitalize()} {artifact.arch}" + if artifact.os == "macos": + comment = f"macOS {artifact.arch}" + + zip_filename = f"browseros_server_{version}_{artifact.platform}.zip" + url = f"https://cdn.browseros.com/server/{zip_filename}" + + enclosure = ENCLOSURE_TEMPLATE.format( + comment=comment, + url=url, + os=artifact.os, + arch=artifact.arch, + signature=artifact.signature, + length=artifact.length, + ) + enclosures.append(enclosure) + + return APPCAST_TEMPLATE.format( + title=title, + appcast_url=appcast_url, + version=version, + pub_date=pub_date, + enclosures="\n\n".join(enclosures), + ) + + +def create_server_zip( + binary_path: Path, + output_zip: Path, + is_windows: bool = False, +) -> bool: + """Create zip with proper structure: resources/bin/browseros_server + + Args: + binary_path: Path to the binary to package + output_zip: Path for output zip file + is_windows: Whether this is Windows binary (affects target name) + + Returns: + True on success, False on failure + """ + staging_dir = output_zip.parent / f"staging_{output_zip.stem}" + try: + staging_dir.mkdir(parents=True, exist_ok=True) + bin_dir = staging_dir / "resources" / "bin" + bin_dir.mkdir(parents=True, exist_ok=True) + + target_name = "browseros_server.exe" if is_windows else "browseros_server" + shutil.copy2(binary_path, bin_dir / target_name) + + with zipfile.ZipFile(output_zip, 'w', zipfile.ZIP_DEFLATED) as zf: + for root, _, files in os.walk(staging_dir): + for file in files: + file_path = Path(root) / file + arcname = file_path.relative_to(staging_dir) + zf.write(file_path, arcname) + + log_success(f"Created {output_zip.name}") + return True + + except Exception as e: + log_error(f"Failed to create zip: {e}") + return False + finally: + if staging_dir.exists(): + shutil.rmtree(staging_dir) + + +def get_appcast_path(channel: str = "alpha") -> Path: + """Get path to appcast file in config/appcast directory""" + appcast_dir = Path(__file__).parent.parent.parent / "config" / "appcast" + if channel == "alpha": + return appcast_dir / "appcast-server.alpha.xml" + return appcast_dir / "appcast-server.xml" diff --git a/packages/browseros/build/modules/ota/server.py b/packages/browseros/build/modules/ota/server.py new file mode 100644 index 00000000..4e011dd9 --- /dev/null +++ b/packages/browseros/build/modules/ota/server.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +"""Server OTA module for BrowserOS Server binary updates""" + +import shutil +import tempfile +from pathlib import Path +from typing import List, Optional + +from ...common.module import CommandModule, ValidationError +from ...common.context import Context +from ...common.utils import ( + log_info, + log_error, + log_success, + log_warning, + IS_MACOS, + IS_WINDOWS, +) + +from .common import ( + SERVER_PLATFORMS, + SignedArtifact, + sparkle_sign_file, + generate_server_appcast, + parse_existing_appcast, + create_server_zip, + get_appcast_path, +) +from .sign_binary import ( + sign_macos_binary, + notarize_macos_binary, + sign_windows_binary, + get_entitlements_path, +) +from ..upload import get_r2_client, upload_file_to_r2 + + +class ServerOTAModule(CommandModule): + """OTA update module for BrowserOS Server binaries + + This module handles the full OTA workflow: + 1. Sign individual binaries (codesign for macOS, CodeSignTool for Windows) + 2. Create zip packages with proper structure + 3. Sign zips with Sparkle Ed25519 + 4. Upload to R2 + 5. Generate and upload appcast XML + """ + + produces = ["server_ota_artifacts", "server_appcast"] + requires = [] + description = "Create and upload BrowserOS Server OTA update" + + def __init__( + self, + version: str = "", + channel: str = "alpha", + binaries_dir: Optional[Path] = None, + platform_filter: Optional[str] = None, + ): + """ + Args: + version: Version string (e.g., "0.0.36") + channel: Release channel ("alpha" or "prod") + binaries_dir: Directory containing server binaries + platform_filter: Platform(s) to process, comma-separated (e.g., "darwin_arm64,darwin_x64") + """ + self.version = version + self.channel = channel + self.binaries_dir = binaries_dir + self.platform_filter = platform_filter + + def validate(self, context: Context) -> None: + ctx = context + if not self.version: + raise ValidationError("Version is required") + + if self.channel not in ["alpha", "prod"]: + raise ValidationError("Channel must be 'alpha' or 'prod'") + + if self.binaries_dir: + if not self.binaries_dir.exists(): + raise ValidationError(f"Binaries directory not found: {self.binaries_dir}") + else: + default_dir = ctx.root_dir / "resources" / "binaries" / "browseros_server" + if not default_dir.exists(): + raise ValidationError(f"Default binaries directory not found: {default_dir}") + self.binaries_dir = default_dir + + platforms = self._get_platforms() + for p in platforms: + binary_name = p["binary"] + binary_path = self.binaries_dir / binary_name + if not binary_path.exists(): + raise ValidationError(f"Binary not found: {binary_path}") + + if IS_MACOS(): + if not ctx.env.macos_certificate_name: + raise ValidationError("MACOS_CERTIFICATE_NAME required for signing") + elif IS_WINDOWS(): + if not ctx.env.code_sign_tool_path: + raise ValidationError("CODE_SIGN_TOOL_PATH required for signing") + + if not ctx.env.has_r2_config(): + raise ValidationError( + "R2 configuration not set. Required env vars: " + "R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY" + ) + + def _get_platforms(self) -> List[dict]: + """Get platforms to process based on filter (supports comma-separated)""" + if self.platform_filter: + requested = [p.strip() for p in self.platform_filter.split(",")] + return [p for p in SERVER_PLATFORMS if p["name"] in requested] + return SERVER_PLATFORMS + + def execute(self, context: Context) -> None: + ctx = context + log_info(f"\nšŸš€ BrowserOS Server OTA v{self.version} ({self.channel})") + log_info("=" * 70) + + platforms = self._get_platforms() + temp_dir = Path(tempfile.mkdtemp()) + log_info(f"Temp directory: {temp_dir}") + + signed_artifacts: List[SignedArtifact] = [] + + for platform in platforms: + log_info(f"\nšŸ“¦ Processing {platform['name']}...") + + binary_name = platform["binary"] + source_binary = self.binaries_dir / binary_name + + # Copy binary to temp to preserve original + temp_binary = temp_dir / binary_name + shutil.copy2(source_binary, temp_binary) + + if not self._sign_binary(temp_binary, platform, ctx): + log_warning(f"Skipping {platform['name']} due to signing failure") + continue + + zip_name = f"browseros_server_{self.version}_{platform['name']}.zip" + zip_path = temp_dir / zip_name + is_windows = platform["os"] == "windows" + + if not create_server_zip(temp_binary, zip_path, is_windows): + log_error(f"Failed to create zip for {platform['name']}") + continue + + log_info(f"Signing {zip_name} with Sparkle...") + signature, length = sparkle_sign_file(zip_path, ctx.env) + + if not signature: + log_error(f"Failed to sign zip for {platform['name']}") + continue + + log_success(f" {platform['name']}: {length} bytes") + + artifact = SignedArtifact( + platform=platform["name"], + zip_path=zip_path, + signature=signature, + length=length, + os=platform["os"], + arch=platform["arch"], + ) + signed_artifacts.append(artifact) + + if not signed_artifacts: + log_error("No artifacts were processed successfully") + raise RuntimeError("OTA failed - no artifacts") + + log_info("\nšŸ“ Generating appcast...") + appcast_path = get_appcast_path(self.channel) + existing_appcast = parse_existing_appcast(appcast_path) + + appcast_content = generate_server_appcast( + self.version, + signed_artifacts, + self.channel, + existing=existing_appcast, + ) + appcast_path.parent.mkdir(parents=True, exist_ok=True) + appcast_path.write_text(appcast_content) + log_success(f"Appcast saved to: {appcast_path}") + + log_info("\nšŸ“¤ Uploading artifacts to R2...") + r2_client = get_r2_client(ctx.env) + if not r2_client: + raise RuntimeError("Failed to create R2 client") + + bucket = ctx.env.r2_bucket + for artifact in signed_artifacts: + r2_key = f"server/{artifact.zip_path.name}" + if not upload_file_to_r2(r2_client, artifact.zip_path, r2_key, bucket): + raise RuntimeError(f"Failed to upload {artifact.zip_path.name}") + + ctx.artifacts["server_ota_artifacts"] = signed_artifacts + ctx.artifacts["server_appcast"] = appcast_path + + log_info("\n" + "=" * 70) + log_success(f"āœ… Server OTA v{self.version} ({self.channel}) artifacts ready!") + log_info("=" * 70) + + log_info("\nArtifact URLs:") + for artifact in signed_artifacts: + log_info(f" https://cdn.browseros.com/server/{artifact.zip_path.name}") + + log_info(f"\nAppcast saved to: {appcast_path}") + log_info("\nšŸ“‹ Next step: Run 'browseros ota server release-appcast' to make the release live") + + def _sign_binary(self, binary_path: Path, platform: dict, ctx: Context) -> bool: + """Sign binary based on platform""" + os_type = platform["os"] + + if os_type == "macos": + if not IS_MACOS(): + log_warning(f"macOS signing requires macOS - skipping {platform['name']}") + return True + + entitlements = get_entitlements_path(ctx.root_dir) + if not sign_macos_binary(binary_path, ctx.env, entitlements): + return False + + log_info("Notarizing...") + return notarize_macos_binary(binary_path, ctx.env) + + elif os_type == "windows": + if not IS_WINDOWS(): + log_warning(f"Windows signing requires Windows - skipping {platform['name']}") + return True + + return sign_windows_binary(binary_path, ctx.env) + + elif os_type == "linux": + log_info(f"No code signing for Linux binaries") + return True + + return True diff --git a/packages/browseros/build/modules/ota/sign_binary.py b/packages/browseros/build/modules/ota/sign_binary.py new file mode 100644 index 00000000..c3d11db5 --- /dev/null +++ b/packages/browseros/build/modules/ota/sign_binary.py @@ -0,0 +1,304 @@ +#!/usr/bin/env python3 +"""Platform-specific binary signing for OTA binaries""" + +import subprocess +import tempfile +from pathlib import Path +from typing import Optional + +from ...common.env import EnvConfig +from ...common.utils import ( + log_info, + log_error, + log_success, + log_warning, + IS_MACOS, + IS_WINDOWS, +) + + +def sign_macos_binary( + binary_path: Path, + env: Optional[EnvConfig] = None, + entitlements_path: Optional[Path] = None, +) -> bool: + """Sign a macOS binary with codesign + + Args: + binary_path: Path to binary to sign + env: Environment config with certificate name + entitlements_path: Optional path to entitlements plist + + Returns: + True on success, False on failure + """ + if not IS_MACOS(): + log_error("macOS signing requires macOS") + return False + + if env is None: + env = EnvConfig() + + certificate_name = env.macos_certificate_name + if not certificate_name: + log_error("MACOS_CERTIFICATE_NAME not set") + return False + + log_info(f"Signing {binary_path.name}...") + + cmd = [ + "codesign", + "--sign", certificate_name, + "--force", + "--timestamp", + "--identifier", f"com.browseros.{binary_path.stem}", + "--options", "runtime", + ] + + if entitlements_path and entitlements_path.exists(): + cmd.extend(["--entitlements", str(entitlements_path)]) + + cmd.append(str(binary_path)) + + try: + result = subprocess.run(cmd, capture_output=True, text=True, check=False) + if result.returncode != 0: + log_error(f"codesign failed: {result.stderr}") + return False + + log_success(f"Signed {binary_path.name}") + return True + + except Exception as e: + log_error(f"Signing failed: {e}") + return False + + +def verify_macos_signature(binary_path: Path) -> bool: + """Verify macOS binary signature""" + if not IS_MACOS(): + return False + + try: + result = subprocess.run( + ["codesign", "--verify", "--verbose=2", str(binary_path)], + capture_output=True, + text=True, + check=False, + ) + return result.returncode == 0 + except Exception: + return False + + +def notarize_macos_binary( + binary_path: Path, + env: Optional[EnvConfig] = None, +) -> bool: + """Notarize a macOS binary with Apple + + The binary must be zipped for notarization submission. + + Args: + binary_path: Path to binary to notarize (will be zipped internally) + env: Environment config with notarization credentials + + Returns: + True on success, False on failure + """ + if not IS_MACOS(): + log_error("macOS notarization requires macOS") + return False + + if env is None: + env = EnvConfig() + + apple_id = env.macos_notarization_apple_id + team_id = env.macos_notarization_team_id + password = env.macos_notarization_password + + if not all([apple_id, team_id, password]): + log_error("Missing notarization credentials:") + if not apple_id: + log_error(" PROD_MACOS_NOTARIZATION_APPLE_ID not set") + if not team_id: + log_error(" PROD_MACOS_NOTARIZATION_TEAM_ID not set") + if not password: + log_error(" PROD_MACOS_NOTARIZATION_PWD not set") + return False + + log_info(f"Notarizing {binary_path.name}...") + + notarize_zip = None + try: + fd, tmp_path = tempfile.mkstemp(suffix=".zip") + import os + os.close(fd) + notarize_zip = Path(tmp_path) + + result = subprocess.run( + ["ditto", "-c", "-k", "--keepParent", str(binary_path), str(notarize_zip)], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + log_error(f"Failed to create zip: {result.stderr}") + return False + + assert apple_id is not None + assert team_id is not None + assert password is not None + subprocess.run( + [ + "xcrun", "notarytool", "store-credentials", "notarytool-profile", + "--apple-id", apple_id, + "--team-id", team_id, + "--password", password, + ], + capture_output=True, + text=True, + check=False, + ) + + log_info("Submitting for notarization (this may take a while)...") + result = subprocess.run( + [ + "xcrun", "notarytool", "submit", str(notarize_zip), + "--keychain-profile", "notarytool-profile", + "--wait", + ], + capture_output=True, + text=True, + check=False, + ) + + if result.returncode != 0: + log_error(f"Notarization failed: {result.stderr}") + log_error(result.stdout) + return False + + if "status: Accepted" not in result.stdout: + log_error("Notarization was not accepted") + log_error(result.stdout) + return False + + log_success(f"Notarized {binary_path.name}") + return True + + except Exception as e: + log_error(f"Notarization failed: {e}") + return False + finally: + if notarize_zip and notarize_zip.exists(): + notarize_zip.unlink() + + +def sign_windows_binary( + binary_path: Path, + env: Optional[EnvConfig] = None, +) -> bool: + """Sign a Windows binary with SSL.com CodeSignTool + + Args: + binary_path: Path to binary to sign + env: Environment config with eSigner credentials + + Returns: + True on success, False on failure + """ + if not IS_WINDOWS(): + log_warning("Windows signing should be done on Windows") + log_warning("Skipping signing - binary will be unsigned") + return True + + if env is None: + env = EnvConfig() + + if not env.code_sign_tool_path: + log_error("CODE_SIGN_TOOL_PATH not set") + return False + + if not all([env.esigner_username, env.esigner_password, env.esigner_totp_secret]): + log_error("Missing eSigner credentials") + return False + + codesigntool_path = Path(env.code_sign_tool_path) / "CodeSignTool.bat" + if not codesigntool_path.exists(): + log_error(f"CodeSignTool.bat not found at: {codesigntool_path}") + return False + + log_info(f"Signing {binary_path.name}...") + + try: + temp_output_dir = binary_path.parent / "signed_temp" + temp_output_dir.mkdir(exist_ok=True) + + cmd = [ + str(codesigntool_path), + "sign", + "-username", env.esigner_username, + "-password", f'"{env.esigner_password}"', + ] + + if env.esigner_credential_id: + cmd.extend(["-credential_id", env.esigner_credential_id]) + + cmd.extend([ + "-totp_secret", env.esigner_totp_secret, + "-input_file_path", str(binary_path), + "-output_dir_path", str(temp_output_dir), + "-override", + ]) + + result = subprocess.run( + " ".join(cmd), + shell=True, + capture_output=True, + text=True, + cwd=str(codesigntool_path.parent), + ) + + if result.stdout and "Error:" in result.stdout: + log_error(f"Signing failed: {result.stdout}") + return False + + signed_file = temp_output_dir / binary_path.name + if signed_file.exists(): + import shutil + shutil.move(str(signed_file), str(binary_path)) + + try: + temp_output_dir.rmdir() + except Exception: + pass + + verify_cmd = [ + "powershell", "-Command", + f"(Get-AuthenticodeSignature '{binary_path}').Status", + ] + verify_result = subprocess.run(verify_cmd, capture_output=True, text=True) + if "Valid" in verify_result.stdout: + log_success(f"Signed and verified {binary_path.name}") + return True + else: + log_error(f"Signature verification failed: {verify_result.stdout.strip()}") + return False + + except Exception as e: + log_error(f"Signing failed: {e}") + return False + + +def get_entitlements_path(root_dir: Path) -> Optional[Path]: + """Get path to server binary entitlements file""" + candidates = [ + root_dir / "resources" / "entitlements" / "browseros-executable-entitlements.plist", + root_dir / "packages" / "browseros" / "resources" / "entitlements" / "browseros-executable-entitlements.plist", + ] + + for candidate in candidates: + if candidate.exists(): + return candidate + + return None diff --git a/packages/browseros/build/modules/sign/sparkle.py b/packages/browseros/build/modules/sign/sparkle.py index 1f463426..3264cb15 100644 --- a/packages/browseros/build/modules/sign/sparkle.py +++ b/packages/browseros/build/modules/sign/sparkle.py @@ -1,23 +1,16 @@ #!/usr/bin/env python3 """Sparkle signing module for macOS auto-update""" -import base64 -import os -import re -import subprocess -import tempfile from pathlib import Path -from typing import Dict, Optional, Tuple +from typing import Dict, Tuple from ...common.module import CommandModule, ValidationError from ...common.context import Context +from ...common.sparkle import sparkle_sign_file from ...common.utils import ( log_info, - log_error, log_success, log_warning, - IS_MACOS, - join_paths, ) @@ -29,24 +22,11 @@ class SparkleSignModule(CommandModule): description = "Sign DMG files with Sparkle Ed25519 key for auto-update" def validate(self, ctx: Context) -> None: - if not IS_MACOS(): - raise ValidationError("Sparkle signing is macOS only") - - # Check sign_update tool exists - sign_update = self._get_sign_update_path(ctx) - if not sign_update.exists(): - raise ValidationError(f"sign_update not found: {sign_update}") - - # Check Sparkle private key is available if not ctx.env.has_sparkle_key(): raise ValidationError( "SPARKLE_PRIVATE_KEY environment variable not set" ) - def _get_sign_update_path(self, ctx: Context) -> Path: - """Get path to Sparkle sign_update tool""" - return join_paths(ctx.chromium_src, "third_party", "sparkle", "bin", "sign_update") - def execute(self, ctx: Context) -> None: log_info("\nšŸ” Signing DMGs with Sparkle...") @@ -88,120 +68,18 @@ def sign_dmgs_with_sparkle( Returns: Dict mapping filename to (signature, length) tuple """ - env = ctx.env - sign_update = join_paths(ctx.chromium_src, "third_party", "sparkle", "bin", "sign_update") - - if not sign_update.exists(): - log_error(f"sign_update not found: {sign_update}") - return {} - - if not env.has_sparkle_key(): - log_error("SPARKLE_PRIVATE_KEY not set") - return {} - signatures = {} - # Write private key to temp file - key_file = None - try: - # Decode base64 key if it looks encoded, otherwise use as-is - key_data = env.sparkle_private_key - try: - # Try to decode as base64 - decoded = base64.b64decode(key_data) - key_data = decoded.decode("utf-8") - except Exception: - # Not base64, use as-is - pass - - # Write key to temp file - key_file = tempfile.NamedTemporaryFile( - mode="w", suffix=".key", delete=False - ) - key_file.write(key_data) - key_file.close() - - # Sign each DMG - for dmg_path in dmg_files: - sig, length = _sign_single_dmg(sign_update, key_file.name, dmg_path) - if sig: - signatures[dmg_path.name] = (sig, length) - - finally: - # Clean up temp key file - if key_file and os.path.exists(key_file.name): - os.unlink(key_file.name) + for dmg_path in dmg_files: + log_info(f"šŸ” Signing {dmg_path.name}...") + sig, length = sparkle_sign_file(dmg_path, ctx.env) + if sig: + signatures[dmg_path.name] = (sig, length) + log_success(f"āœ“ Signed {dmg_path.name}") return signatures -def _sign_single_dmg( - sign_update: Path, - key_file: str, - dmg_path: Path, -) -> Tuple[Optional[str], int]: - """Sign a single DMG and parse the output - - Args: - sign_update: Path to sign_update binary - key_file: Path to temporary key file - dmg_path: Path to DMG file - - Returns: - (signature, length) tuple, or (None, 0) on failure - """ - log_info(f"šŸ” Signing {dmg_path.name}...") - - try: - result = subprocess.run( - [str(sign_update), "--ed-key-file", key_file, str(dmg_path)], - capture_output=True, - text=True, - check=False, - ) - - if result.returncode != 0: - log_error(f"sign_update failed: {result.stderr}") - return None, 0 - - # Parse output: sparkle:edSignature="..." length="..." - output = result.stdout.strip() - sig, length = parse_sparkle_output(output) - - if sig: - log_success(f"āœ“ Signed {dmg_path.name}") - return sig, length - else: - log_error(f"Failed to parse sign_update output: {output}") - return None, 0 - - except Exception as e: - log_error(f"Error signing {dmg_path.name}: {e}") - return None, 0 - - -def parse_sparkle_output(output: str) -> Tuple[Optional[str], int]: - """Parse sign_update output to extract signature and length - - Example output: - sparkle:edSignature="abc123..." length="126911210" - - Args: - output: Raw output from sign_update - - Returns: - (signature, length) tuple, or (None, 0) if parsing fails - """ - # Match: sparkle:edSignature="..." length="..." - sig_match = re.search(r'sparkle:edSignature="([^"]+)"', output) - len_match = re.search(r'length="(\d+)"', output) - - if sig_match and len_match: - return sig_match.group(1), int(len_match.group(1)) - - return None, 0 - - def get_sparkle_signatures(ctx: Context) -> Dict[str, Tuple[str, int]]: """Get stored Sparkle signatures from context diff --git a/packages/browseros/pyproject.toml b/packages/browseros/pyproject.toml index 8dcefb1c..232c3f91 100644 --- a/packages/browseros/pyproject.toml +++ b/packages/browseros/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "boto3>=1.34.0", "python-dotenv>=1.0.0", "Pillow>=10.0.0", + "cryptography>=41.0.0", ] [project.scripts] @@ -34,6 +35,7 @@ packages = [ "build.modules.extract", "build.modules.apply", "build.modules.feature", + "build.modules.ota", ] [tool.black] diff --git a/packages/browseros/uv.lock b/packages/browseros/uv.lock index e37796ee..deba14d3 100644 --- a/packages/browseros/uv.lock +++ b/packages/browseros/uv.lock @@ -41,6 +41,7 @@ source = { editable = "." } dependencies = [ { name = "boto3" }, { name = "click" }, + { name = "cryptography" }, { name = "pillow" }, { name = "python-dotenv" }, { name = "pyyaml" }, @@ -58,6 +59,7 @@ dev = [ requires-dist = [ { name = "boto3", specifier = ">=1.34.0" }, { name = "click", specifier = ">=8.0.0" }, + { name = "cryptography", specifier = ">=41.0.0" }, { name = "pillow", specifier = ">=10.0.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, { name = "pyyaml", specifier = ">=5.4.1" }, @@ -80,6 +82,63 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" }, ] +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.4" @@ -158,6 +217,62 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "cryptography" +version = "46.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" }, + { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" }, + { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" }, + { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" }, + { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" }, + { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" }, + { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" }, + { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" }, + { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" }, + { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" }, + { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" }, + { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" }, + { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" }, + { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" }, + { url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" }, + { url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" }, + { url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" }, + { url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" }, + { url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" }, + { url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" }, + { url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" }, + { url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" }, + { url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" }, + { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" }, + { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" }, + { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" }, + { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" }, + { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" }, + { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" }, + { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" }, + { url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" }, + { url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -275,6 +390,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/70/6b41bdcddf541b437bbb9f47f94d2db5d9ddef6c37ccab8c9107743748a4/pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7", size = 2525630, upload-time = "2025-10-15T18:23:57.149Z" }, ] +[[package]] +name = "pycparser" +version = "2.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, +] + [[package]] name = "pygments" version = "2.19.2"