195 lines
7.4 KiB
Python
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())
|