wolfskin/docker/tv_publish.py

413 lines
15 KiB
Python
Executable file

#!/usr/bin/env python3
"""Upload a verified draft; promote only with explicit --promote owner approval."""
import argparse
import base64
import datetime
import hashlib
import json
import os
import re
import subprocess
import urllib.error
import urllib.parse
import urllib.request
import uuid
from collections.abc import Iterable
from pathlib import Path
from typing import Any
SERVER = "https://git.justinvisser.org"
REPOSITORY = "justinzeus/wolfskin-tv-updates"
REQUIRED = {
"Wolfskin-armeabi-v7a.apk",
"Wolfskin-arm64-v8a.apk",
"Wolfskin-x86_64.apk",
"Wolfskin.apk",
"wolfskin-update.json",
"RELEASE-NOTES.md",
"SHA256SUMS",
}
class ForgeHttpError(RuntimeError):
"""Retain HTTP status without exposing response bodies or authorization."""
def __init__(self, method: str, status: int) -> None:
super().__init__(f"Forgejo {method} failed: HTTP {status}")
self.status = status
class NoRedirect(urllib.request.HTTPRedirectHandler):
"""Validate every redirect before forwarding distribution-tool credentials."""
def redirect_request(
self, req: Any, fp: Any, code: int, msg: str, headers: Any, newurl: str
) -> None:
return None
class Forge:
"""Credential-scoped release API; credentials exist only in process memory."""
def __init__(
self, server: str, authorization: str, test_server: bool = False
) -> None:
self.server = server.rstrip("/")
self.origin = urllib.parse.urlsplit(self.server)
self.authorization = authorization
self.test_server = test_server
self.opener = urllib.request.build_opener(NoRedirect)
self.api = self.server + "/api/v1/repos/" + REPOSITORY
def request(
self,
method: str,
url: str,
data: Any = None,
content_type: str | None = None,
length: int | None = None,
authenticated: bool = True,
) -> Any:
"""Reject cross-origin credential forwarding and non-HTTPS production URLs."""
for _ in range(6):
parsed = urllib.parse.urlsplit(url)
local = self.test_server and parsed.hostname in (
"localhost",
"127.0.0.1",
"::1",
)
if (
parsed.username
or parsed.password
or (parsed.scheme != "https" and not local)
):
raise ValueError(
"Distribution requests require credential-free HTTPS URLs"
)
if authenticated and (parsed.scheme, parsed.netloc) != (
self.origin.scheme,
self.origin.netloc,
):
raise ValueError(
"Refusing to send release credentials to another origin"
)
headers = {"Accept": "application/json"}
if authenticated:
headers["Authorization"] = self.authorization
if content_type:
headers["Content-Type"] = content_type
if length is not None:
headers["Content-Length"] = str(length)
request = urllib.request.Request(
url, data=data, method=method, headers=headers
)
try:
return self.opener.open(request, timeout=60)
except urllib.error.HTTPError as error:
if method != "GET" or error.code not in (301, 302, 303, 307, 308):
status = error.code
error.close()
raise ForgeHttpError(method, status) from None
location = error.headers.get("Location")
error.close()
if not location:
raise ValueError("Forgejo redirect has no location")
url = urllib.parse.urljoin(url, location)
raise ValueError("Too many Forgejo redirects")
def json(
self,
method: str,
path: str,
value: dict[str, Any] | None = None,
authenticated: bool = True,
) -> Any:
"""Read or mutate release metadata without exposing HTTP authorization."""
data = None if value is None else json.dumps(value).encode()
with self.request(
method,
self.api + path,
data,
"application/json",
authenticated=authenticated,
) as response:
return json.load(response)
def upload(self, release_id: int, file: Path) -> dict[str, Any]:
"""Stream multipart APK bytes instead of copying a universal APK into memory."""
boundary = uuid.uuid4().hex
prefix = (
f'--{boundary}\r\nContent-Disposition: form-data; name="attachment"; filename="{file.name}"\r\n'
"Content-Type: application/octet-stream\r\n\r\n"
).encode()
suffix = f"\r\n--{boundary}--\r\n".encode()
def chunks() -> Iterable[bytes]:
yield prefix
with file.open("rb") as stream:
while chunk := stream.read(1024 * 1024):
yield chunk
yield suffix
url = f"{self.api}/releases/{release_id}/assets?name={urllib.parse.quote(file.name)}"
with self.request(
"POST",
url,
chunks(),
f"multipart/form-data; boundary={boundary}",
len(prefix) + file.stat().st_size + len(suffix),
) as response:
return json.load(response)
def verify(self, url: str, expected: str, authenticated: bool) -> None:
"""Verify bytes fetched from Forgejo, not only its declared asset size."""
with self.request("GET", url, authenticated=authenticated) as response:
actual = hashlib.file_digest(response, "sha256").hexdigest()
if actual != expected:
raise ValueError("Uploaded artifact checksum mismatch")
def require_newer_code(self, code: int) -> None:
"""Read the public channel anonymously and fail closed on an uncertain floor."""
try:
release = self.json("GET", "/releases/latest", authenticated=False)
except ForgeHttpError as error:
if error.status != 404:
raise
releases = self.json("GET", "/releases?limit=1", authenticated=False)
if releases == []:
return
raise ValueError("Latest is missing but the channel is not empty") from None
assets = [
asset
for asset in release["assets"]
if asset["name"] == "wolfskin-update.json"
]
if len(assets) != 1:
raise ValueError("Latest release must have exactly one update manifest")
with self.request(
"GET", assets[0]["browser_download_url"], authenticated=False
) as response:
manifest = json.load(response)
latest_code = manifest["versionCode"]
if (
manifest["schemaVersion"] != 1
or manifest["packageId"] != "org.justinvisser.wolfskin.tv.opt"
or type(latest_code) is not int
or not 1 <= latest_code <= 2100000000
):
raise ValueError("Invalid live household version identity")
if code <= latest_code:
raise ValueError(
f"Bundle versionCode must exceed live channel code {latest_code}"
)
def credential(server: str) -> str:
"""Read git's existing credential helper without secrets in argv, stdout or disk."""
parsed = urllib.parse.urlsplit(server)
environment = {
name: value
for name, value in os.environ.items()
if not name.startswith("GIT_TRACE")
and name not in {"GIT_CURL_VERBOSE", "SHELLOPTS", "BASHOPTS", "BASH_ENV", "ENV"}
}
environment["GIT_TERMINAL_PROMPT"] = "0"
result = subprocess.run(
["git", "credential", "fill"],
input=f"protocol={parsed.scheme}\nhost={parsed.netloc}\n\n",
text=True,
capture_output=True,
check=False,
env=environment,
)
if result.returncode:
raise RuntimeError("Git credential helper failed; no release created")
fields = dict(
line.split("=", 1) for line in result.stdout.splitlines() if "=" in line
)
if not fields.get("password"):
raise ValueError("Git credential helper returned no release credential")
encoded = base64.b64encode(
f"{fields.get('username', '')}:{fields['password']}".encode()
).decode()
return "Basic " + encoded
def validate_bundle(directory: Path) -> tuple[dict[str, Any], dict[str, str]]:
"""Require every public asset and verify the local final-byte checksum set."""
for name in REQUIRED:
if not (directory / name).is_file():
raise ValueError(f"Required release asset missing: {name}")
hashes: dict[str, str] = {}
for line in (directory / "SHA256SUMS").read_text().splitlines():
match = re.fullmatch(r"([0-9a-f]{64}) ([A-Za-z0-9_.-]+)", line)
if match is None or match[2] in hashes:
raise ValueError("Invalid or duplicate SHA256SUMS entry")
hashes[match[2]] = match[1]
if set(hashes) != REQUIRED - {"SHA256SUMS"}:
raise ValueError("Checksum set must cover exactly the public artifacts")
for name, expected in hashes.items():
with (directory / name).open("rb") as stream:
if hashlib.file_digest(stream, "sha256").hexdigest() != expected:
raise ValueError(f"Local checksum mismatch: {name}")
hashes["SHA256SUMS"] = hashlib.sha256(
(directory / "SHA256SUMS").read_bytes()
).hexdigest()
manifest = json.loads((directory / "wolfskin-update.json").read_text())
if (
manifest["schemaVersion"] != 1
or manifest["packageId"] != "org.justinvisser.wolfskin.tv.opt"
):
raise ValueError("Wrong household manifest identity")
if (
manifest["signerSha256"]
!= "182bac72e292b9ea4d3d4e6383f92eb2161fa28ba5afcff0337cb7d5a9d5341d"
):
raise ValueError("Wrong household signing authority")
version = manifest["versionName"]
code = manifest["versionCode"]
if (
not re.fullmatch(r"\d+\.\d+\.\d+", version)
or type(code) is not int
or not 1 <= code <= 2100000000
):
raise ValueError("Invalid household version identity")
if (
not re.fullmatch(r"[0-9a-f]{40}", manifest["sourceCommit"])
or manifest["sourceTag"] != f"pwolfskin-opt-v{version}"
):
raise ValueError("Invalid immutable source identity")
published = datetime.datetime.fromisoformat(
manifest["publishedAt"].replace("Z", "+00:00")
)
if published.utcoffset() is None:
raise ValueError("Publication time must include a timezone")
expected_abis = {"armeabi-v7a", "arm64-v8a", "x86_64", "universal"}
if (
len(manifest["assets"]) != 4
or {asset["abi"] for asset in manifest["assets"]} != expected_abis
):
raise ValueError("Manifest must describe all four standalone APKs")
for asset in manifest["assets"]:
expected_name = (
"Wolfskin.apk"
if asset["abi"] == "universal"
else f"Wolfskin-{asset['abi']}.apk"
)
if asset["name"] != expected_name or asset["minSdk"] < 23:
raise ValueError("Invalid APK asset identity or minimum SDK")
path = directory / asset["name"]
if (
hashes.get(path.name) != asset["sha256"]
or path.stat().st_size != asset["bytes"]
):
raise ValueError("Manifest does not match final APK bytes")
return manifest, hashes
def publish(
directory: Path,
server: str,
promote: bool,
dry_run: bool,
test_server: bool = False,
draft_id: int | None = None,
) -> None:
"""Keep incomplete uploads draft; withdraw a failed promoted release from latest."""
manifest, hashes = validate_bundle(directory)
version = manifest["versionName"]
tag = f"wolfskin-opt-v{version}"
if dry_run:
print(
f"Dry run: draft {REPOSITORY} {tag}; upload and verify {len(hashes)} assets; promote={promote}; no requests or credentials"
)
return
Forge(server, "", test_server).require_newer_code(manifest["versionCode"])
forge = Forge(server, credential(server), test_server)
if draft_id is None:
release = forge.json(
"POST",
"/releases",
{
"name": f"v{version}",
"tag_name": tag,
"body": (directory / "RELEASE-NOTES.md").read_text(),
"draft": True,
"prerelease": False,
},
)
else:
release = forge.json("GET", f"/releases/{draft_id}")
if not release["draft"] or release["tag_name"] != tag:
raise ValueError("Resume target must be the matching draft release")
release_id = release["id"]
existing = {asset["name"]: asset for asset in release.get("assets", [])}
print(
f"Verifying draft release {release_id}; it stays draft on any verification failure"
)
uploaded: dict[str, str] = {}
for name, expected in hashes.items():
asset = existing.get(name) or forge.upload(release_id, directory / name)
url = asset["browser_download_url"]
forge.verify(url, expected, authenticated=True)
uploaded[name] = url
if not promote:
print(
"Draft upload verified. Publication requires owner approval and explicit --promote."
)
return
forge.require_newer_code(manifest["versionCode"])
try:
forge.json("PATCH", f"/releases/{release_id}", {"draft": False})
latest = forge.json("GET", "/releases/latest", authenticated=False)
if latest["id"] != release_id or latest["tag_name"] != tag:
raise ValueError("Anonymous latest does not identify the promoted release")
for name, url in uploaded.items():
forge.verify(url, hashes[name], authenticated=False)
except (ValueError, RuntimeError, OSError, KeyError, TypeError):
try:
forge.json("PATCH", f"/releases/{release_id}", {"draft": True})
except (ValueError, RuntimeError, OSError):
raise RuntimeError(
"Post-publication verification AND withdrawal failed; release may remain public"
) from None
raise RuntimeError(
"Post-publication verification failed; release withdrawn to draft"
) from None
print(f"Published and anonymously verified {tag}")
def main() -> None:
"""Expose a deliberately separate publication command."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--directory", required=True, type=Path)
parser.add_argument("--promote", action="store_true")
parser.add_argument("--dry-run", action="store_true")
parser.add_argument(
"--draft-id", type=int, help="Resume and reverify an existing matching draft"
)
parser.add_argument(
"--test-server", help="Local fake Forgejo only; HTTP loopback permitted"
)
args = parser.parse_args()
if args.test_server:
parsed = urllib.parse.urlsplit(args.test_server)
if parsed.scheme != "http" or parsed.hostname not in (
"localhost",
"127.0.0.1",
"::1",
):
parser.error("--test-server is restricted to an HTTP loopback fixture")
publish(
args.directory.resolve(),
args.test_server or SERVER,
args.promote,
args.dry_run,
bool(args.test_server),
args.draft_id,
)
if __name__ == "__main__":
main()