Skip to content

Commit f96996b

Browse files
authored
Detect image type from magic numbers in image component (home-assistant#157190)
1 parent eb9fc66 commit f96996b

File tree

2 files changed

+67
-1
lines changed

2 files changed

+67
-1
lines changed

homeassistant/components/image/__init__.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,20 @@
7070

7171
IMAGE_SERVICE_SNAPSHOT: VolDictType = {vol.Required(ATTR_FILENAME): cv.string}
7272

73+
MAP_MAGIC_NUMBERS_TO_CONTENT_TYPE = {
74+
b"\x89PNG": "image/png",
75+
b"GIF8": "image/gif",
76+
b"RIFF": "image/webp",
77+
b"\x49\x49\x2a\x00": "image/tiff",
78+
b"\x4d\x4d\x00\x2a": "image/tiff",
79+
b"\xff\xd8\xff\xdb": "image/jpeg",
80+
b"\xff\xd8\xff\xe0": "image/jpeg",
81+
b"\xff\xd8\xff\xed": "image/jpeg",
82+
b"\xff\xd8\xff\xee": "image/jpeg",
83+
b"\xff\xd8\xff\xe1": "image/jpeg",
84+
b"\xff\xd8\xff\xe2": "image/jpeg",
85+
}
86+
7387

7488
class ImageEntityDescription(EntityDescription, frozen_or_thawed=True):
7589
"""A class that describes image entities."""
@@ -94,6 +108,11 @@ def valid_image_content_type(content_type: str | None) -> str:
94108
return content_type
95109

96110

111+
def infer_image_type(content: bytes) -> str | None:
112+
"""Infer image type from first 4 bytes (magic number)."""
113+
return MAP_MAGIC_NUMBERS_TO_CONTENT_TYPE.get(content[:4])
114+
115+
97116
async def _async_get_image(image_entity: ImageEntity, timeout: int) -> Image:
98117
"""Fetch image from an image entity."""
99118
with suppress(asyncio.CancelledError, TimeoutError, ImageContentTypeError):
@@ -242,7 +261,9 @@ async def _fetch_url(self, url: str) -> httpx.Response | None:
242261
async def _async_load_image_from_url(self, url: str) -> Image | None:
243262
"""Load an image by url."""
244263
if response := await self._fetch_url(url):
245-
content_type = response.headers.get("content-type")
264+
content_type = response.headers.get("content-type") or infer_image_type(
265+
response.content
266+
)
246267
try:
247268
return Image(
248269
content=response.content,

tests/components/image/test_init.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,51 @@ async def test_fetch_image_url_wrong_content_type(
348348
assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR
349349

350350

351+
@respx.mock
352+
@pytest.mark.parametrize(
353+
("content", "content_type"),
354+
[
355+
(b"\x89PNG", "image/png"),
356+
(b"\xff\xd8\xff\xdb", "image/jpeg"),
357+
(b"\xff\xd8\xff\xe0", "image/jpeg"),
358+
(b"\xff\xd8\xff\xed", "image/jpeg"),
359+
(b"\xff\xd8\xff\xee", "image/jpeg"),
360+
(b"\xff\xd8\xff\xe1", "image/jpeg"),
361+
(b"\xff\xd8\xff\xe2", "image/jpeg"),
362+
(b"GIF89a", "image/gif"),
363+
(b"GIF87a", "image/gif"),
364+
(b"RIFF", "image/webp"),
365+
(b"\x49\x49\x2a\x00", "image/tiff"),
366+
(b"\x4d\x4d\x00\x2a", "image/tiff"),
367+
],
368+
)
369+
async def test_fetch_image_url_infer_content_type_from_magic_number(
370+
hass: HomeAssistant,
371+
hass_client: ClientSessionGenerator,
372+
content: bytes,
373+
content_type: str,
374+
) -> None:
375+
"""Test fetching an image and inferring content-type from magic number."""
376+
respx.get("https://example.com/myimage.jpg").respond(
377+
status_code=HTTPStatus.OK, content=content
378+
)
379+
380+
mock_integration(hass, MockModule(domain="test"))
381+
mock_platform(hass, "test.image", MockImagePlatform([MockURLImageEntity(hass)]))
382+
assert await async_setup_component(
383+
hass, image.DOMAIN, {"image": {"platform": "test"}}
384+
)
385+
await hass.async_block_till_done()
386+
387+
client = await hass_client()
388+
389+
resp = await client.get("/api/image_proxy/image.test")
390+
assert resp.status == HTTPStatus.OK
391+
body = await resp.read()
392+
assert body == content
393+
assert resp.content_type == content_type
394+
395+
351396
async def test_image_stream(
352397
hass: HomeAssistant,
353398
hass_client: ClientSessionGenerator,

0 commit comments

Comments
 (0)