Files
archy/apps/qortal/prepare-runtime-jar.py

195 lines
7.4 KiB
Python

#!/usr/bin/env python3
"""Prepare or roll back the Qortal empty-cache runtime overlay.
The official JAR is never overwritten. A package-owned contract binds its exact
bytes, the independently reviewed replacement class, and the still-separate
upstream provenance disposition. Any drift refuses before Core can launch.
"""
import hashlib
import io
import json
import os
from pathlib import Path
import stat
import sys
import zipfile
SCHEMA = "archipelago.qortal.empty-cache-overlay.v1"
VERSION = "6.1.9"
TAG = "v6.1.9"
UPSTREAM_COMMIT = "108bf191d42d710ec617f535af30cfd82fc03c87"
TARGET = "org/qortal/controller/arbitrary/ArbitraryDataCacheManager.class"
PATCH_SHA256 = "7fccb0afc2113abe19af0e1fa2e70cb7a61fbd8c410ad372e4e9d28ef31ae0c8"
MAX_JAR_BYTES = 256 * 1024 * 1024
MAX_CLASS_BYTES = 1024 * 1024
MAX_CONTRACT_BYTES = 16 * 1024
class Refusal(ValueError):
pass
def sha(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def unique_object(pairs):
result = {}
for key, value in pairs:
if key in result:
raise Refusal("duplicate JSON property")
result[key] = value
return result
def read_regular(path: Path, limit: int) -> bytes:
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
with os.fdopen(fd, "rb") as stream:
if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
raise Refusal("input must be a regular file")
data = stream.read(limit + 1)
if len(data) > limit:
raise Refusal("input exceeds package bound")
return data
def parse_json(raw: bytes):
try:
return json.loads(raw.decode("utf-8"), object_pairs_hook=unique_object)
except (UnicodeError, json.JSONDecodeError, RecursionError) as exc:
raise Refusal("invalid strict JSON") from exc
def load_json(path: Path, limit: int):
return parse_json(read_regular(path, limit))
def validate_contract(doc):
expected_keys = {
"schema", "qortal_version", "upstream_tag", "upstream_commit",
"original_jar_sha256", "target_entry", "replacement_class_sha256",
"source_patch_sha256", "behavior_review_status",
"upstream_provenance_status", "provenance_receipt_sha256",
}
if type(doc) is not dict or set(doc) != expected_keys:
raise Refusal("overlay contract schema drift")
fixed = {
"schema": SCHEMA,
"qortal_version": VERSION,
"upstream_tag": TAG,
"upstream_commit": UPSTREAM_COMMIT,
"target_entry": TARGET,
"source_patch_sha256": PATCH_SHA256,
"behavior_review_status": "accepted",
"upstream_provenance_status": "accepted",
}
for key, value in fixed.items():
if doc.get(key) != value:
raise Refusal("overlay contract is not accepted for this package")
for key in ("original_jar_sha256", "replacement_class_sha256", "provenance_receipt_sha256"):
value = doc.get(key)
if type(value) is not str or len(value) != 64 or any(c not in "0123456789abcdef" for c in value):
raise Refusal("overlay contract digest is invalid")
return doc
def write_exclusive(path: Path, data: bytes, mode: int = 0o600):
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, mode)
try:
with os.fdopen(fd, "wb") as stream:
stream.write(data)
stream.flush()
os.fsync(stream.fileno())
except BaseException:
try:
path.unlink()
except FileNotFoundError:
pass
raise
def build_runtime(original: bytes, replacement: bytes) -> bytes:
if replacement[:4] != b"\xca\xfe\xba\xbe":
raise Refusal("replacement is not a Java class")
source = io.BytesIO(original)
output = io.BytesIO()
with zipfile.ZipFile(source) as src:
names = src.namelist()
if len(set(names)) != len(names) or names.count(TARGET) != 1:
raise Refusal("official JAR has ambiguous entries")
if any(name.upper().startswith("META-INF/") and
name.upper().endswith((".SF", ".RSA", ".DSA", ".EC")) for name in names):
raise Refusal("refuse to invalidate a signed JAR")
with zipfile.ZipFile(output, "w") as dst:
for item in src.infolist():
dst.writestr(item, replacement if item.filename == TARGET else src.read(item.filename))
runtime = output.getvalue()
with zipfile.ZipFile(io.BytesIO(original)) as src, zipfile.ZipFile(io.BytesIO(runtime)) as dst:
changed = [name for name in names if src.read(name) != dst.read(name)]
if changed != [TARGET]:
raise Refusal("runtime overlay changed unexpected entries")
return runtime
def prepare(original_path: Path, class_path: Path, contract_path: Path,
output_path: Path, receipt_path: Path):
contract_raw = read_regular(contract_path, MAX_CONTRACT_BYTES)
contract = validate_contract(parse_json(contract_raw))
original = read_regular(original_path, MAX_JAR_BYTES)
replacement = read_regular(class_path, MAX_CLASS_BYTES)
if sha(original) != contract["original_jar_sha256"]:
raise Refusal("official JAR bytes differ from package contract")
if sha(replacement) != contract["replacement_class_sha256"]:
raise Refusal("replacement class bytes differ from package contract")
runtime = build_runtime(original, replacement)
receipt = {
"schema": SCHEMA,
"original_path": str(original_path),
"original_jar_sha256": sha(original),
"runtime_path": str(output_path),
"runtime_jar_sha256": sha(runtime),
"contract_sha256": sha(contract_raw),
}
write_exclusive(output_path, runtime)
try:
write_exclusive(receipt_path, (json.dumps(receipt, sort_keys=True) + "\n").encode("ascii"))
except BaseException:
output_path.unlink()
raise
def rollback(original_path: Path, output_path: Path, receipt_path: Path):
receipt = load_json(receipt_path, MAX_CONTRACT_BYTES)
expected_keys = {"schema", "original_path", "original_jar_sha256",
"runtime_path", "runtime_jar_sha256", "contract_sha256"}
if type(receipt) is not dict or set(receipt) != expected_keys or receipt.get("schema") != SCHEMA:
raise Refusal("rollback receipt schema drift")
if receipt.get("original_path") != str(original_path) or receipt.get("runtime_path") != str(output_path):
raise Refusal("rollback path drift")
original = read_regular(original_path, MAX_JAR_BYTES)
runtime = read_regular(output_path, MAX_JAR_BYTES)
if sha(original) != receipt.get("original_jar_sha256") or sha(runtime) != receipt.get("runtime_jar_sha256"):
raise Refusal("rollback byte drift")
output_path.unlink()
receipt_path.unlink()
def main():
try:
if len(sys.argv) == 7 and sys.argv[1] == "prepare":
prepare(*(Path(value) for value in sys.argv[2:]))
elif len(sys.argv) == 5 and sys.argv[1] == "rollback":
rollback(*(Path(value) for value in sys.argv[2:]))
else:
print("usage: prepare-runtime-jar.py prepare ORIGINAL CLASS CONTRACT OUTPUT RECEIPT\n"
" prepare-runtime-jar.py rollback ORIGINAL OUTPUT RECEIPT", file=sys.stderr)
return 64
except (OSError, ValueError, zipfile.BadZipFile, RecursionError):
print("Qortal runtime overlay refused: package provenance, official JAR, replacement class, or rollback bytes drifted.", file=sys.stderr)
return 78
return 0
if __name__ == "__main__":
sys.exit(main())