"""#51 custom decor image security and lifecycle contracts.""" from __future__ import annotations import base64 import importlib import json import struct import zlib import pytest from custom_components.houseplan.const import MAX_DECOR_ASSET_BYTES from custom_components.houseplan.decor_assets import ( DecorAssetError, asset_meta_path, asset_refs, public_asset, read_catalog, validate_asset, ) PNG_1X1 = base64.b64decode( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" ) JPEG_3X2 = base64.b64decode( "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAACAAMDASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwDx2iiiu04z/9k=" ) WEBP_VP8_3X2 = base64.b64decode( "UklGRjAAAABXRUJQVlA4ICQAAABQAQCdASoDAAIAAUAmJQBOgC6gAP77LkvF3YjjJ4dVU9ffoAA=" ) WEBP_VP8L_3X2 = base64.b64decode( "UklGRh4AAABXRUJQVlA4TBEAAAAvAkAAAAdQkTIUp/+BiOh/AAA=" ) WEBP_VP8X_3X2 = base64.b64decode( "UklGRlIAAABXRUJQVlA4WAoAAAAQAAAAAgAAAQAAQUxQSAcAAAAAgICAgICAAFZQOCAkAAAAUAEAnQEqAwACAAFAJiUAToAuoAD++y5Lxd2I4yeHVVPX36AA" ) def test_png_identity_and_dimensions_are_content_addressed() -> None: asset = validate_asset(PNG_1X1, "pixel.PNG") assert (asset.mime, asset.ext, asset.width, asset.height) == ("image/png", ".png", 1, 1) assert len(asset.asset_id) == 64 assert asset.asset_id == validate_asset(PNG_1X1, "other.png").asset_id def test_declared_mime_must_agree_with_extension_and_bytes() -> None: with pytest.raises(DecorAssetError, match="disagree"): validate_asset(PNG_1X1, "pixel.png", "image/jpeg") assert validate_asset(PNG_1X1, "pixel.png", "image/png; charset=binary").width == 1 @pytest.mark.parametrize(("payload", "filename", "mime"), [ (JPEG_3X2, "photo.jpeg", "image/jpeg"), (WEBP_VP8_3X2, "lossy.webp", "image/webp"), (WEBP_VP8L_3X2, "lossless.webp", "image/webp"), (WEBP_VP8X_3X2, "alpha.webp", "image/webp"), ]) def test_supported_raster_headers_and_dimensions( payload: bytes, filename: str, mime: str, ) -> None: """Имя до #430 обещало «and_full_decode», а проверялись w/h/mime — их даёт header-парсер, и мутация полного декодирования оставалась зелёной.""" asset = validate_asset(payload, filename, "application/octet-stream") assert (asset.width, asset.height, asset.mime) == (3, 2, mime) def test_upload_size_and_extension_guards() -> None: with pytest.raises(DecorAssetError, match="empty"): validate_asset(b"", "empty.png") with pytest.raises(DecorAssetError, match="2 MiB"): validate_asset(b"x" * (2 * 1024 * 1024 + 1), "large.png") with pytest.raises(DecorAssetError, match="PNG, JPEG, WebP or SVG"): validate_asset(PNG_1X1, "pixel.gif") @pytest.mark.parametrize("payload", [ b'', b'', b'', b']>', b'', b'', b'', b'', b'', b'', b'', b'', b'', ]) def test_svg_rejects_the_whole_unsafe_document(payload: bytes) -> None: with pytest.raises(DecorAssetError): validate_asset(payload, "unsafe.svg") def test_svg_utf16_doctype_is_rejected_before_entity_expansion() -> None: payload = ''' ]> '''.encode("utf-16") with pytest.raises(DecorAssetError, match="DTD, entities"): validate_asset(payload, "encoded.svg") def test_svg_canonicalizes_safe_geometry_and_requires_aspect_ratio() -> None: asset = validate_asset( b'', "safe.svg", ) assert (asset.width, asset.height, asset.mime) == (30, 20, "image/svg+xml") with pytest.raises(DecorAssetError, match="dimensions"): validate_asset(b'', "bad.svg") def test_svg_dimension_and_text_boundaries_fail_closed() -> None: with pytest.raises(DecorAssetError, match="viewBox"): validate_asset( b'', "bad.svg", ) with pytest.raises(DecorAssetError, match="dimensions"): validate_asset( b'', "bad.svg", ) with pytest.raises(DecorAssetError, match="safety limit"): validate_asset( b'', "large.svg", ) with pytest.raises(DecorAssetError, match="SVG text"): validate_asset( b'text', "text.svg", ) def test_svg_preserves_safe_local_gradient_clip_mask_and_transparency() -> None: asset = validate_asset(b''' ''', "safe.svg", "image/svg+xml") assert asset.data.startswith(b' None: too_deep = ( b'' + b'' * 65 + b'' * 65 + b'' ) too_many = ( b'' + b'' * 5000 + b'' ) for payload in (too_deep, too_many): with pytest.raises(DecorAssetError, match="safety limit"): validate_asset(payload, "bounded.svg") @pytest.mark.parametrize("attribute", [ 'opacity="NaN"', 'opacity="1.1"', 'stop-opacity="101%"', 'offset="-0.1"', ]) def test_svg_rejects_non_finite_or_out_of_range_unit_values(attribute: str) -> None: tag = "stop" if attribute.startswith(("stop", "offset")) else "path" payload = ( f'' f'<{tag} {attribute}/>' ).encode() with pytest.raises(DecorAssetError, match="invalid|range"): validate_asset(payload, "bounded.svg") def test_svg_rejects_one_oversized_attribute_before_tree_use() -> None: payload = ( b'' ) with pytest.raises(DecorAssetError, match="safety limit"): validate_asset(payload, "bounded.svg") def test_truncated_raster_is_rejected_by_the_header_parser() -> None: """Обрезка на 33 байтах убирает `IEND`, поэтому файл отбивает парсер заголовка. До #430 этот тест назывался «by_full_decode» и записывался в доказательство декодирования — при no-op блока Pillow он оставался зелёным. Настоящий свидетель декодирования — тест ниже.""" with pytest.raises(DecorAssetError, match="corrupt or has the wrong type"): validate_asset(PNG_1X1[:33], "truncated.png", "image/png") def _png_with_corrupt_idat() -> bytes: """PNG, безупречный для парсера заголовка и мёртвый для декодера. Сигнатура, IHDR с честными 1x1, IEND на месте, длины и CRC всех чанков верны — придраться нечему, пока кто-нибудь не попробует распаковать IDAT, в котором лежит не zlib-поток, а текст. """ def chunk(kind: bytes, payload: bytes) -> bytes: return (struct.pack(">I", len(payload)) + kind + payload + struct.pack(">I", zlib.crc32(kind + payload) & 0xFFFFFFFF)) return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", 1, 1, 8, 0, 0, 0, 0)) + chunk(b"IDAT", b"not a zlib stream at all") + chunk(b"IEND", b"")) def test_valid_looking_png_is_rejected_by_the_full_pillow_decode() -> None: """Свидетель полного декодирования (#430, п.1 аудита v1.71.0-beta.1). Раньше блок `with Image.open(...) as image: image.load()` можно было заменить на no-op, и все 35 тестов файла оставались зелёными: заголовок отвечал на все вопросы, которые они задавали. Здесь спрашивается то, на что заголовок ответить не может, — распаковывается ли растр вообще. """ pytest.importorskip("PIL", reason="полное декодирование делает Pillow") with pytest.raises(DecorAssetError, match="cannot be decoded"): validate_asset(_png_with_corrupt_idat(), "corrupt.png", "image/png") def test_pillow_is_present_wherever_home_assistant_is() -> None: """Свидетель выше не имеет права молча пропускаться в каноне. `validate_asset` глотает `ImportError` осознанно: чистое подмножество тестов должно работать без всего HA. Цена — пропуск теста там, где Pillow нет, а пропущенный тест выглядит как пройденный. Поэтому в окружении с Home Assistant (Linux CI, WSL) отсутствие Pillow — красное само по себе. """ pytest.importorskip("homeassistant", reason="чистая песочница без HA") importlib.import_module("PIL.Image") def test_svg_asset_carries_canonical_bytes_not_the_upload() -> None: """Свидетель канонизации (#430, п.2). ТЗ §3 требует ре-сериализацию, а `ValidatedAsset(canonical, …)` → `ValidatedAsset(data, …)` не краснило ни один тест: все проверяли w/h/mime и ни один — сами байты. """ raw = (b'\n' b'\n' b'' b'' b'a>b' b'') asset = validate_asset(raw, "canonical.svg") assert asset.data != raw assert not asset.data.startswith(b"" not in asset.data, "пустой элемент сжимается" assert asset.data == ( b'' b'' b'a>b' b'' ) def test_svg_size_limit_applies_to_canonical_bytes() -> None: """Свидетель второй половины п.2: `_check_size(canonical)`. Экранирование `>` в тексте раздувает документ вчетверо, поэтому загрузка размером 1.84 МиБ канонизуется в 7.35 МиБ. Проверка размера на входе это пропускает; снятие `_check_size(canonical)` до #430 не краснило ничего, и в хранилище уезжал файл вчетверо больше объявленного предела. """ payload = (b'' + (b"" + b">" * 4096 + b"") * 470 + b"") assert len(payload) < MAX_DECOR_ASSET_BYTES, "загрузка обязана проходить входной контроль" with pytest.raises(DecorAssetError, match="2 MiB"): validate_asset(payload, "canonical-bomb.svg") @pytest.mark.parametrize("value", [ "javascript:alert(1)", "data:image/png;base64,AAAA", "http://example.com/x", "https://example.com/x", "//example.com/x", ]) def test_svg_external_resource_guard_catches_what_no_other_rule_does(value: str) -> None: """Свидетель гарда внешних URL (#430, п.3). Все три «внешних» кейса корпуса ловились другими правилами: тегом не из словаря, атрибутом не из словаря, ветвью `name == "href"`. Замена условия на `if False:` не краснила ничего. Здесь тег и атрибут разрешённые, `url(` нет, `href` нет — сработать может только сам токен-гард, и сообщение это подтверждает. """ payload = ( '' f'' ).encode() with pytest.raises(DecorAssetError, match="External SVG resources are forbidden"): validate_asset(payload, "external.svg") def test_catalog_ignores_missing_or_malformed_sidecars(tmp_path) -> None: aid = "a" * 64 (tmp_path / f"{aid}.png").write_bytes(PNG_1X1) row = {"asset_id": aid, "ext": ".png", "mime": "image/png", "width": 1, "height": 1} (tmp_path / f"{aid}.json").write_text(json.dumps(row), encoding="utf-8") (tmp_path / "broken.json").write_text("{", encoding="utf-8") assert read_catalog(tmp_path) == [row] assert public_asset(row)["url"].endswith(f"/assets/_/{aid}.png") def test_catalog_empty_directory_and_metadata_path(tmp_path) -> None: missing = tmp_path / "missing" aid = "c" * 64 assert read_catalog(missing) == [] assert asset_meta_path(tmp_path, aid) == tmp_path / f"{aid}.json" def test_reference_scan_is_cross_space_and_image_only() -> None: aid = "b" * 64 refs = asset_refs({"spaces": [ {"id": "one", "decor": [{"id": "a", "kind": "image", "asset_id": aid}]}, {"id": "two", "decor": [ {"id": "b", "kind": "image", "asset_id": aid}, {"id": "c", "kind": "furniture", "asset_id": aid}, ]}, ]}) assert refs[aid] == [ {"space_id": "one", "decor_id": "a"}, {"space_id": "two", "decor_id": "b"}, ] def test_reference_scan_skips_malformed_spaces_and_asset_ids() -> None: assert asset_refs({"spaces": [None, {"decor": [None, { "kind": "image", "asset_id": "not-a-content-hash", }]}]}) == {}