189 lines
11 KiB
Python
189 lines
11 KiB
Python
"""Versioned offline qualification artifact, not a runtime installer.
|
|
|
|
Reuse an attested upstream Tool build's object files unchanged. Only the adapter
|
|
entry point is compiled. The previous staging, packages and services are untouched.
|
|
All inputs, link objects, outputs and checks are hashed in the private report.
|
|
"""
|
|
import argparse
|
|
from datetime import datetime, timezone
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import platform
|
|
import re
|
|
import subprocess
|
|
import time
|
|
import zipfile
|
|
|
|
COMMIT = "01d5f10901116c311e3fb84d5a1541f663d3ce20"
|
|
UPSTREAM_SHA256 = "4565ecec4e41e094bcc43127a51970e810aa9080a9d7c025af64632bfd4c0189"
|
|
|
|
|
|
def digest(path):
|
|
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
|
|
|
|
def run(args):
|
|
release = platform.freedesktop_os_release()
|
|
if os.geteuid() == 0 or (release.get("ID"), release.get("VERSION_ID"), platform.machine()) != ("ubuntu", "24.04", "x86_64"):
|
|
raise RuntimeError("Unprivileged Ubuntu 24.04 amd64 required")
|
|
group = Path("/sys/fs/cgroup") / Path("/proc/self/cgroup").read_text().strip().split("::", 1)[1].lstrip("/")
|
|
limit = (group / "memory.max").read_text().strip()
|
|
if limit == "max" or int(limit) > 3 * 1024**3:
|
|
raise RuntimeError("A bounded user scope with MemoryMax <= 3G is required")
|
|
previous = json.loads(args.upstream_report.read_text())
|
|
if previous.get("state") != "complete" or previous.get("source_sha256") != UPSTREAM_SHA256:
|
|
raise RuntimeError("Unqualified upstream build")
|
|
upstream = Path(previous["binary"])
|
|
if digest(upstream) != previous["binary_sha256"]:
|
|
raise RuntimeError("Upstream binary changed")
|
|
source = upstream.parents[2]
|
|
if source.name != "vesc_tool-" + COMMIT:
|
|
raise RuntimeError("Upstream source path mismatch")
|
|
os.umask(0o077)
|
|
root = args.output.resolve()
|
|
root.mkdir(parents=True, mode=0o700, exist_ok=False)
|
|
# Paths enter a generated makefile, never a shell command assembled from JSON.
|
|
if any(not re.fullmatch(r"[A-Za-z0-9_./-]+", str(p)) for p in (root, source)):
|
|
raise RuntimeError("Build paths must be make-safe")
|
|
with zipfile.ZipFile(args.artifact) as bundle:
|
|
for name in ("offline_main.cpp", "config_export.h", "engine_main.cpp", "native_bundle.py"):
|
|
(root / name).write_bytes(bundle.read(name))
|
|
report = {"schema": "missioncore.vesc.native-probe/v1", "state": "running",
|
|
"started_at": datetime.now(timezone.utc).isoformat(), "monotonic_started": time.monotonic(),
|
|
"source_commit": COMMIT, "artifact_sha256": digest(args.artifact),
|
|
"upstream_report_sha256": digest(args.upstream_report),
|
|
"adapter_sha256": digest(root / "offline_main.cpp"),
|
|
"hardware_access": False, "runtime_installed": False, "system_packages_installed": False,
|
|
"jobs": [], "checks": []}
|
|
|
|
def publish():
|
|
(root / "report.json").write_text(json.dumps(report, indent=2) + "\n")
|
|
|
|
staging = source.parents[1]
|
|
qtbase = staging / "sysroot/usr"
|
|
env = dict(os.environ, LC_ALL="C", QT_QPA_PLATFORM="offscreen",
|
|
LD_LIBRARY_PATH=str(qtbase / "lib/x86_64-linux-gnu"),
|
|
QT_PLUGIN_PATH=str(qtbase / "lib/x86_64-linux-gnu/qt5/plugins"),
|
|
XDG_CONFIG_HOME=str(root / "config"), XDG_CACHE_HOME=str(root / "cache"))
|
|
|
|
def execute(name, command, data=None, expected=0, timeout=60):
|
|
start = time.monotonic()
|
|
proc = subprocess.run(command, cwd=source, env=env, input=data,
|
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout)
|
|
(root / (name + ".stdout")).write_bytes(proc.stdout)
|
|
(root / (name + ".stderr")).write_bytes(proc.stderr)
|
|
report["jobs"].append({"id": name, "exit_code": proc.returncode,
|
|
"duration_seconds": time.monotonic() - start,
|
|
"stdout_sha256": hashlib.sha256(proc.stdout).hexdigest(),
|
|
"stderr_sha256": hashlib.sha256(proc.stderr).hexdigest()})
|
|
publish()
|
|
if proc.returncode != expected:
|
|
raise RuntimeError("Native probe step failed: " + name)
|
|
return proc.stdout
|
|
|
|
try:
|
|
makefile = (source / "Makefile").read_text().replace("\\\n", " ")
|
|
match = re.search(r"^OBJECTS\s*=\s*(.+)$", makefile, re.MULTILINE)
|
|
if not match:
|
|
raise RuntimeError("Upstream link objects missing")
|
|
objects = [source / name for name in match.group(1).split() if name != "build/lin/obj/main.o"]
|
|
if not 100 < len(objects) < 1000 or any(not p.is_file() for p in objects):
|
|
raise RuntimeError("Upstream object inventory incomplete")
|
|
report["link_objects"] = [{"file": str(p.relative_to(source)), "sha256": digest(p)} for p in objects]
|
|
report["upstream_makefile_sha256"] = digest(source / "Makefile")
|
|
target = root / "mission-core-vesc-offline"
|
|
wrapper = root / "Makefile.native"
|
|
wrapper.write_text(
|
|
"include " + str(source / "Makefile") + "\n"
|
|
".PHONY: mission-core-native-probe\n"
|
|
"mission-core-native-probe:\n"
|
|
"\t$(CXX) -c $(CXXFLAGS) $(INCPATH) -o " + str(root / "offline_main.o") + " " + str(root / "offline_main.cpp") + "\n"
|
|
"\t$(LINK) $(LFLAGS) -o " + str(target) + " " + str(root / "offline_main.o") +
|
|
" $(filter-out build/lin/obj/main.o,$(OBJECTS)) $(OBJCOMP) $(LIBS)\n")
|
|
execute("compile-link", ["/usr/bin/make", "-f", str(wrapper), "mission-core-native-probe"], timeout=180)
|
|
report["binary_sha256"] = digest(target)
|
|
for index, archive in enumerate(args.archives):
|
|
raw = archive.read_bytes()
|
|
native = json.loads(execute("archive-%d" % index, [str(target)], raw))
|
|
if not native["ok"] or not native["compatibility"]["legacy_power_loss_correction"]:
|
|
raise RuntimeError("Upstream compatibility check failed")
|
|
import base64
|
|
packet = base64.b64decode(native["compatibility"]["offline_detect_example_base64"])
|
|
if packet[:2] != bytes([58, 0]) or int.from_bytes(packet[2:6], "big", signed=True) != 50000:
|
|
raise RuntimeError("Expected native 5.02 detect correction was not applied")
|
|
rpm_packets = [base64.b64decode(p) for p in native["offline_signed_rpm_examples_base64"]]
|
|
if (len(rpm_packets) != 2 or any(len(p) != 5 or p[0] != 8 for p in rpm_packets)
|
|
or [int.from_bytes(p[1:], "big", signed=True) for p in rpm_packets] != [3000, -3000]):
|
|
raise RuntimeError("Upstream signed RPM serialization mismatch")
|
|
report["checks"].append({"id": "signed-rpm-serialization-%d" % index, "ok": True})
|
|
report["checks"].append({"id": "native-archive-%d" % index, "ok": True,
|
|
"archive_sha256": hashlib.sha256(raw).hexdigest(),
|
|
"motor_parameter_count": len(native["motor"]["parameters"]),
|
|
"application_parameter_count": len(native["application"]["parameters"]),
|
|
"binary_round_trip_exact": True,
|
|
"xml_equivalent_by_upstream_comparison": True,
|
|
"xml_binary_exact": all(native[k]["xml_round_trip_exact"] for k in ("motor", "application")),
|
|
"legacy_power_loss_correction": True})
|
|
# Fail closed on archive corruption and unsupported firmware instead
|
|
# of silently presenting the bundled defaults as actual settings.
|
|
original = json.loads(raw)
|
|
damaged = json.loads(raw); damaged["configs"]["motor"]["sha256"] = "0" * 64
|
|
unknown = json.loads(raw); unknown["identity"]["major"] = 99
|
|
wrong_signature = json.loads(raw)
|
|
payload = bytearray(base64.b64decode(original["configs"]["motor"]["payload"])); payload[1] ^= 1
|
|
wrong_signature["configs"]["motor"]["payload"] = base64.b64encode(payload).decode()
|
|
wrong_signature["configs"]["motor"]["sha256"] = hashlib.sha256(payload).hexdigest()
|
|
truncated = json.loads(raw)
|
|
payload = base64.b64decode(original["configs"]["motor"]["payload"])[:-1]
|
|
truncated["configs"]["motor"].update(payload=base64.b64encode(payload).decode(),
|
|
sha256=hashlib.sha256(payload).hexdigest(), bytes=len(payload))
|
|
for name, value in (("corrupt", damaged), ("unsupported", unknown), ("signature", wrong_signature), ("truncated", truncated)):
|
|
rejected = json.loads(execute("%s-%d" % (name, index), [str(target)], json.dumps(value).encode(), expected=1))
|
|
if rejected["ok"] or rejected["hardware_access"]:
|
|
raise RuntimeError("Invalid archive was not rejected")
|
|
report["checks"].append({"id": "%s-%d" % (name, index), "ok": True})
|
|
for item in report["link_objects"]:
|
|
if digest(source / item["file"]) != item["sha256"]:
|
|
raise RuntimeError("Upstream objects were modified")
|
|
engine = root / "mission-core-vesc-engine"
|
|
wrapper.write_text(wrapper.read_text() +
|
|
"\n.PHONY: mission-core-native-engine\nmission-core-native-engine:\n"
|
|
"\t$(CXX) -c $(CXXFLAGS) $(INCPATH) -o " + str(root / "engine_main.o") + " " + str(root / "engine_main.cpp") + "\n"
|
|
"\t$(LINK) $(LFLAGS) -o " + str(engine) + " " + str(root / "engine_main.o") +
|
|
" $(filter-out build/lin/obj/main.o,$(OBJECTS)) $(OBJCOMP) $(LIBS)\n")
|
|
execute("engine-compile", ["/usr/bin/make", "-f", str(wrapper), "mission-core-native-engine"], timeout=180)
|
|
requests = [{"id": 1, "method": "engine"}, {"id": 2, "method": "current", "current_a": 30},
|
|
{"id": 3, "method": "hall_start", "current_a": 5}, {"id": 4, "method": "arbitrary_packet"}]
|
|
responses = [json.loads(line) for line in execute("engine-offline", [str(engine), "--offline"],
|
|
b"".join(json.dumps(r).encode()+b"\n" for r in requests)).splitlines()]
|
|
if (len(responses) != 5 or not responses[0]["ready"] or not responses[1]["ok"]
|
|
or any(r["ok"] for r in responses[2:]) or responses[1]["result"]["hardware_enabled"]):
|
|
raise RuntimeError("Native engine offline boundary failed")
|
|
report["checks"].append({"id": "engine-offline-denies-hardware", "ok": True})
|
|
execute("runtime-bundle", ["/usr/bin/python3", str(root / "native_bundle.py"),
|
|
"--engine", str(engine), "--sysroot", str(staging / "sysroot"), "--source", str(source), "--output", str(root / "runtime")], timeout=180)
|
|
report["native_runtime"] = json.loads((root / "runtime/bundle.json").read_text())
|
|
report.update(state="complete", binary=str(target), upstream_objects_unchanged=True)
|
|
except Exception as error:
|
|
report.update(state="error", error=str(error))
|
|
raise
|
|
finally:
|
|
report["finished_at"] = datetime.now(timezone.utc).isoformat()
|
|
report["duration_seconds"] = time.monotonic() - report["monotonic_started"]
|
|
publish()
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--artifact", type=Path, required=True)
|
|
parser.add_argument("--upstream-report", type=Path, required=True)
|
|
parser.add_argument("--output", type=Path, required=True)
|
|
parser.add_argument("--archives", type=Path, nargs="+", required=True)
|
|
run(parser.parse_args())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|