Files
NODEDC_MISSION_CORE/plugins/vesc/packaging/native_probe.py
T

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()