Install approved plugin wheels directly from R2

This commit is contained in:
yellowcooln committed 2026-09-04 12:13:08 -04:00
1 parent 3e008cac26
commit eb8f934124
14 files changed
+534 -124

No files matched your search

+147 -6
View File
@@ -2,25 +2,33 @@
from __future__ import annotations
import hashlib
import json
import logging
import os
import re
import ssl
import tempfile
import time
import urllib.error
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Optional
from urllib.parse import urlsplit
logger = logging.getLogger("PluginCatalogue")
DEFAULT_CATALOGUE_URL = "https://repeater-plugins.openhop.dev/catalogue.json"
DEFAULT_CACHE_TTL_SECONDS = 600 # 10 minutes
SUPPORTED_SCHEMA = 1
SUPPORTED_SCHEMAS = (1, 2)
USER_AGENT = "openhop-repeater-plugin-manager/1.0"
APPROVED_WHEEL_ORIGIN = "https://repeater-plugins.openhop.dev"
MAX_WHEEL_BYTES = 100 * 1024 * 1024
_REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
_ID_RE = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
_SHA256_RE = re.compile(r"^[a-f0-9]{64}$")
_ssl_ctx: Optional[ssl.SSLContext] = None
@@ -37,6 +45,24 @@ def _ssl_context() -> ssl.SSLContext:
return _ssl_ctx
def _validate_approved_wheel_url(plugin_id: str, version: str, wheel_url: str) -> None:
parsed = urlsplit(wheel_url)
origin = f"{parsed.scheme}://{parsed.netloc}"
expected_prefix = f"/plugins/{plugin_id}/{version}/"
if (
origin != APPROVED_WHEEL_ORIGIN
or not parsed.path.startswith(expected_prefix)
or not parsed.path.endswith(".whl")
or parsed.query
or parsed.fragment
):
raise CatalogueError(
f"wheel_url must be an approved R2 wheel under "
f"{APPROVED_WHEEL_ORIGIN}{expected_prefix}",
code=400,
)
@dataclass(frozen=True)
class CataloguePlugin:
id: str
@@ -45,6 +71,13 @@ class CataloguePlugin:
repository: str
category: str = ""
logo: str = ""
version: Optional[str] = None
wheel_url: Optional[str] = None
sha256: Optional[str] = None
@property
def has_approved_wheel(self) -> bool:
return bool(self.version and self.wheel_url and self.sha256)
def to_dict(self) -> dict[str, Any]:
out: dict[str, Any] = {
@@ -57,6 +90,12 @@ class CataloguePlugin:
out["category"] = self.category
if self.logo:
out["logo"] = self.logo
if self.version:
out["version"] = self.version
if self.wheel_url:
out["wheel_url"] = self.wheel_url
if self.sha256:
out["sha256"] = self.sha256
return out
@@ -82,9 +121,9 @@ def parse_catalogue(data: Any) -> Catalogue:
if not isinstance(data, dict):
raise CatalogueError("catalogue must be a JSON object", code=400)
schema = data.get("schema")
if schema != SUPPORTED_SCHEMA:
if schema not in SUPPORTED_SCHEMAS:
raise CatalogueError(
f"unsupported catalogue schema {schema!r}; expected {SUPPORTED_SCHEMA}",
f"unsupported catalogue schema {schema!r}; expected one of {SUPPORTED_SCHEMAS}",
code=400,
)
raw_plugins = data.get("plugins")
@@ -94,6 +133,7 @@ def parse_catalogue(data: Any) -> Catalogue:
plugins: list[CataloguePlugin] = []
seen_ids: set[str] = set()
seen_repos: set[str] = set()
seen_wheel_urls: set[str] = set()
for idx, item in enumerate(raw_plugins):
if not isinstance(item, dict):
raise CatalogueError(f"plugins[{idx}] must be an object", code=400)
@@ -144,6 +184,33 @@ def parse_catalogue(data: Any) -> Catalogue:
code=400,
)
version = None
wheel_url = None
sha256 = None
if schema == 2:
raw_version = item.get("version")
raw_wheel_url = item.get("wheel_url")
raw_sha256 = item.get("sha256")
if not isinstance(raw_version, str) or not raw_version.strip():
raise CatalogueError(f"plugins[{idx}].version is required", code=400)
if not isinstance(raw_wheel_url, str) or not raw_wheel_url.strip():
raise CatalogueError(f"plugins[{idx}].wheel_url is required", code=400)
if not isinstance(raw_sha256, str) or not _SHA256_RE.fullmatch(raw_sha256):
raise CatalogueError(
f"plugins[{idx}].sha256 must be a lowercase SHA-256 digest",
code=400,
)
version = raw_version.strip()
wheel_url = raw_wheel_url.strip()
sha256 = raw_sha256
try:
_validate_approved_wheel_url(plugin_id, version, wheel_url)
except CatalogueError as exc:
raise CatalogueError(f"plugins[{idx}].{exc}", code=exc.code) from exc
if wheel_url in seen_wheel_urls:
raise CatalogueError(f"duplicate wheel URL: {wheel_url}", code=400)
seen_wheel_urls.add(wheel_url)
seen_ids.add(plugin_id)
seen_repos.add(repository)
plugins.append(
@@ -154,13 +221,16 @@ def parse_catalogue(data: Any) -> Catalogue:
repository=repository,
category=category.strip(),
logo=logo,
version=version,
wheel_url=wheel_url,
sha256=sha256,
)
)
return Catalogue(schema=SUPPORTED_SCHEMA, plugins=tuple(plugins))
return Catalogue(schema=int(schema), plugins=tuple(plugins))
class CatalogueClient:
"""Fetch and cache the curated plugin catalogue JSON."""
"""Fetch, cache, and download from the curated plugin catalogue."""
def __init__(
self,
@@ -178,7 +248,7 @@ class CatalogueClient:
self._cached: Optional[Catalogue] = None
def _default_open(self, request: urllib.request.Request, timeout: float = 30.0):
return urllib.request.urlopen( # nosec B310 - configured catalogue URL
return urllib.request.urlopen( # nosec B310 - validated HTTPS URLs
request, timeout=timeout, context=_ssl_context()
)
@@ -238,3 +308,74 @@ class CatalogueClient:
if plugin is None:
raise CatalogueError(f"plugin not in catalogue: {plugin_id}", code=404)
return plugin
def download_wheel(self, plugin: CataloguePlugin, dest_dir: Path | str) -> Path:
"""Download and verify one schema-2 approved wheel from R2."""
if not plugin.version or not plugin.wheel_url or not plugin.sha256:
raise CatalogueError(
f"catalogue entry {plugin.id} does not contain approved wheel metadata",
code=400,
)
_validate_approved_wheel_url(plugin.id, plugin.version, plugin.wheel_url)
if not _SHA256_RE.fullmatch(plugin.sha256):
raise CatalogueError(f"invalid approved checksum for {plugin.id}", code=400)
destination_dir = Path(dest_dir)
destination_dir.mkdir(parents=True, exist_ok=True)
destination = destination_dir / Path(urlsplit(plugin.wheel_url).path).name
fd, temporary_name = tempfile.mkstemp(prefix=".wheel-", dir=str(destination_dir))
digest = hashlib.sha256()
total = 0
request = urllib.request.Request(
plugin.wheel_url,
headers={
"User-Agent": self.user_agent,
"Accept": "application/octet-stream",
},
method="GET",
)
try:
with os.fdopen(fd, "wb") as output:
try:
with self._opener(request, timeout=120.0) as response:
final_url = getattr(response, "geturl", lambda: plugin.wheel_url)()
_validate_approved_wheel_url(
plugin.id,
plugin.version,
final_url or plugin.wheel_url,
)
while True:
chunk = response.read(1024 * 1024)
if not chunk:
break
total += len(chunk)
if total > MAX_WHEEL_BYTES:
raise CatalogueError(
"approved plugin wheel exceeds 100 MiB", code=502
)
digest.update(chunk)
output.write(chunk)
except CatalogueError:
raise
except urllib.error.HTTPError as exc:
raise CatalogueError(
f"approved plugin wheel is unavailable (HTTP {exc.code})", code=502
) from exc
except urllib.error.URLError as exc:
raise CatalogueError("approved plugin wheel is unavailable", code=502) from exc
except Exception as exc:
raise CatalogueError("approved plugin wheel is unavailable", code=502) from exc
if total == 0:
raise CatalogueError("approved plugin wheel is empty", code=502)
if digest.hexdigest() != plugin.sha256:
raise CatalogueError(
f"approved plugin wheel checksum mismatch for {plugin.id}", code=502
)
os.replace(temporary_name, destination)
return destination
finally:
try:
Path(temporary_name).unlink(missing_ok=True)
except OSError as exc:
logger.debug("Could not remove temporary wheel %s: %s", temporary_name, exc)