368 lines
14 KiB
Python
Executable file
368 lines
14 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 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):
|
|
raise RuntimeError(
|
|
f"Forgejo {method} failed: HTTP {error.code}"
|
|
) 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 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 = 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
|
|
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()
|