Files
hermes-relay/scripts/android_release_artifacts.py

245 lines
9.0 KiB
Python

#!/usr/bin/env python3
"""Package and verify immutable Android Play-preflight release artifacts."""
from __future__ import annotations
import argparse
import hashlib
import json
import re
import shutil
import sys
from pathlib import Path
from typing import Any
SCHEMA_VERSION = 1
MANIFEST_NAME = "play-preflight.json"
CHECKSUMS_NAME = "SHA256SUMS.txt"
GIT_OBJECT_PATTERN = re.compile(r"^[0-9a-f]{40}$")
EXPECTED_ROLES = {
"sideload-apk": "hermes-relay-{version}-sideload-release.apk",
"google-play-aab": "hermes-relay-{version}-googlePlay-release.aab",
"sideload-mapping": "mapping-sideloadRelease.txt",
"google-play-mapping": "mapping-googlePlayRelease.txt",
}
PUBLIC_ROLES = {"sideload-apk", "google-play-aab"}
class ArtifactError(ValueError):
"""Raised when a release artifact set violates its contract."""
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def safe_name(name: str) -> str:
candidate = Path(name)
if candidate.name != name or name in {"", ".", ".."}:
raise ArtifactError(f"unsafe artifact filename: {name!r}")
return name
def checksum_text(entries: list[dict[str, Any]]) -> str:
public = [entry for entry in entries if entry["role"] in PUBLIC_ROLES]
return "".join(
f"{entry['sha256']} {entry['name']}\n"
for entry in sorted(public, key=lambda item: item["name"])
)
def package_artifacts(args: argparse.Namespace) -> None:
if not GIT_OBJECT_PATTERN.fullmatch(args.commit):
raise ArtifactError(f"invalid commit id: {args.commit!r}")
if not GIT_OBJECT_PATTERN.fullmatch(args.tree):
raise ArtifactError(f"invalid tree id: {args.tree!r}")
if not str(args.version_code).isdigit():
raise ArtifactError(f"invalid versionCode: {args.version_code!r}")
output = args.output.resolve()
output.mkdir(parents=True, exist_ok=True)
if any(output.iterdir()):
raise ArtifactError(f"output directory must be empty: {output}")
sources = {
"sideload-apk": args.sideload_apk.resolve(),
"google-play-aab": args.google_play_aab.resolve(),
"sideload-mapping": args.sideload_mapping.resolve(),
"google-play-mapping": args.google_play_mapping.resolve(),
}
entries: list[dict[str, Any]] = []
for role, source in sources.items():
if not source.is_file():
raise ArtifactError(f"missing {role}: {source}")
destination_name = safe_name(EXPECTED_ROLES[role].format(version=args.version))
if role in PUBLIC_ROLES and source.name != destination_name:
raise ArtifactError(
f"{role} must be named {destination_name}, got {source.name}"
)
destination = output / destination_name
shutil.copy2(source, destination)
entries.append(
{
"role": role,
"name": destination.name,
"sha256": sha256(destination),
"size": destination.stat().st_size,
}
)
manifest = {
"schemaVersion": SCHEMA_VERSION,
"version": args.version,
"versionCode": str(args.version_code),
"commit": args.commit,
"tree": args.tree,
"track": "production",
"status": "draft",
"artifacts": sorted(entries, key=lambda item: item["role"]),
}
(output / MANIFEST_NAME).write_text(
json.dumps(manifest, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
(output / CHECKSUMS_NAME).write_text(
checksum_text(entries),
encoding="utf-8",
newline="\n",
)
print(json.dumps(manifest, sort_keys=True))
def verify_artifacts(args: argparse.Namespace) -> None:
if not GIT_OBJECT_PATTERN.fullmatch(args.tree):
raise ArtifactError(f"invalid expected tree id: {args.tree!r}")
if not str(args.version_code).isdigit():
raise ArtifactError(f"invalid expected versionCode: {args.version_code!r}")
directory = args.directory.resolve()
try:
manifest = json.loads((directory / MANIFEST_NAME).read_text(encoding="utf-8"))
except FileNotFoundError as exc:
raise ArtifactError(f"missing {MANIFEST_NAME}") from exc
except json.JSONDecodeError as exc:
raise ArtifactError(f"invalid {MANIFEST_NAME}: {exc}") from exc
if not isinstance(manifest, dict):
raise ArtifactError(f"{MANIFEST_NAME} must contain an object")
expected_metadata = {
"schemaVersion": SCHEMA_VERSION,
"version": args.version,
"versionCode": str(args.version_code),
"tree": args.tree,
"track": "production",
"status": "draft",
}
for key, expected in expected_metadata.items():
if str(manifest.get(key)) != str(expected):
raise ArtifactError(
f"manifest {key} mismatch: expected {expected!r}, got {manifest.get(key)!r}"
)
commit = manifest.get("commit")
if not isinstance(commit, str) or not GIT_OBJECT_PATTERN.fullmatch(commit):
raise ArtifactError(f"manifest commit is invalid: {commit!r}")
raw_entries = manifest.get("artifacts")
if not isinstance(raw_entries, list):
raise ArtifactError("manifest artifacts must be a list")
entries: dict[str, dict[str, Any]] = {}
for raw_entry in raw_entries:
if not isinstance(raw_entry, dict):
raise ArtifactError("every artifact entry must be an object")
role = raw_entry.get("role")
if role not in EXPECTED_ROLES:
raise ArtifactError(f"unexpected artifact role: {role!r}")
if role in entries:
raise ArtifactError(f"duplicate artifact role: {role}")
entries[str(role)] = raw_entry
if set(entries) != set(EXPECTED_ROLES):
missing = sorted(set(EXPECTED_ROLES) - set(entries))
raise ArtifactError("missing artifact roles: " + ", ".join(missing))
verified: list[dict[str, Any]] = []
for role, expected_pattern in EXPECTED_ROLES.items():
entry = entries[role]
name = safe_name(str(entry.get("name", "")))
expected_name = expected_pattern.format(version=args.version)
if name != expected_name:
raise ArtifactError(f"{role} must be named {expected_name}, got {name}")
path = directory / name
if not path.is_file():
raise ArtifactError(f"missing artifact file: {name}")
if sha256(path) != entry.get("sha256"):
raise ArtifactError(f"SHA-256 mismatch for {name}")
if path.stat().st_size != entry.get("size"):
raise ArtifactError(f"size mismatch for {name}")
verified.append(entry)
try:
actual_checksums = (directory / CHECKSUMS_NAME).read_text(encoding="utf-8")
except FileNotFoundError as exc:
raise ArtifactError(f"missing {CHECKSUMS_NAME}") from exc
if actual_checksums != checksum_text(verified):
raise ArtifactError(f"{CHECKSUMS_NAME} does not match the manifest")
allowed = {
MANIFEST_NAME,
CHECKSUMS_NAME,
*(entry["name"] for entry in verified),
}
actual = {path.name for path in directory.iterdir() if path.is_file()}
if actual != allowed:
raise ArtifactError(
"artifact set mismatch: expected "
+ ", ".join(sorted(allowed))
+ "; got "
+ ", ".join(sorted(actual))
)
if any(not path.is_file() for path in directory.iterdir()):
raise ArtifactError("artifact directory contains a non-file entry")
print(json.dumps(manifest, sort_keys=True))
def parser() -> argparse.ArgumentParser:
root = argparse.ArgumentParser(description=__doc__)
commands = root.add_subparsers(dest="command", required=True)
package = commands.add_parser("package")
package.add_argument("--version", required=True)
package.add_argument("--version-code", required=True)
package.add_argument("--commit", required=True)
package.add_argument("--tree", required=True)
package.add_argument("--sideload-apk", required=True, type=Path)
package.add_argument("--google-play-aab", required=True, type=Path)
package.add_argument("--sideload-mapping", required=True, type=Path)
package.add_argument("--google-play-mapping", required=True, type=Path)
package.add_argument("--output", required=True, type=Path)
package.set_defaults(func=package_artifacts)
verify = commands.add_parser("verify")
verify.add_argument("--version", required=True)
verify.add_argument("--version-code", required=True)
verify.add_argument("--tree", required=True)
verify.add_argument("--directory", required=True, type=Path)
verify.set_defaults(func=verify_artifacts)
return root
def main() -> int:
args = parser().parse_args()
try:
args.func(args)
except ArtifactError as exc:
print(f"artifact contract failed: {exc}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())