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