mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-03 20:27:56 +00:00
264 lines
13 KiB
Python
Executable file
264 lines
13 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""Build the current native host source with this installation's persistent key.
|
|
|
|
This is a native artifact installer, not a Git updater. The common source update
|
|
and reviewed self-edit paths choose the repository state before calling it.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import fcntl
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import runpy
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
|
|
|
|
PACKAGE = "ai.ouroboros.android"
|
|
KEY_ALIAS = "ouroboros-host"
|
|
|
|
|
|
def run(argv, *, binary=False, input=None):
|
|
return subprocess.run([str(value) for value in argv], check=True,
|
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
text=not binary, input=input).stdout
|
|
|
|
|
|
def file_hash(path):
|
|
digest = hashlib.sha256()
|
|
with Path(path).open("rb") as stream:
|
|
for block in iter(lambda: stream.read(1024 * 1024), b""):
|
|
digest.update(block)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def platform_state(repo, app):
|
|
module = runpy.run_path(str(repo / "android/provision/runtime.py"))
|
|
module["platform_current"].__globals__["APP"] = app
|
|
return module["platform_current"](repo)
|
|
|
|
|
|
def source_identity(repo, sdk):
|
|
paths = sorted(path for path in (repo / "android" / "host").rglob("*")
|
|
if path.is_file() and "__pycache__" not in path.parts)
|
|
paths.append(repo / "assets" / "icon_1024.png")
|
|
paths.append(repo / "VERSION")
|
|
if not (repo / "android" / "host" / "build.py").is_file():
|
|
raise RuntimeError("The selected repository has no Android host compiler.")
|
|
inputs = {str(path.relative_to(repo)): file_hash(path) for path in paths}
|
|
inputs["sdk_installation"] = file_hash(sdk / "installation.json")
|
|
module = runpy.run_path(str(repo / "android/provision/runtime.py"))
|
|
inputs["platform_desired"] = module["platform_inputs"](repo)
|
|
digest = hashlib.sha256(json.dumps(inputs, sort_keys=True).encode()).hexdigest()
|
|
return digest, inputs
|
|
|
|
|
|
def signer(apk, java, sdk):
|
|
output = run([java / "bin" / "java", "-jar", sdk / "build-tools" / "36.0.0"
|
|
/ "lib" / "apksigner.jar", "verify", "--print-certs", apk])
|
|
certs = re.findall(r"^Signer #\d+ certificate SHA-256 digest: ([0-9a-fA-F]+)$", output, re.M)
|
|
if len(certs) != 1:
|
|
raise RuntimeError("Could not establish the APK's single signing certificate.")
|
|
return certs[0].lower()
|
|
|
|
|
|
def installed_package(android_exec):
|
|
try:
|
|
output = run([android_exec, "pm", "path", PACKAGE])
|
|
except subprocess.CalledProcessError as error:
|
|
# Android pm reports a genuinely missing package as exit 1 with no
|
|
# output. Root/transport errors carry diagnostics or another code.
|
|
if error.returncode == 1 and not (error.stdout or "").strip() and not (error.stderr or "").strip():
|
|
return None
|
|
raise
|
|
paths = [line.removeprefix("package:") for line in output.splitlines()
|
|
if line.startswith("package:")]
|
|
if not paths:
|
|
raise RuntimeError("Android package lookup returned no package path.")
|
|
if len(paths) != 1:
|
|
raise RuntimeError("The installed host unexpectedly has multiple APK files.")
|
|
details = run([android_exec, "dumpsys", "package", PACKAGE])
|
|
match = re.search(r"\bversionCode=(\d+)\b", details)
|
|
if not match:
|
|
raise RuntimeError("The installed host versionCode could not be read.")
|
|
return {"path": paths[0], "version_code": int(match[1])}
|
|
|
|
|
|
def copy_installed(apk_path, destination, android_exec):
|
|
with destination.open("wb") as stream:
|
|
subprocess.run([str(android_exec), "cat", apk_path], check=True,
|
|
stdout=stream, stderr=subprocess.PIPE)
|
|
|
|
|
|
def write_receipt(path, receipt):
|
|
temporary = path.with_suffix(".tmp")
|
|
temporary.write_text(json.dumps(receipt, indent=2, sort_keys=True) + "\n")
|
|
os.replace(temporary, path)
|
|
|
|
|
|
def restore_host(android_exec):
|
|
run([android_exec, "am", "start-foreground-service", "-n", PACKAGE + "/.CoreService", "-a", "status"])
|
|
|
|
|
|
def sync_bootstrap(repo, app, android_exec, *, check=False):
|
|
"""Adopt the source-selected entry scripts without moving Git or the seed."""
|
|
actual = {}
|
|
for name in ("android-exec", "android-call", "build-apk", "update-host", "enter-linux", "core-control"):
|
|
source = repo / "android" / "bootstrap" / name
|
|
data = source.read_bytes()
|
|
expected = hashlib.sha256(data).hexdigest()
|
|
if name in {"enter-linux", "core-control"}:
|
|
destination = "/data/local/ouroboros-phone/bin/" + name
|
|
try:
|
|
old = run([android_exec, "cat", destination], binary=True)
|
|
except subprocess.CalledProcessError:
|
|
old = b""
|
|
if old != data and not check:
|
|
run([android_exec, "/system/bin/sh", "-c",
|
|
'cat > "$1.tmp" && chmod 755 "$1.tmp" && mv "$1.tmp" "$1"',
|
|
"ouroboros-bootstrap-install", destination], binary=True, input=data)
|
|
old = run([android_exec, "cat", destination], binary=True)
|
|
else:
|
|
destination = app / "tools" / name
|
|
old = destination.read_bytes() if destination.exists() else b""
|
|
if old != data and not check:
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
temporary = destination.with_suffix(".tmp")
|
|
temporary.write_bytes(data)
|
|
temporary.chmod(0o755)
|
|
os.replace(temporary, destination)
|
|
old = destination.read_bytes()
|
|
actual[name] = hashlib.sha256(old).hexdigest()
|
|
if actual[name] != expected:
|
|
return False, actual
|
|
return True, actual
|
|
|
|
|
|
def update(args):
|
|
app = args.app_root.resolve()
|
|
repo = args.repo.resolve() if args.repo else app / "repo"
|
|
sdk = args.sdk.resolve() if args.sdk else app / "android-sdk"
|
|
key = app / "signing" / "host.keystore"
|
|
password = app / "signing" / "host-password"
|
|
if not key.is_file() or not password.is_file():
|
|
raise RuntimeError("Personal signing key is missing. Restore its backup; updates never create a replacement.")
|
|
current_platform, desired_platform = platform_state(repo, app)
|
|
if not current_platform:
|
|
if args.check:
|
|
return {"status": "platform_update_required", "platform_inputs": desired_platform}
|
|
run([sys.executable, repo / "android/provision/runtime.py", "--ensure-platform", repo, "--app-root", app])
|
|
if not platform_state(repo, app)[0]:
|
|
raise RuntimeError("Prepared platform does not match the selected source recipe.")
|
|
pin = json.loads((sdk / "installation.json").read_text())
|
|
if pin.get("build_tools_version") != "36.0.0" or pin.get("platform") != "android-36":
|
|
raise RuntimeError("SDK installation does not match the host compiler's pinned platform.")
|
|
java = args.java_home.resolve() if args.java_home else Path(pin["java_home"])
|
|
android_exec = app / "tools" / "android-exec"
|
|
certificate = run([java / "bin" / "keytool", "-exportcert", "-alias", KEY_ALIAS,
|
|
"-keystore", key, "-storepass:file", password], binary=True)
|
|
expected_signer = hashlib.sha256(certificate).hexdigest()
|
|
input_digest, inputs = source_identity(repo, sdk)
|
|
state = app / "data" / "state" / "android_host.json"
|
|
previous = json.loads(state.read_text()) if state.exists() else {}
|
|
installed = installed_package(android_exec)
|
|
with tempfile.TemporaryDirectory(prefix="ouroboros-installed-apk-") as temporary:
|
|
installed_apk = Path(temporary) / "app.apk"
|
|
if installed:
|
|
copy_installed(installed["path"], installed_apk, android_exec)
|
|
installed["sha256"] = file_hash(installed_apk)
|
|
if signer(installed_apk, java, sdk) != expected_signer:
|
|
raise RuntimeError("Installed host uses a different signing key. Restore the matching personal key.")
|
|
current = bool(installed and previous.get("input_sha256") == input_digest
|
|
and previous.get("apk_sha256") == installed["sha256"]
|
|
and previous.get("version_code") == installed["version_code"])
|
|
if args.check or current:
|
|
bootstrap_ok, bootstrap = sync_bootstrap(repo, app, android_exec, check=args.check)
|
|
if current and not args.check:
|
|
if not bootstrap_ok:
|
|
raise RuntimeError("Bootstrap script readback did not match the selected source.")
|
|
restore_host(android_exec)
|
|
return {"status": "current" if current and bootstrap_ok else "build_required", "input_sha256": input_digest,
|
|
"installed": installed, "signer_sha256": expected_signer,
|
|
"bootstrap": bootstrap,
|
|
"source_commit": run(["git", "-C", repo, "rev-parse", "HEAD"]).strip()}
|
|
|
|
version_code = installed["version_code"] + 1 if installed else 1
|
|
build_root = app / "data" / "android-builds"
|
|
build_root.mkdir(parents=True, exist_ok=True)
|
|
output = Path(tempfile.mkdtemp(prefix=f"{version_code}-{input_digest[:12]}-", dir=build_root))
|
|
version_name = (repo / "VERSION").read_text().strip()
|
|
run([sys.executable, repo / "android" / "host" / "build.py", "--sdk", sdk,
|
|
"--java-home", java, "--out", output, "--keystore", key, "--key-alias", KEY_ALIAS,
|
|
"--keystore-pass-file", password, "--version-code", version_code,
|
|
"--version-name", version_name])
|
|
apk = output / "Ouroboros.apk"
|
|
if source_identity(repo, sdk)[0] != input_digest:
|
|
raise RuntimeError("Android source changed during compilation; the candidate was not installed.")
|
|
if signer(apk, java, sdk) != expected_signer:
|
|
raise RuntimeError("Built host does not have the installation's signing certificate.")
|
|
digest = file_hash(apk)
|
|
# Android's mount namespace cannot see this chroot path. pm consumes the
|
|
# APK through stdin, while the Linux parent survives host replacement.
|
|
with apk.open("rb") as stream:
|
|
result = subprocess.run([str(android_exec), "pm", "install", "-r", "-S", str(apk.stat().st_size), "--", "-"],
|
|
stdin=stream, capture_output=True, text=True, check=True)
|
|
if result.stdout.strip() != "Success":
|
|
raise RuntimeError("Android did not confirm APK replacement: " + result.stdout.strip())
|
|
observed = installed_package(android_exec)
|
|
if not observed or observed["version_code"] != version_code:
|
|
raise RuntimeError("Installed version does not match the built APK; inspect before retrying.")
|
|
copy_installed(observed["path"], installed_apk, android_exec)
|
|
if file_hash(installed_apk) != digest or signer(installed_apk, java, sdk) != expected_signer:
|
|
raise RuntimeError("Installed APK readback does not match the built artifact.")
|
|
receipt = {"schema_version": 1, "input_sha256": input_digest, "inputs": inputs,
|
|
"source_commit": run(["git", "-C", repo, "rev-parse", "HEAD"]).strip(),
|
|
"version_name": version_name, "version_code": version_code,
|
|
"apk_sha256": digest, "signer_sha256": expected_signer, "artifact": str(apk)}
|
|
state.parent.mkdir(parents=True, exist_ok=True)
|
|
write_receipt(state, receipt)
|
|
bootstrap_ok, bootstrap = sync_bootstrap(repo, app, android_exec)
|
|
if not bootstrap_ok:
|
|
raise RuntimeError("Bootstrap script readback did not match the selected source.")
|
|
receipt["bootstrap"] = bootstrap
|
|
write_receipt(state, receipt)
|
|
restore_host(android_exec)
|
|
return {"status": "installed", **receipt}
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--app-root", type=Path, default=Path("/opt/ouroboros"))
|
|
parser.add_argument("--repo", type=Path)
|
|
parser.add_argument("--sdk", type=Path)
|
|
parser.add_argument("--java-home", type=Path)
|
|
parser.add_argument("--check", action="store_true", help="Inspect only; do not build, install, or start the host.")
|
|
args = parser.parse_args()
|
|
try:
|
|
if args.check:
|
|
result = update(args)
|
|
else:
|
|
lock_path = args.app_root / "data" / "state" / "android_host.lock"
|
|
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
with lock_path.open("a") as lock:
|
|
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
result = update(args)
|
|
print(json.dumps(result, sort_keys=True))
|
|
return 0 if result["status"] in {"current", "installed"} else 1
|
|
except (OSError, ValueError, KeyError, RuntimeError, subprocess.SubprocessError) as error:
|
|
detail = {"status": "failed", "error": str(error)}
|
|
if isinstance(error, subprocess.CalledProcessError):
|
|
detail = {"status": "failed", "error": "Tool process failed", "returncode": error.returncode}
|
|
for name in ("stdout", "stderr"):
|
|
value = getattr(error, name, None)
|
|
detail[name] = (value.decode("utf-8", errors="replace") if isinstance(value, bytes) else value or "").strip()
|
|
print(json.dumps(detail))
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|