245 lines
9.0 KiB
Python
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())
|