|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Verify that the public QSDM Hive updater feed matches an approved release.""" |
| 3 | + |
| 4 | +from __future__ import annotations |
| 5 | + |
| 6 | +import argparse |
| 7 | +import base64 |
| 8 | +import json |
| 9 | +import re |
| 10 | +import ssl |
| 11 | +import sys |
| 12 | +import urllib.error |
| 13 | +import urllib.request |
| 14 | +from collections.abc import Callable |
| 15 | +from dataclasses import dataclass |
| 16 | + |
| 17 | + |
| 18 | +DEFAULT_BASE_URL = "https://qsdm.tech/downloads" |
| 19 | +PINNED_RELEASE_KEY_ID = ( |
| 20 | + "10ab9c5710761d4c9dca59d42446e9ea0e3315d15cdc3715df1dcb8c96fa07a1" |
| 21 | +) |
| 22 | +SEMVER_RE = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+$") |
| 23 | +COMMIT_RE = re.compile(r"^[0-9a-f]{40}$") |
| 24 | + |
| 25 | + |
| 26 | +class FeedCheckError(RuntimeError): |
| 27 | + """Raised when the public release feed is missing or inconsistent.""" |
| 28 | + |
| 29 | + |
| 30 | +@dataclass(frozen=True) |
| 31 | +class UpdaterManifest: |
| 32 | + version: str |
| 33 | + path: str |
| 34 | + |
| 35 | + |
| 36 | +def parse_updater_manifest(content: str, source: str) -> UpdaterManifest: |
| 37 | + values: dict[str, str] = {} |
| 38 | + for line in content.splitlines(): |
| 39 | + match = re.match(r"^(version|path):\s*['\"]?([^'\"\s]+)", line.strip()) |
| 40 | + if match and match.group(1) not in values: |
| 41 | + values[match.group(1)] = match.group(2) |
| 42 | + |
| 43 | + if not values.get("version") or not values.get("path"): |
| 44 | + raise FeedCheckError(f"{source} is missing version or path") |
| 45 | + return UpdaterManifest(version=values["version"], path=values["path"]) |
| 46 | + |
| 47 | + |
| 48 | +def validate_release_envelope( |
| 49 | + content: str, |
| 50 | + *, |
| 51 | + source: str, |
| 52 | + expected_version: str, |
| 53 | + expected_commit: str, |
| 54 | + expected_platform: str, |
| 55 | + expected_artifact: str, |
| 56 | +) -> None: |
| 57 | + try: |
| 58 | + envelope = json.loads(content) |
| 59 | + except json.JSONDecodeError as error: |
| 60 | + raise FeedCheckError(f"{source} is not valid JSON: {error}") from error |
| 61 | + |
| 62 | + if envelope.get("schema") != "qsdm.signed-release.v1": |
| 63 | + raise FeedCheckError(f"{source} has an unsupported envelope schema") |
| 64 | + if envelope.get("algorithm") != "ML-DSA-87": |
| 65 | + raise FeedCheckError(f"{source} is not signed with ML-DSA-87") |
| 66 | + if envelope.get("key_id") != PINNED_RELEASE_KEY_ID: |
| 67 | + raise FeedCheckError(f"{source} is signed by an unapproved release key") |
| 68 | + |
| 69 | + encoded_manifest = envelope.get("manifest_base64") |
| 70 | + if not isinstance(encoded_manifest, str) or not encoded_manifest: |
| 71 | + raise FeedCheckError(f"{source} has no signed manifest payload") |
| 72 | + try: |
| 73 | + manifest = json.loads( |
| 74 | + base64.b64decode(encoded_manifest, validate=True).decode("utf-8") |
| 75 | + ) |
| 76 | + except (ValueError, UnicodeDecodeError, json.JSONDecodeError) as error: |
| 77 | + raise FeedCheckError(f"{source} has an invalid signed manifest: {error}") from error |
| 78 | + |
| 79 | + expected_fields = { |
| 80 | + "schema": "qsdm.release-manifest.v1", |
| 81 | + "product": "qsdm-hive", |
| 82 | + "channel": "stable", |
| 83 | + "version": expected_version, |
| 84 | + "commit": expected_commit, |
| 85 | + "platform": expected_platform, |
| 86 | + "key_id": PINNED_RELEASE_KEY_ID, |
| 87 | + } |
| 88 | + for field, expected in expected_fields.items(): |
| 89 | + if manifest.get(field) != expected: |
| 90 | + raise FeedCheckError( |
| 91 | + f"{source} signed manifest {field} is {manifest.get(field)!r}; " |
| 92 | + f"expected {expected!r}" |
| 93 | + ) |
| 94 | + |
| 95 | + artifacts = manifest.get("artifacts") |
| 96 | + if not isinstance(artifacts, list): |
| 97 | + raise FeedCheckError(f"{source} signed manifest has no artifact list") |
| 98 | + if not any( |
| 99 | + isinstance(artifact, dict) |
| 100 | + and artifact.get("name") == expected_artifact |
| 101 | + and artifact.get("role") == "installer" |
| 102 | + for artifact in artifacts |
| 103 | + ): |
| 104 | + raise FeedCheckError( |
| 105 | + f"{source} does not authorize installer {expected_artifact}" |
| 106 | + ) |
| 107 | + |
| 108 | + |
| 109 | +def verify_feed( |
| 110 | + *, |
| 111 | + base_url: str, |
| 112 | + expected_version: str, |
| 113 | + expected_commit: str, |
| 114 | + fetch_text: Callable[[str], str], |
| 115 | + require_url: Callable[[str], None], |
| 116 | +) -> None: |
| 117 | + base_url = base_url.rstrip("/") |
| 118 | + expected_artifacts = { |
| 119 | + "windows": f"qsdm-hive-{expected_version}-win-x64.exe", |
| 120 | + "linux": f"qsdm-hive-{expected_version}-linux-x86_64.AppImage", |
| 121 | + } |
| 122 | + manifest_names = {"windows": "latest.yml", "linux": "latest-linux.yml"} |
| 123 | + |
| 124 | + for platform in ("windows", "linux"): |
| 125 | + manifest_name = manifest_names[platform] |
| 126 | + manifest_url = f"{base_url}/{manifest_name}" |
| 127 | + updater_manifest = parse_updater_manifest( |
| 128 | + fetch_text(manifest_url), manifest_url |
| 129 | + ) |
| 130 | + if updater_manifest.version != expected_version: |
| 131 | + raise FeedCheckError( |
| 132 | + f"{manifest_url} advertises {updater_manifest.version}; " |
| 133 | + f"expected {expected_version}" |
| 134 | + ) |
| 135 | + expected_artifact = expected_artifacts[platform] |
| 136 | + if updater_manifest.path != expected_artifact: |
| 137 | + raise FeedCheckError( |
| 138 | + f"{manifest_url} points to {updater_manifest.path}; " |
| 139 | + f"expected {expected_artifact}" |
| 140 | + ) |
| 141 | + require_url(f"{base_url}/{expected_artifact}") |
| 142 | + |
| 143 | + envelope_url = f"{base_url}/qsdm-hive-release-{platform}.json" |
| 144 | + validate_release_envelope( |
| 145 | + fetch_text(envelope_url), |
| 146 | + source=envelope_url, |
| 147 | + expected_version=expected_version, |
| 148 | + expected_commit=expected_commit, |
| 149 | + expected_platform=platform, |
| 150 | + expected_artifact=expected_artifact, |
| 151 | + ) |
| 152 | + |
| 153 | + |
| 154 | +def _fetch_text(url: str) -> str: |
| 155 | + request = urllib.request.Request( |
| 156 | + url, headers={"User-Agent": "qsdm-hive-release-feed-check/1"} |
| 157 | + ) |
| 158 | + try: |
| 159 | + with urllib.request.urlopen( |
| 160 | + request, timeout=20, context=_certificate_context() |
| 161 | + ) as response: |
| 162 | + return response.read().decode("utf-8") |
| 163 | + except (urllib.error.URLError, TimeoutError, UnicodeDecodeError) as error: |
| 164 | + raise FeedCheckError(f"could not read {url}: {error}") from error |
| 165 | + |
| 166 | + |
| 167 | +def _require_url(url: str) -> None: |
| 168 | + request = urllib.request.Request( |
| 169 | + url, |
| 170 | + method="HEAD", |
| 171 | + headers={"User-Agent": "qsdm-hive-release-feed-check/1"}, |
| 172 | + ) |
| 173 | + try: |
| 174 | + with urllib.request.urlopen( |
| 175 | + request, timeout=20, context=_certificate_context() |
| 176 | + ) as response: |
| 177 | + if response.status != 200: |
| 178 | + raise FeedCheckError(f"{url} returned HTTP {response.status}") |
| 179 | + except (urllib.error.URLError, TimeoutError) as error: |
| 180 | + raise FeedCheckError(f"could not reach {url}: {error}") from error |
| 181 | + |
| 182 | + |
| 183 | +def _certificate_context() -> ssl.SSLContext: |
| 184 | + # Some supported Windows operator hosts use Python builds whose bundled |
| 185 | + # OpenSSL trust path is stale even though the Windows and Git trust stores |
| 186 | + # are current. Prefer certifi when the host already provides it; CI needs no |
| 187 | + # additional dependency and continues to use its operating-system bundle. |
| 188 | + try: |
| 189 | + import certifi |
| 190 | + except ImportError: |
| 191 | + return ssl.create_default_context() |
| 192 | + return ssl.create_default_context(cafile=certifi.where()) |
| 193 | + |
| 194 | + |
| 195 | +def main() -> int: |
| 196 | + parser = argparse.ArgumentParser(description=__doc__) |
| 197 | + parser.add_argument("--expected-version", required=True) |
| 198 | + parser.add_argument("--expected-commit", required=True) |
| 199 | + parser.add_argument("--base-url", default=DEFAULT_BASE_URL) |
| 200 | + args = parser.parse_args() |
| 201 | + |
| 202 | + if not SEMVER_RE.fullmatch(args.expected_version): |
| 203 | + parser.error("--expected-version must be MAJOR.MINOR.PATCH") |
| 204 | + if not COMMIT_RE.fullmatch(args.expected_commit): |
| 205 | + parser.error("--expected-commit must be a full lowercase Git commit") |
| 206 | + |
| 207 | + try: |
| 208 | + verify_feed( |
| 209 | + base_url=args.base_url, |
| 210 | + expected_version=args.expected_version, |
| 211 | + expected_commit=args.expected_commit, |
| 212 | + fetch_text=_fetch_text, |
| 213 | + require_url=_require_url, |
| 214 | + ) |
| 215 | + except FeedCheckError as error: |
| 216 | + print(f"QSDM Hive production feed check failed: {error}", file=sys.stderr) |
| 217 | + print( |
| 218 | + "Publish the locally signed Windows and Linux release with " |
| 219 | + "QSDM/deploy/scripts/publish_hive_dual_platform_release.sh, " |
| 220 | + "then rerun the release workflow.", |
| 221 | + file=sys.stderr, |
| 222 | + ) |
| 223 | + return 1 |
| 224 | + |
| 225 | + print( |
| 226 | + f"QSDM Hive production feed matches {args.expected_version} " |
| 227 | + f"at {args.expected_commit}." |
| 228 | + ) |
| 229 | + return 0 |
| 230 | + |
| 231 | + |
| 232 | +if __name__ == "__main__": |
| 233 | + raise SystemExit(main()) |
0 commit comments