From 0ba269a39a9ea0ae065c5c57af00253c7882488c Mon Sep 17 00:00:00 2001 From: Michael Sauer Date: Sat, 12 Sep 2026 12:25:02 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20nl=5Fmkimage=20pr=C3=BCft=20Images=20mi?= =?UTF-8?q?t=20--verify?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deploy.sh soll kein Image auf die Station schieben, das jede Lampe ablehnen würde. Die Prüfung sitzt in nl_mkimage, weil dort das Trailer-Layout ohnehin steht - eine zweite Kopie derselben Offsets wäre genau die Doppelung, die beim Abweichen jedes Update killt. Co-Authored-By: Claude Haiku 4.5 --- tools/mkimage/nl_mkimage.py | 45 +++++++++++++++++++++++++++--- tools/mkimage/test_nl_mkimage.py | 47 ++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 4 deletions(-) diff --git a/tools/mkimage/nl_mkimage.py b/tools/mkimage/nl_mkimage.py index 5d5afc0..5f37eef 100755 --- a/tools/mkimage/nl_mkimage.py +++ b/tools/mkimage/nl_mkimage.py @@ -22,14 +22,51 @@ def field(value: str, name: str) -> bytes: return raw.ljust(FIELD, b"\x00") +def verify(path: Path, expect_device: str | None) -> int: + """Checks an _ota.img the way the firmware does before accepting it.""" + data = path.read_bytes() + if len(data) <= TRAILER_SIZE: + print(f"error: {path} is too short to carry a trailer", file=sys.stderr) + return 1 + payload, trailer = data[:-TRAILER_SIZE], data[-TRAILER_SIZE:] + if trailer[0:4] != b"NINL": + print(f"error: {path} has no ninlight trailer", file=sys.stderr) + return 1 + if trailer[4] != TRAILER_VERSION: + print(f"error: {path} has trailer version {trailer[4]}, expected {TRAILER_VERSION}", + file=sys.stderr) + return 1 + device = trailer[5:21].rstrip(b"\x00").decode("ascii", "replace") + version = trailer[21:37].rstrip(b"\x00").decode("ascii", "replace") + if expect_device is not None and device != expect_device: + print(f"error: {path} carries device '{device}', expected '{expect_device}'", + file=sys.stderr) + return 1 + if trailer[37:69] != hashlib.sha256(payload).digest(): + print(f"error: {path} fails its sha256 - the payload does not match the trailer", + file=sys.stderr) + return 1 + print(f"{device} {version} {len(data)}") + return 0 + + def main() -> None: p = argparse.ArgumentParser(description="append the ninlight OTA trailer") - p.add_argument("--image", required=True, type=Path, help="input _gz.img") - p.add_argument("--device", required=True, help="device id, e.g. e27_rgb") - p.add_argument("--version", required=True, help="firmware version") - p.add_argument("--out", required=True, type=Path, help="output _ota.img") + p.add_argument("--image", type=Path, help="input _gz.img") + p.add_argument("--device", help="device id, e.g. e27_rgb") + p.add_argument("--version", help="firmware version") + p.add_argument("--out", type=Path, help="output _ota.img") + p.add_argument("--verify", type=Path, help="check an existing _ota.img instead of building one") + p.add_argument("--expect-device", help="with --verify: require this device id") a = p.parse_args() + if a.verify is not None: + raise SystemExit(verify(a.verify, a.expect_device)) + + for name in ("image", "device", "version", "out"): + if getattr(a, name) is None: + p.error(f"--{name} is required unless --verify is given") + data = a.image.read_bytes() if len(data) > TRAILER_SIZE and data[-TRAILER_SIZE : -TRAILER_SIZE + 4] == b"NINL": sys.exit("error: image already carries a trailer") diff --git a/tools/mkimage/test_nl_mkimage.py b/tools/mkimage/test_nl_mkimage.py index 9609de1..4e4f0d4 100644 --- a/tools/mkimage/test_nl_mkimage.py +++ b/tools/mkimage/test_nl_mkimage.py @@ -53,3 +53,50 @@ def test_refuses_image_that_already_has_a_trailer(tmp_path): ) assert r.returncode != 0 assert "trailer" in r.stderr + + +def verify(path, *extra): + return subprocess.run( + [sys.executable, str(SCRIPT), "--verify", str(path), *extra], + capture_output=True, text=True, + ) + + +def test_verify_accepts_a_good_image(tmp_path): + _, out = build(tmp_path, device="e27_rgb", version="1.2.3") + r = verify(out) + assert r.returncode == 0, r.stderr + assert r.stdout.split()[:2] == ["e27_rgb", "1.2.3"] + + +def test_verify_rejects_a_file_without_trailer(tmp_path): + p = tmp_path / "plain.img" + p.write_bytes(b"x" * 5000) + r = verify(p) + assert r.returncode == 1 + assert "trailer" in r.stderr.lower() + + +def test_verify_rejects_a_corrupted_payload(tmp_path): + _, out = build(tmp_path) + d = bytearray(out.read_bytes()) + d[100] ^= 0x01 + out.write_bytes(d) + r = verify(out) + assert r.returncode == 1 + assert "sha256" in r.stderr.lower() + + +def test_verify_checks_the_expected_device(tmp_path): + _, out = build(tmp_path, device="evk") + assert verify(out, "--expect-device", "evk").returncode == 0 + r = verify(out, "--expect-device", "g95_cw") + assert r.returncode == 1 + assert "evk" in r.stderr and "g95_cw" in r.stderr + + +def test_verify_rejects_a_file_shorter_than_the_trailer(tmp_path): + p = tmp_path / "tiny.img" + p.write_bytes(b"NINL" + b"\x00" * 10) + r = verify(p) + assert r.returncode == 1