1#!/usr/bin/env python32"""Fail-closed policy verifier for Portal public releases."""34from __future__ import annotations56import argparse7import hashlib8import json9import pathlib10import re11import subprocess12import sys13import tomllib14from collections.abc import Callable, Sequence15from typing import Any16from urllib.parse import quote171819REPOSITORY = "FelineStateMachine/goq"20PRODUCT = "portal-client"21BUNDLE_IDENTIFIER = "sh.goq.portal"22PLATFORM = "macos"23ARCHITECTURE = "arm64"24VERIFICATION = "developer-id+hardened-runtime+notarized+stapled+gatekeeper"25NON_RELEASE_FEATURES = (26 "demo-direct-node",27 "experimental-non-macos-pointer-capture",28)29TAG_PATTERN = re.compile(30 r"^v(?P<version>(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\."31 r"(?:0|[1-9][0-9]*)(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?)$"32)33SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")34RunCommand = Callable[[Sequence[str], pathlib.Path], str]353637class VerificationError(RuntimeError):38 """A release input violates the public release contract."""394041def require(condition: bool, message: str) -> None:42 if not condition:43 raise VerificationError(message)444546def run_command(command: Sequence[str], cwd: pathlib.Path) -> str:47 result = subprocess.run(48 command,49 cwd=cwd,50 check=False,51 stdout=subprocess.PIPE,52 stderr=subprocess.PIPE,53 text=True,54 )55 if result.returncode != 0:56 detail = result.stderr.strip() or result.stdout.strip() or "command failed"57 raise VerificationError(f"{command[0]} verification failed: {detail}")58 return result.stdout.strip()596061def release_version(release_tag: str) -> str:62 match = TAG_PATTERN.fullmatch(release_tag)63 require(match is not None, "release tag must be an exact vVERSION tag")64 return match.group("version")656667def expected_asset_names(release_tag: str) -> tuple[str, str, str]:68 version = release_version(release_tag)69 stem = f"Portal-{version}-{ARCHITECTURE}"70 return f"{stem}.dmg", f"{stem}.dmg.sha256", f"{stem}.json"717273def read_toml(path: pathlib.Path) -> dict[str, Any]:74 require(path.is_file() and not path.is_symlink(), f"required TOML file is missing: {path}")75 try:76 return tomllib.loads(path.read_text(encoding="utf-8"))77 except (OSError, tomllib.TOMLDecodeError) as error:78 raise VerificationError(f"cannot read TOML file {path}: {error}") from error798081def workspace_package_versions(repo_dir: pathlib.Path) -> dict[str, str]:82 workspace = read_toml(repo_dir / "Cargo.toml")83 members = workspace.get("workspace", {}).get("members")84 require(isinstance(members, list) and members, "Cargo workspace members are missing")85 versions: dict[str, str] = {}86 for member_pattern in members:87 require(isinstance(member_pattern, str), "Cargo workspace member must be a path")88 matches = sorted(repo_dir.glob(member_pattern))89 require(matches, f"Cargo workspace member does not resolve: {member_pattern}")90 for member in matches:91 manifest = read_toml(member / "Cargo.toml")92 package = manifest.get("package", {})93 name = package.get("name")94 version = package.get("version")95 require(isinstance(name, str) and name, f"package name is missing: {member}")96 require(isinstance(version, str) and version, f"package version is missing: {member}")97 require(name not in versions, f"duplicate workspace package name: {name}")98 versions[name] = version99 return versions100101102def validate_source(103 repo_dir: pathlib.Path,104 release_tag: str,105 runner: RunCommand = run_command,106) -> dict[str, Any]:107 version = release_version(release_tag)108 repo_dir = repo_dir.resolve()109 require((repo_dir / ".git").exists(), "repository does not contain .git")110 status = runner(111 ["git", "status", "--porcelain=v1", "--untracked-files=all"], repo_dir112 )113 require(status == "", "release worktree must be clean")114 head = runner(["git", "rev-parse", "--verify", "HEAD"], repo_dir)115 tagged_commit = runner(116 ["git", "rev-list", "-n", "1", f"refs/tags/{release_tag}"], repo_dir117 )118 require(tagged_commit == head, f"release tag {release_tag} must resolve exactly to HEAD")119120 versions = workspace_package_versions(repo_dir)121 mismatched = sorted(name for name, value in versions.items() if value != version)122 require(not mismatched, f"workspace package versions do not match {version}: {', '.join(mismatched)}")123124 tauri_path = repo_dir / "src-tauri" / "tauri.conf.json"125 try:126 tauri = json.loads(tauri_path.read_text(encoding="utf-8"))127 except (OSError, json.JSONDecodeError) as error:128 raise VerificationError(f"cannot read Tauri configuration: {error}") from error129 require(tauri.get("productName") == "Portal", "unexpected Tauri product name")130 require(tauri.get("identifier") == BUNDLE_IDENTIFIER, "unexpected Tauri bundle identifier")131 require(tauri.get("version") == version, "Tauri version does not match release tag")132133 portal_manifest = read_toml(repo_dir / "src-tauri" / "Cargo.toml")134 features = portal_manifest.get("features", {})135 default_features = features.get("default", [])136 require(isinstance(default_features, list), "Portal default features must be a list")137 for feature in NON_RELEASE_FEATURES:138 require(feature in features, f"Portal {feature} feature declaration is missing")139 require(feature not in default_features, f"{feature} must not be enabled by default")140 return {141 "release_tag": release_tag,142 "version": version,143 "git_commit": head,144 "workspace_packages": versions,145 "platform": PLATFORM,146 "architecture": ARCHITECTURE,147 "demo_direct_node": False,148 }149150151def sha256_file(path: pathlib.Path) -> str:152 digest = hashlib.sha256()153 with path.open("rb") as handle:154 for chunk in iter(lambda: handle.read(1024 * 1024), b""):155 digest.update(chunk)156 return digest.hexdigest()157158159def expected_release_manifest(160 release_tag: str, git_commit: str, digest: str161) -> dict[str, Any]:162 asset, checksum_asset, _ = expected_asset_names(release_tag)163 return {164 "architecture": ARCHITECTURE,165 "architectures": [ARCHITECTURE],166 "asset": asset,167 "bundle_identifier": BUNDLE_IDENTIFIER,168 "checksum_asset": checksum_asset,169 "demo_direct_node": False,170 "developer_id_signed": True,171 "format": 1,172 "gatekeeper_verified": True,173 "git_commit": git_commit,174 "hardened_runtime": True,175 "notarized": True,176 "platform": PLATFORM,177 "product": PRODUCT,178 "release_tag": release_tag,179 "sha256": digest,180 "stapled": True,181 "version": release_version(release_tag),182 }183184185def validate_assets(186 repo_dir: pathlib.Path,187 release_tag: str,188 asset_dir: pathlib.Path,189 runner: RunCommand = run_command,190) -> dict[str, Any]:191 source = validate_source(repo_dir, release_tag, runner)192 asset_dir = asset_dir.resolve()193 require(asset_dir.is_dir() and not asset_dir.is_symlink(), "asset directory is missing or unsafe")194 asset, checksum_asset, manifest_asset = expected_asset_names(release_tag)195 expected_names = {asset, checksum_asset, manifest_asset}196 entries = list(asset_dir.iterdir())197 require({entry.name for entry in entries} == expected_names, "release directory must contain the exact Portal asset set")198 require(199 all(entry.is_file() and not entry.is_symlink() for entry in entries),200 "release assets must be regular files",201 )202 digest = sha256_file(asset_dir / asset)203 require(SHA256_PATTERN.fullmatch(digest) is not None, "invalid DMG SHA-256")204 checksum = (asset_dir / checksum_asset).read_text(encoding="utf-8")205 require(checksum == f"{digest} {asset}\n", "checksum file does not exactly match the DMG")206 try:207 manifest = json.loads((asset_dir / manifest_asset).read_text(encoding="utf-8"))208 except (OSError, json.JSONDecodeError) as error:209 raise VerificationError(f"cannot read Portal release manifest: {error}") from error210 expected_manifest = expected_release_manifest(release_tag, source["git_commit"], digest)211 require(manifest == expected_manifest, "Portal release manifest does not match the verified source and DMG")212 return expected_manifest213214215def validate_release_asset_names(release_tag: str, names: Any) -> list[str]:216 require(isinstance(names, list), "GitHub asset names must be a JSON array")217 require(all(isinstance(name, str) for name in names), "GitHub asset names must be strings")218 expected = sorted(expected_asset_names(release_tag))219 require(sorted(names) == expected, "GitHub Release must contain the exact Portal asset set")220 return expected221222223def validate_website_manifest(path: pathlib.Path) -> dict[str, Any]:224 try:225 manifest = json.loads(path.read_text(encoding="utf-8"))226 except (OSError, json.JSONDecodeError) as error:227 raise VerificationError(f"cannot read website Portal manifest: {error}") from error228 require(229 set(manifest) == {"builds", "format", "product"},230 "website Portal manifest has unexpected top-level fields",231 )232 require(manifest.get("format") == 1, "website Portal manifest format must be 1")233 require(manifest.get("product") == PRODUCT, "website Portal manifest product is invalid")234 builds = manifest.get("builds")235 require(236 isinstance(builds, dict) and set(builds) == {"macos-arm64"},237 "website manifest may describe only macOS arm64",238 )239 build = builds["macos-arm64"]240 require(isinstance(build, dict), "website macOS arm64 entry must be an object")241 available = build.get("available")242 require(isinstance(available, bool), "website availability must be boolean")243 if not available:244 require(set(build) == {"available", "reason"}, "unavailable website entry must not contain release links")245 require(isinstance(build.get("reason"), str) and build["reason"].strip(), "unavailable website entry requires a reason")246 return manifest247248 required_fields = {249 "architecture",250 "asset",251 "available",252 "checksum_asset",253 "checksum_url",254 "download_url",255 "manifest_asset",256 "manifest_url",257 "platform",258 "release_tag",259 "release_url",260 "sha256",261 "verification",262 "version",263 }264 require(set(build) == required_fields, "available website entry fields are incomplete or unexpected")265 tag = build.get("release_tag")266 require(isinstance(tag, str), "website release tag is missing")267 version = release_version(tag)268 asset, checksum_asset, manifest_asset = expected_asset_names(tag)269 require(build.get("version") == version, "website version does not match release tag")270 require(build.get("platform") == PLATFORM, "website platform must be macos")271 require(build.get("architecture") == ARCHITECTURE, "website architecture must be arm64")272 require(build.get("asset") == asset, "website DMG asset name is invalid")273 require(build.get("checksum_asset") == checksum_asset, "website checksum asset name is invalid")274 require(build.get("manifest_asset") == manifest_asset, "website release manifest asset name is invalid")275 require(276 isinstance(build.get("sha256"), str) and SHA256_PATTERN.fullmatch(build["sha256"]) is not None,277 "website SHA-256 is invalid",278 )279 encoded_tag = quote(tag, safe="")280 release_base = f"https://github.com/{REPOSITORY}/releases"281 download_base = f"{release_base}/download/{encoded_tag}"282 require(build.get("download_url") == f"{download_base}/{asset}", "website download URL is not exact")283 require(build.get("checksum_url") == f"{download_base}/{checksum_asset}", "website checksum URL is not exact")284 require(build.get("manifest_url") == f"{download_base}/{manifest_asset}", "website manifest URL is not exact")285 require(build.get("release_url") == f"{release_base}/tag/{encoded_tag}", "website release URL is not exact")286 require(build.get("verification") == VERIFICATION, "website verification claim is invalid")287 return manifest288289290def load_json(path: pathlib.Path) -> Any:291 try:292 return json.loads(path.read_text(encoding="utf-8"))293 except (OSError, json.JSONDecodeError) as error:294 raise VerificationError(f"cannot read JSON file {path}: {error}") from error295296297def parser() -> argparse.ArgumentParser:298 argument_parser = argparse.ArgumentParser(description=__doc__)299 subparsers = argument_parser.add_subparsers(dest="command", required=True)300 source_parser = subparsers.add_parser("source")301 source_parser.add_argument("--repo-dir", type=pathlib.Path, required=True)302 source_parser.add_argument("--release-tag", required=True)303 asset_parser = subparsers.add_parser("assets")304 asset_parser.add_argument("--repo-dir", type=pathlib.Path, required=True)305 asset_parser.add_argument("--release-tag", required=True)306 asset_parser.add_argument("--asset-dir", type=pathlib.Path, required=True)307 remote_parser = subparsers.add_parser("release-assets")308 remote_parser.add_argument("--release-tag", required=True)309 remote_parser.add_argument("--names-json", type=pathlib.Path, required=True)310 website_parser = subparsers.add_parser("website")311 website_parser.add_argument("--manifest", type=pathlib.Path, required=True)312 return argument_parser313314315def main(argv: Sequence[str] | None = None) -> int:316 args = parser().parse_args(argv)317 try:318 if args.command == "source":319 result = validate_source(args.repo_dir, args.release_tag)320 elif args.command == "assets":321 result = validate_assets(args.repo_dir, args.release_tag, args.asset_dir)322 elif args.command == "release-assets":323 result = validate_release_asset_names(args.release_tag, load_json(args.names_json))324 else:325 result = validate_website_manifest(args.manifest)326 except VerificationError as error:327 print(f"Portal release verification failed: {error}", file=sys.stderr)328 return 1329 print(json.dumps(result, indent=2, sort_keys=True))330 return 0331332333if __name__ == "__main__":334 raise SystemExit(main())