Implement verified household TV updates and release tooling
This commit is contained in:
parent
152dac97c0
commit
d171aaae9e
29 changed files with 2485 additions and 99 deletions
368
docker/tv_publish.py
Executable file
368
docker/tv_publish.py
Executable file
|
|
@ -0,0 +1,368 @@
|
|||
#!/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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue