feat: nl_mkimage prüft Images mit --verify

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 <noreply@anthropic.com>
This commit is contained in:
Michael Sauer
2026-09-12 12:25:02 +02:00
co-authored by Claude Haiku 4.5
parent cb0a4b035d
commit 0ba269a39a
2 changed files with 88 additions and 4 deletions
+41 -4
View File
@@ -22,14 +22,51 @@ def field(value: str, name: str) -> bytes:
return raw.ljust(FIELD, b"\x00") 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: def main() -> None:
p = argparse.ArgumentParser(description="append the ninlight OTA trailer") p = argparse.ArgumentParser(description="append the ninlight OTA trailer")
p.add_argument("--image", required=True, type=Path, help="input _gz.img") p.add_argument("--image", type=Path, help="input _gz.img")
p.add_argument("--device", required=True, help="device id, e.g. e27_rgb") p.add_argument("--device", help="device id, e.g. e27_rgb")
p.add_argument("--version", required=True, help="firmware version") p.add_argument("--version", help="firmware version")
p.add_argument("--out", required=True, type=Path, help="output _ota.img") 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() 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() data = a.image.read_bytes()
if len(data) > TRAILER_SIZE and data[-TRAILER_SIZE : -TRAILER_SIZE + 4] == b"NINL": if len(data) > TRAILER_SIZE and data[-TRAILER_SIZE : -TRAILER_SIZE + 4] == b"NINL":
sys.exit("error: image already carries a trailer") sys.exit("error: image already carries a trailer")
+47
View File
@@ -53,3 +53,50 @@ def test_refuses_image_that_already_has_a_trailer(tmp_path):
) )
assert r.returncode != 0 assert r.returncode != 0
assert "trailer" in r.stderr 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