232 lines
7.9 KiB
Python
232 lines
7.9 KiB
Python
"""Trailer, version order, Wi-Fi credential check and the firmware catalog."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import gzip
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from custom_components.ninlight import firmware
|
|
from custom_components.ninlight.firmware import (
|
|
FirmwareCatalog,
|
|
FirmwareVersion,
|
|
InvalidImageError,
|
|
contains_ssid,
|
|
is_newer,
|
|
parse_trailer,
|
|
parse_version,
|
|
)
|
|
from tests_unit.images import CONTENT_WITH_SSID, CONTENT_WITHOUT_SSID, build_image
|
|
|
|
# Strictly ascending. Every neighbour pair and every pair across the list is checked.
|
|
ORDER = [
|
|
"0.3.8",
|
|
"0.3.8-36-gca9bb",
|
|
"0.3.8-47-g71a57",
|
|
"0.3.9-rc1",
|
|
"0.3.9-rc1-4-gab12",
|
|
"0.3.9-rc2",
|
|
"0.3.9",
|
|
"0.4.0-rc1",
|
|
"0.10.0",
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize(("older", "newer"), list(zip(ORDER, ORDER[1:], strict=False))) # noqa: RUF007
|
|
def test_version_neighbours(older: str, newer: str) -> None:
|
|
assert is_newer(newer, older)
|
|
assert not is_newer(older, newer)
|
|
|
|
|
|
def test_version_order_holds_across_the_whole_list() -> None:
|
|
for i, older in enumerate(ORDER):
|
|
for newer in ORDER[i + 1 :]:
|
|
assert is_newer(newer, older), (newer, older)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("text", "expected"),
|
|
[
|
|
("0.3.8-36-gca9bb", FirmwareVersion((0, 3, 8), None, 36)),
|
|
("0.3.8-38-gd3cc9", FirmwareVersion((0, 3, 8), None, 38)), # exactly 15 chars, hash present
|
|
("0.3.8-36-g", FirmwareVersion((0, 3, 8), None, 36)), # hash cut to nothing
|
|
("0.3.8-36", FirmwareVersion((0, 3, 8), None, 36)),
|
|
("0.3.8-3-gab-dir", FirmwareVersion((0, 3, 8), None, 3)), # "-dirty" cut to "-dir"
|
|
("0.3.8-dirty", FirmwareVersion((0, 3, 8), None, 0)),
|
|
("0.4.0-rc1-12-ga", FirmwareVersion((0, 4, 0), 1, 12)), # 15 chars, hash cut to one char
|
|
("0.3.9-rc2", FirmwareVersion((0, 3, 9), 2, 0)),
|
|
],
|
|
)
|
|
def test_parse_version(text: str, expected: FirmwareVersion) -> None:
|
|
assert parse_version(text) == expected
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"text",
|
|
[
|
|
"",
|
|
"gca9bb", # git describe --always without a tag
|
|
"v0.3.8", # the firmware strips the "v"
|
|
"0.3",
|
|
"0.3.8-",
|
|
"0.10.10-rc10-12", # 15 chars ending in digits without "-g": commit count may be cut
|
|
"0.3.8-36-gXYZ",
|
|
"0.3.8 ",
|
|
],
|
|
)
|
|
def test_unparseable_versions(text: str) -> None:
|
|
assert parse_version(text) is None
|
|
|
|
|
|
def test_same_commit_count_with_other_hash_is_not_newer() -> None:
|
|
assert not is_newer("0.3.8-36-gffff0", "0.3.8-36-gca9bb")
|
|
assert not is_newer("0.3.8-36-gca9bb", "0.3.8-36-gffff0")
|
|
|
|
|
|
def test_unparseable_side_is_never_newer() -> None:
|
|
assert not is_newer("gca9bb", "0.3.8")
|
|
assert not is_newer("0.3.9", "gca9bb")
|
|
|
|
|
|
def test_parse_trailer_reads_nul_padded_fields() -> None:
|
|
assert parse_trailer(build_image("g95_cw", "0.3.8-38-gd3cc9")) == ("g95_cw", "0.3.8-38-gd3cc9")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("data", "reason"),
|
|
[
|
|
(b"\xff" * 512, "short"),
|
|
(build_image(magic=b"NINX"), "NINL"),
|
|
(build_image(fmt=2), "format"),
|
|
(build_image(corrupt_hash=True), "sha256"),
|
|
],
|
|
)
|
|
def test_invalid_images(data: bytes, reason: str) -> None:
|
|
with pytest.raises(InvalidImageError, match=reason):
|
|
parse_trailer(data)
|
|
|
|
|
|
def test_ssid_found_when_nul_framed() -> None:
|
|
assert contains_ssid(build_image(content=CONTENT_WITH_SSID), "ninIOT")
|
|
|
|
|
|
def test_ssid_missing_in_image_without_credentials() -> None:
|
|
assert not contains_ssid(build_image(content=CONTENT_WITHOUT_SSID), "ninIOT")
|
|
|
|
|
|
@pytest.mark.parametrize("content", [b"\x00xninIOT\x00", b"\x00ninIOTx\x00", b"ninIOT"])
|
|
def test_ssid_must_be_nul_framed(content: bytes) -> None:
|
|
assert not contains_ssid(build_image(content=content), "ninIOT")
|
|
|
|
|
|
def test_other_ssid_does_not_match() -> None:
|
|
assert not contains_ssid(build_image(content=CONTENT_WITH_SSID), "ninIOT-gast")
|
|
|
|
|
|
def test_empty_ssid_never_matches() -> None:
|
|
assert not contains_ssid(build_image(content=b"\x00\x00\x00"), "")
|
|
|
|
|
|
def test_broken_gzip_is_not_a_match() -> None:
|
|
payload = b"\xab" * 56 + b"\x1f\x8b\x08" + bytes(7) + b"\xff" * 64
|
|
assert not contains_ssid(build_image(payload=payload), "ninIOT")
|
|
|
|
|
|
def test_gzip_stream_inside_the_header_is_ignored() -> None:
|
|
payload = gzip.compress(CONTENT_WITH_SSID, mtime=0).ljust(120, b"\x00")
|
|
assert not contains_ssid(build_image(payload=payload), "ninIOT")
|
|
|
|
|
|
def _write(directory: Path, name: str, data: bytes) -> Path:
|
|
path = directory / name
|
|
path.write_bytes(data)
|
|
return path
|
|
|
|
|
|
def test_scan_creates_directory_and_accepts_valid_images_only(tmp_path: Path) -> None:
|
|
directory = tmp_path / "ninlight_firmware"
|
|
catalog = FirmwareCatalog(directory)
|
|
assert catalog.scan() == ()
|
|
assert directory.is_dir()
|
|
_write(directory, "a.img", build_image("gu10_rgb", "0.3.8-47-g71a57"))
|
|
_write(directory, "broken.img", build_image(corrupt_hash=True))
|
|
_write(directory, "notes.txt", build_image())
|
|
images = catalog.scan()
|
|
assert [(i.path.name, i.device_type, i.version) for i in images] == [
|
|
("a.img", "gu10_rgb", "0.3.8-47-g71a57")
|
|
]
|
|
assert catalog.images == images
|
|
|
|
|
|
def test_invalid_file_is_logged_once(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
|
|
catalog = FirmwareCatalog(tmp_path)
|
|
_write(tmp_path, "broken.img", build_image(corrupt_hash=True))
|
|
catalog.scan()
|
|
catalog.scan()
|
|
assert sum("broken.img" in r.getMessage() for r in caplog.records) == 1
|
|
|
|
|
|
def test_rescan_sees_new_and_removed_files(tmp_path: Path) -> None:
|
|
catalog = FirmwareCatalog(tmp_path)
|
|
old = _write(tmp_path, "a.img", build_image(version="0.3.8-47-g71a57"))
|
|
catalog.scan()
|
|
_write(tmp_path, "b.img", build_image(version="0.3.9-rc1"))
|
|
old.unlink()
|
|
assert [i.version for i in catalog.scan()] == ["0.3.9-rc1"]
|
|
|
|
|
|
def test_newer_images_filters_exact_device_type_and_sorts_descending(tmp_path: Path) -> None:
|
|
catalog = FirmwareCatalog(tmp_path)
|
|
_write(tmp_path, "1.img", build_image("gu10_rgb", "0.3.8-47-g71a57"))
|
|
_write(tmp_path, "2.img", build_image("gu10_rgb", "0.3.9-rc1"))
|
|
_write(tmp_path, "3.img", build_image("gu10_cw", "0.4.0")) # newest, but other type
|
|
_write(tmp_path, "4.img", build_image("gu10", "0.5.0")) # prefix of the type
|
|
_write(tmp_path, "5.img", build_image("gu10_rgb", "0.3.8-36-gffff0")) # equal to installed
|
|
_write(tmp_path, "6.img", build_image("gu10_rgb", "0.3.8")) # older
|
|
catalog.scan()
|
|
newer = catalog.newer_images("gu10_rgb", "0.3.8-36-gca9bb")
|
|
assert [(i.device_type, i.version) for i in newer] == [
|
|
("gu10_rgb", "0.3.9-rc1"),
|
|
("gu10_rgb", "0.3.8-47-g71a57"),
|
|
]
|
|
|
|
|
|
def test_verify_detects_changed_and_removed_files(tmp_path: Path) -> None:
|
|
catalog = FirmwareCatalog(tmp_path)
|
|
path = _write(tmp_path, "a.img", build_image(version="0.3.8-47-g71a57"))
|
|
(image,) = catalog.scan()
|
|
assert catalog.verify(image)
|
|
path.write_bytes(build_image(version="0.3.9-rc1"))
|
|
assert not catalog.verify(image)
|
|
path.unlink()
|
|
assert not catalog.verify(image)
|
|
|
|
|
|
def test_catalog_ssid_check_is_cached_per_image_and_ssid(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
catalog = FirmwareCatalog(tmp_path)
|
|
_write(tmp_path, "a.img", build_image(content=CONTENT_WITH_SSID))
|
|
(image,) = catalog.scan()
|
|
calls: list[str] = []
|
|
real = firmware.contains_ssid
|
|
|
|
def spy(data: bytes, ssid: str) -> bool:
|
|
calls.append(ssid)
|
|
return real(data, ssid)
|
|
|
|
monkeypatch.setattr(firmware, "contains_ssid", spy)
|
|
assert catalog.contains_ssid(image, "ninIOT")
|
|
assert catalog.contains_ssid(image, "ninIOT")
|
|
assert not catalog.contains_ssid(image, "andere")
|
|
assert calls == ["ninIOT", "andere"]
|
|
|
|
|
|
def test_catalog_ssid_check_on_removed_file_is_false(tmp_path: Path) -> None:
|
|
catalog = FirmwareCatalog(tmp_path)
|
|
path = _write(tmp_path, "a.img", build_image(content=CONTENT_WITH_SSID))
|
|
(image,) = catalog.scan()
|
|
path.unlink()
|
|
assert not catalog.contains_ssid(image, "ninIOT")
|