From e134d8dff2fa7083df97df2cfb67ec30ab823b87 Mon Sep 17 00:00:00 2001 From: Niek van der Maas Date: Wed, 15 Jul 2026 12:35:21 +0200 Subject: [PATCH 1/4] Add repository brand icon endpoint Serve brand icons for integrations from HACS itself, now that custom integrations ship brand images in the repository and home-assistant/brands no longer accepts them: - Downloaded integrations are served from the local brand folder. - Other integrations are fetched from the repository content on GitHub and cached on disk, keyed by the version they were fetched for. - Repositories without a brand icon are redirected to the brands CDN. --- custom_components/hacs/__init__.py | 2 + custom_components/hacs/brands.py | 209 ++++++++++++++++++ ...est-icon-view-downloaded-icon-missing.json | 9 + ..._brandstest-icon-view-downloaded-icon.json | 9 + ...-icon-view-not-found-0000000-icon-png.json | 9 + ...-icon-view-not-found-1296269-icon-png.json | 9 + ...iew-not-found-1296269-not-an-icon-png.json | 9 + ...t-icon-view-remote-dark-icon-fallback.json | 10 + ...icon-view-remote-icon-invalid-content.json | 10 + ...ndstest-icon-view-remote-icon-missing.json | 10 + ...test_brandstest-icon-view-remote-icon.json | 10 + tests/test_brands.py | 170 ++++++++++++++ 12 files changed, 466 insertions(+) create mode 100644 custom_components/hacs/brands.py create mode 100644 tests/snapshots/api-usage/tests/test_brandstest-icon-view-downloaded-icon-missing.json create mode 100644 tests/snapshots/api-usage/tests/test_brandstest-icon-view-downloaded-icon.json create mode 100644 tests/snapshots/api-usage/tests/test_brandstest-icon-view-not-found-0000000-icon-png.json create mode 100644 tests/snapshots/api-usage/tests/test_brandstest-icon-view-not-found-1296269-icon-png.json create mode 100644 tests/snapshots/api-usage/tests/test_brandstest-icon-view-not-found-1296269-not-an-icon-png.json create mode 100644 tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-dark-icon-fallback.json create mode 100644 tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-icon-invalid-content.json create mode 100644 tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-icon-missing.json create mode 100644 tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-icon.json create mode 100644 tests/test_brands.py diff --git a/custom_components/hacs/__init__.py b/custom_components/hacs/__init__.py index cca5b8f34fd..e4c14b4f864 100644 --- a/custom_components/hacs/__init__.py +++ b/custom_components/hacs/__init__.py @@ -23,6 +23,7 @@ from homeassistant.loader import async_get_integration from .base import HacsBase +from .brands import async_register_icon_view from .const import DOMAIN, HACS_SYSTEM_ID, MINIMUM_HA_VERSION from .data_client import HacsDataClient from .enums import HacsDisabledReason, HacsStage, LovelaceMode @@ -143,6 +144,7 @@ async def async_startup(): async_register_websocket_commands(hass) await async_register_frontend(hass, hacs) + async_register_icon_view(hass, hacs) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) diff --git a/custom_components/hacs/brands.py b/custom_components/hacs/brands.py new file mode 100644 index 00000000000..4472db119dd --- /dev/null +++ b/custom_components/hacs/brands.py @@ -0,0 +1,209 @@ +"""Serve brand icons for repositories managed by HACS. + +Custom integrations ship their brand images in a local brand folder +(custom_components//brand/) since Home Assistant 2026.3, and +home-assistant/brands no longer accepts images for custom integrations. + +This view serves those icons to the HACS frontend: +- Downloaded integrations are served straight from the local brand folder. +- Integrations that are not downloaded are fetched from the repository + content on GitHub and cached on disk. The cache is keyed by the version + the icon was fetched for, so a new release invalidates it automatically. +- Repositories without a brand icon are redirected to the brands CDN, + which still hosts previously accepted images. +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +import re +import time +from typing import TYPE_CHECKING + +from aiohttp import web + +from homeassistant.components.http import HomeAssistantView +from homeassistant.core import HomeAssistant, callback + +from .enums import HacsCategory +from .exceptions import HacsException + +if TYPE_CHECKING: + from .base import HacsBase + from .repositories.base import HacsRepository + +URL_BASE = "/api/hacs/repository" +BRANDS_CDN_URL = "https://brands.home-assistant.io/_" +CACHE_DIR = ".storage/hacs.icons" +CACHE_CONTROL = f"public, max-age={60 * 60 * 24}" +NEGATIVE_CACHE_TTL = 60 * 60 * 24 * 7 +MAX_ICON_SIZE = 1024 * 1024 +PNG_MAGIC = b"\x89PNG\r\n\x1a\n" +VALID_FILENAMES = ("icon.png", "dark_icon.png") + +_DOMAIN_RE = re.compile(r"[a-z0-9_]+") + +_VIEW_REGISTERED = "hacs_repository_icon_view_registered" + + +def _read_file(path: Path) -> bytes | None: + """Read a file, returning None if it can not be read.""" + try: + return path.read_bytes() + except OSError: + return None + + +def _cache_lookup(cache_file: Path, marker_file: Path) -> tuple[bytes | None, bool]: + """Return cached icon content and whether a fresh negative marker exists.""" + if (content := _read_file(cache_file)) is not None: + return content, False + try: + fresh = (time.time() - marker_file.stat().st_mtime) < NEGATIVE_CACHE_TTL + except OSError: + fresh = False + return None, fresh + + +def _cache_write( + cache_dir: Path, + prefix: str, + filename: str, + target: Path, + content: bytes | None, +) -> None: + """Write icon content (or a negative marker) and drop entries for old versions.""" + cache_dir.mkdir(parents=True, exist_ok=True) + for stale in ( + *cache_dir.glob(f"{prefix}*-{filename}"), + *cache_dir.glob(f"{prefix}*-{filename}.missing"), + ): + if stale != target: + stale.unlink(missing_ok=True) + if content is None: + target.touch() + else: + target.write_bytes(content) + + +class HacsRepositoryIconView(HomeAssistantView): + """Serve brand icons for HACS repositories.""" + + url = f"{URL_BASE}/{{repository_id}}/{{filename}}" + name = "api:hacs:repository:icon" + requires_auth = False + + def __init__(self, hacs: HacsBase) -> None: + """Initialize the view.""" + self.hacs = hacs + self._cache_dir = Path(hacs.hass.config.path(CACHE_DIR)) + self._locks: dict[str, asyncio.Lock] = {} + + async def get( + self, + request: web.Request, + repository_id: str, + filename: str, + ) -> web.StreamResponse: + """Serve the brand icon for a repository.""" + repository = self.hacs.repositories.get_by_id(repository_id) + if ( + filename not in VALID_FILENAMES + or repository is None + or repository.data.category != HacsCategory.INTEGRATION + or not repository.data.domain + or not _DOMAIN_RE.fullmatch(repository.data.domain) + ): + raise web.HTTPNotFound + + if repository.data.installed: + return await self._async_serve_local(repository, filename) + return await self._async_serve_remote(repository, filename) + + async def _async_serve_local( + self, repository: HacsRepository, filename: str + ) -> web.StreamResponse: + """Serve the icon from the downloaded integration.""" + brand_path = Path( + self.hacs.hass.config.path( + "custom_components", repository.data.domain, "brand", filename + ) + ) + content = await self.hacs.hass.async_add_executor_job(_read_file, brand_path) + if content is not None: + return self._icon_response(content) + return self._fallback_response(repository, filename) + + async def _async_serve_remote( + self, repository: HacsRepository, filename: str + ) -> web.StreamResponse: + """Serve the icon from the GitHub repository content, with caching.""" + if (ref := repository.data.last_version or repository.data.default_branch) is None: + return self._fallback_response(repository, filename) + + prefix = f"{repository.data.id}-" + safe_ref = re.sub(r"[^A-Za-z0-9._-]", "_", ref) + cache_file = self._cache_dir / f"{prefix}{safe_ref}-{filename}" + marker_file = cache_file.parent / f"{cache_file.name}.missing" + + lock = self._locks.setdefault(str(repository.data.id), asyncio.Lock()) + async with lock: + content, missing = await self.hacs.hass.async_add_executor_job( + _cache_lookup, cache_file, marker_file + ) + if content is None and not missing: + content = await self._async_download_icon(repository, ref, filename) + await self.hacs.hass.async_add_executor_job( + _cache_write, + self._cache_dir, + prefix, + filename, + cache_file if content is not None else marker_file, + content, + ) + + if content is not None: + return self._icon_response(content) + return self._fallback_response(repository, filename) + + async def _async_download_icon( + self, repository: HacsRepository, ref: str, filename: str + ) -> bytes | None: + """Download the icon from the repository content.""" + url = ( + f"https://raw.githubusercontent.com/{repository.data.full_name}/{ref}" + f"/custom_components/{repository.data.domain}/brand/{filename}" + ) + try: + content = await self.hacs.async_download_file(url, keep_url=True, nolog=True) + except HacsException: + return None + if content is None or len(content) > MAX_ICON_SIZE or not content.startswith(PNG_MAGIC): + return None + return content + + def _icon_response(self, content: bytes) -> web.Response: + """Return the icon content.""" + return web.Response( + body=content, + content_type="image/png", + headers={"Cache-Control": CACHE_CONTROL}, + ) + + def _fallback_response(self, repository: HacsRepository, filename: str) -> web.StreamResponse: + """Redirect to the icon variant, or to the brands CDN.""" + if filename != "icon.png": + location = f"{URL_BASE}/{repository.data.id}/icon.png" + else: + location = f"{BRANDS_CDN_URL}/{repository.data.domain}/{filename}" + return web.HTTPFound(location, headers={"Cache-Control": CACHE_CONTROL}) + + +@callback +def async_register_icon_view(hass: HomeAssistant, hacs: HacsBase) -> None: + """Register the repository icon view.""" + if hass.data.get(_VIEW_REGISTERED): + return + hass.data[_VIEW_REGISTERED] = True + hass.http.register_view(HacsRepositoryIconView(hacs)) diff --git a/tests/snapshots/api-usage/tests/test_brandstest-icon-view-downloaded-icon-missing.json b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-downloaded-icon-missing.json new file mode 100644 index 00000000000..d5ad7c2d8fc --- /dev/null +++ b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-downloaded-icon-missing.json @@ -0,0 +1,9 @@ +{ + "tests/test_brands.py::test_icon_view_downloaded_icon_missing": { + "https://api.github.com/repos/hacs/integration": 1, + "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, + "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, + "https://api.github.com/repos/hacs/integration/git/trees/main": 1, + "https://api.github.com/repos/hacs/integration/releases": 1 + } +} \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/test_brandstest-icon-view-downloaded-icon.json b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-downloaded-icon.json new file mode 100644 index 00000000000..98350236626 --- /dev/null +++ b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-downloaded-icon.json @@ -0,0 +1,9 @@ +{ + "tests/test_brands.py::test_icon_view_downloaded_icon": { + "https://api.github.com/repos/hacs/integration": 1, + "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, + "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, + "https://api.github.com/repos/hacs/integration/git/trees/main": 1, + "https://api.github.com/repos/hacs/integration/releases": 1 + } +} \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/test_brandstest-icon-view-not-found-0000000-icon-png.json b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-not-found-0000000-icon-png.json new file mode 100644 index 00000000000..68ef78beb7c --- /dev/null +++ b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-not-found-0000000-icon-png.json @@ -0,0 +1,9 @@ +{ + "tests/test_brands.py::test_icon_view_not_found[0000000-icon.png]": { + "https://api.github.com/repos/hacs/integration": 1, + "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, + "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, + "https://api.github.com/repos/hacs/integration/git/trees/main": 1, + "https://api.github.com/repos/hacs/integration/releases": 1 + } +} \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/test_brandstest-icon-view-not-found-1296269-icon-png.json b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-not-found-1296269-icon-png.json new file mode 100644 index 00000000000..ad07f1dfa08 --- /dev/null +++ b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-not-found-1296269-icon-png.json @@ -0,0 +1,9 @@ +{ + "tests/test_brands.py::test_icon_view_not_found[1296269-../icon.png]": { + "https://api.github.com/repos/hacs/integration": 1, + "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, + "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, + "https://api.github.com/repos/hacs/integration/git/trees/main": 1, + "https://api.github.com/repos/hacs/integration/releases": 1 + } +} \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/test_brandstest-icon-view-not-found-1296269-not-an-icon-png.json b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-not-found-1296269-not-an-icon-png.json new file mode 100644 index 00000000000..b38a5e4bf24 --- /dev/null +++ b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-not-found-1296269-not-an-icon-png.json @@ -0,0 +1,9 @@ +{ + "tests/test_brands.py::test_icon_view_not_found[1296269-not_an_icon.png]": { + "https://api.github.com/repos/hacs/integration": 1, + "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, + "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, + "https://api.github.com/repos/hacs/integration/git/trees/main": 1, + "https://api.github.com/repos/hacs/integration/releases": 1 + } +} \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-dark-icon-fallback.json b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-dark-icon-fallback.json new file mode 100644 index 00000000000..5cc1633aaea --- /dev/null +++ b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-dark-icon-fallback.json @@ -0,0 +1,10 @@ +{ + "tests/test_brands.py::test_icon_view_remote_dark_icon_fallback": { + "https://api.github.com/repos/hacs/integration": 1, + "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, + "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, + "https://api.github.com/repos/hacs/integration/git/trees/main": 1, + "https://api.github.com/repos/hacs/integration/releases": 1, + "https://raw.githubusercontent.com/hacs-test-org/integration-basic/1.0.0/custom_components/example/brand/dark_icon.png": 1 + } +} \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-icon-invalid-content.json b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-icon-invalid-content.json new file mode 100644 index 00000000000..72fe4a7f0b3 --- /dev/null +++ b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-icon-invalid-content.json @@ -0,0 +1,10 @@ +{ + "tests/test_brands.py::test_icon_view_remote_icon_invalid_content": { + "https://api.github.com/repos/hacs/integration": 1, + "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, + "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, + "https://api.github.com/repos/hacs/integration/git/trees/main": 1, + "https://api.github.com/repos/hacs/integration/releases": 1, + "https://raw.githubusercontent.com/hacs-test-org/integration-basic/1.0.0/custom_components/example/brand/icon.png": 1 + } +} \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-icon-missing.json b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-icon-missing.json new file mode 100644 index 00000000000..0405eac62a1 --- /dev/null +++ b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-icon-missing.json @@ -0,0 +1,10 @@ +{ + "tests/test_brands.py::test_icon_view_remote_icon_missing": { + "https://api.github.com/repos/hacs/integration": 1, + "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, + "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, + "https://api.github.com/repos/hacs/integration/git/trees/main": 1, + "https://api.github.com/repos/hacs/integration/releases": 1, + "https://raw.githubusercontent.com/hacs-test-org/integration-basic/1.0.0/custom_components/example/brand/icon.png": 1 + } +} \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-icon.json b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-icon.json new file mode 100644 index 00000000000..6f36df4e67e --- /dev/null +++ b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-icon.json @@ -0,0 +1,10 @@ +{ + "tests/test_brands.py::test_icon_view_remote_icon": { + "https://api.github.com/repos/hacs/integration": 1, + "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, + "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, + "https://api.github.com/repos/hacs/integration/git/trees/main": 1, + "https://api.github.com/repos/hacs/integration/releases": 1, + "https://raw.githubusercontent.com/hacs-test-org/integration-basic/1.0.0/custom_components/example/brand/icon.png": 1 + } +} \ No newline at end of file diff --git a/tests/test_brands.py b/tests/test_brands.py new file mode 100644 index 00000000000..b3a80ddf330 --- /dev/null +++ b/tests/test_brands.py @@ -0,0 +1,170 @@ +"""Test the repository brand icon view.""" + +from collections.abc import Generator +import os + +from aiohttp import web +from aiohttp.test_utils import make_mocked_request +from homeassistant.core import HomeAssistant +import pytest + +from custom_components.hacs.brands import PNG_MAGIC, HacsRepositoryIconView + +from tests.common import MockedResponse, ResponseMocker, get_hacs + +REPOSITORY_ID = "1296269" +REPOSITORY_FULL_NAME = "hacs-test-org/integration-basic" +ICON_CONTENT = PNG_MAGIC + b"icon-content" +DARK_ICON_CONTENT = PNG_MAGIC + b"dark-icon-content" +RAW_ICON_URL = ( + "https://raw.githubusercontent.com/hacs-test-org/integration-basic" + "/1.0.0/custom_components/example/brand/icon.png" +) +RAW_DARK_ICON_URL = ( + "https://raw.githubusercontent.com/hacs-test-org/integration-basic" + "/1.0.0/custom_components/example/brand/dark_icon.png" +) + + +async def _get_icon(hass: HomeAssistant, repository_id: str, filename: str) -> web.StreamResponse: + view = HacsRepositoryIconView(get_hacs(hass)) + request = make_mocked_request("GET", f"/api/hacs/repository/{repository_id}/{filename}") + return await view.get(request, repository_id=repository_id, filename=filename) + + +@pytest.mark.parametrize( + ("repository_id", "filename"), + [ + (REPOSITORY_ID, "not_an_icon.png"), + (REPOSITORY_ID, "../icon.png"), + ("0000000", "icon.png"), + ], +) +async def test_icon_view_not_found( + hass: HomeAssistant, + setup_integration: Generator, + repository_id: str, + filename: str, +) -> None: + """Test that unknown repositories and unexpected filenames return 404.""" + with pytest.raises(web.HTTPNotFound): + await _get_icon(hass, repository_id, filename) + + +async def test_icon_view_remote_icon( + hass: HomeAssistant, + setup_integration: Generator, + response_mocker: ResponseMocker, +) -> None: + """Test serving the icon of a repository that is not downloaded.""" + response_mocker.add(RAW_ICON_URL, MockedResponse(content=ICON_CONTENT)) + + response = await _get_icon(hass, REPOSITORY_ID, "icon.png") + + assert response.status == 200 + assert response.body == ICON_CONTENT + assert response.content_type == "image/png" + assert "max-age" in response.headers["Cache-Control"] + + cache_file = hass.config.path(".storage/hacs.icons/1296269-1.0.0-icon.png") + assert os.path.exists(cache_file) + + # A second request is served from the cache without hitting GitHub again, + # the mocked response was consumed by the first request. + response = await _get_icon(hass, REPOSITORY_ID, "icon.png") + assert response.status == 200 + assert response.body == ICON_CONTENT + + +async def test_icon_view_remote_icon_missing( + hass: HomeAssistant, + setup_integration: Generator, + response_mocker: ResponseMocker, +) -> None: + """Test redirecting to the brands CDN when the repository has no icon.""" + response_mocker.add(RAW_ICON_URL, MockedResponse(status=404)) + + response = await _get_icon(hass, REPOSITORY_ID, "icon.png") + + assert response.status == 302 + assert response.headers["Location"] == "https://brands.home-assistant.io/_/example/icon.png" + + marker_file = hass.config.path(".storage/hacs.icons/1296269-1.0.0-icon.png.missing") + assert os.path.exists(marker_file) + + # The negative result is cached, GitHub is not asked again. + response = await _get_icon(hass, REPOSITORY_ID, "icon.png") + assert response.status == 302 + + +async def test_icon_view_remote_icon_invalid_content( + hass: HomeAssistant, + setup_integration: Generator, + response_mocker: ResponseMocker, +) -> None: + """Test that content that is not a PNG image is not served.""" + response_mocker.add(RAW_ICON_URL, MockedResponse(content=b"not a png")) + + response = await _get_icon(hass, REPOSITORY_ID, "icon.png") + + assert response.status == 302 + assert response.headers["Location"] == "https://brands.home-assistant.io/_/example/icon.png" + + +async def test_icon_view_remote_dark_icon_fallback( + hass: HomeAssistant, + setup_integration: Generator, + response_mocker: ResponseMocker, +) -> None: + """Test redirecting to the regular icon when there is no dark variant.""" + response_mocker.add(RAW_DARK_ICON_URL, MockedResponse(status=404)) + + response = await _get_icon(hass, REPOSITORY_ID, "dark_icon.png") + + assert response.status == 302 + assert response.headers["Location"] == f"/api/hacs/repository/{REPOSITORY_ID}/icon.png" + + +async def test_icon_view_downloaded_icon( + hass: HomeAssistant, + setup_integration: Generator, +) -> None: + """Test serving the local icon of a downloaded repository.""" + hacs = get_hacs(hass) + repository = hacs.repositories.get_by_full_name(REPOSITORY_FULL_NAME) + repository.data.installed = True + + brand_dir = hass.config.path("custom_components/example/brand") + + def _write_icon() -> None: + os.makedirs(brand_dir, exist_ok=True) + with open(os.path.join(brand_dir, "icon.png"), mode="wb") as icon_file: + icon_file.write(ICON_CONTENT) + + await hass.async_add_executor_job(_write_icon) + + response = await _get_icon(hass, REPOSITORY_ID, "icon.png") + + assert response.status == 200 + assert response.body == ICON_CONTENT + + # The dark variant does not exist, redirect to the regular icon. + response = await _get_icon(hass, REPOSITORY_ID, "dark_icon.png") + + assert response.status == 302 + assert response.headers["Location"] == f"/api/hacs/repository/{REPOSITORY_ID}/icon.png" + + +async def test_icon_view_downloaded_icon_missing( + hass: HomeAssistant, + setup_integration: Generator, +) -> None: + """Test redirecting to the brands CDN when the download has no brand folder.""" + hacs = get_hacs(hass) + repository = hacs.repositories.get_by_full_name(REPOSITORY_FULL_NAME) + repository.data.installed = True + + response = await _get_icon(hass, REPOSITORY_ID, "icon.png") + + assert response.status == 302 + assert response.headers["Location"] == "https://brands.home-assistant.io/_/example/icon.png" From d1b6f66b482327c4a1adeb21517bc88b4ed21928 Mon Sep 17 00:00:00 2001 From: Niek van der Maas Date: Wed, 15 Jul 2026 13:30:10 +0200 Subject: [PATCH 2/4] Validate local icons, encode cache ref, drop redundant exception handling Address review feedback: - Local and cached icons go through the same PNG magic-byte and size validation as downloaded ones. - The cache filename now percent-encodes the ref so distinct refs can not collide. - async_download_file already returns None on failure, so the try/except around it was dead code. --- custom_components/hacs/brands.py | 26 ++++++++++--------- ...-view-downloaded-icon-invalid-content.json | 9 +++++++ tests/test_brands.py | 24 +++++++++++++++++ 3 files changed, 47 insertions(+), 12 deletions(-) create mode 100644 tests/snapshots/api-usage/tests/test_brandstest-icon-view-downloaded-icon-invalid-content.json diff --git a/custom_components/hacs/brands.py b/custom_components/hacs/brands.py index 4472db119dd..4124be2d599 100644 --- a/custom_components/hacs/brands.py +++ b/custom_components/hacs/brands.py @@ -20,6 +20,7 @@ import re import time from typing import TYPE_CHECKING +from urllib.parse import quote from aiohttp import web @@ -27,7 +28,6 @@ from homeassistant.core import HomeAssistant, callback from .enums import HacsCategory -from .exceptions import HacsException if TYPE_CHECKING: from .base import HacsBase @@ -55,9 +55,16 @@ def _read_file(path: Path) -> bytes | None: return None +def _validate_icon(content: bytes | None) -> bytes | None: + """Return the content if it is a PNG image within the size limit.""" + if content is None or len(content) > MAX_ICON_SIZE or not content.startswith(PNG_MAGIC): + return None + return content + + def _cache_lookup(cache_file: Path, marker_file: Path) -> tuple[bytes | None, bool]: """Return cached icon content and whether a fresh negative marker exists.""" - if (content := _read_file(cache_file)) is not None: + if (content := _validate_icon(_read_file(cache_file))) is not None: return content, False try: fresh = (time.time() - marker_file.stat().st_mtime) < NEGATIVE_CACHE_TTL @@ -130,7 +137,9 @@ async def _async_serve_local( "custom_components", repository.data.domain, "brand", filename ) ) - content = await self.hacs.hass.async_add_executor_job(_read_file, brand_path) + content = _validate_icon( + await self.hacs.hass.async_add_executor_job(_read_file, brand_path) + ) if content is not None: return self._icon_response(content) return self._fallback_response(repository, filename) @@ -143,8 +152,7 @@ async def _async_serve_remote( return self._fallback_response(repository, filename) prefix = f"{repository.data.id}-" - safe_ref = re.sub(r"[^A-Za-z0-9._-]", "_", ref) - cache_file = self._cache_dir / f"{prefix}{safe_ref}-{filename}" + cache_file = self._cache_dir / f"{prefix}{quote(ref, safe='')}-{filename}" marker_file = cache_file.parent / f"{cache_file.name}.missing" lock = self._locks.setdefault(str(repository.data.id), asyncio.Lock()) @@ -175,13 +183,7 @@ async def _async_download_icon( f"https://raw.githubusercontent.com/{repository.data.full_name}/{ref}" f"/custom_components/{repository.data.domain}/brand/{filename}" ) - try: - content = await self.hacs.async_download_file(url, keep_url=True, nolog=True) - except HacsException: - return None - if content is None or len(content) > MAX_ICON_SIZE or not content.startswith(PNG_MAGIC): - return None - return content + return _validate_icon(await self.hacs.async_download_file(url, keep_url=True, nolog=True)) def _icon_response(self, content: bytes) -> web.Response: """Return the icon content.""" diff --git a/tests/snapshots/api-usage/tests/test_brandstest-icon-view-downloaded-icon-invalid-content.json b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-downloaded-icon-invalid-content.json new file mode 100644 index 00000000000..665f0bfb647 --- /dev/null +++ b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-downloaded-icon-invalid-content.json @@ -0,0 +1,9 @@ +{ + "tests/test_brands.py::test_icon_view_downloaded_icon_invalid_content": { + "https://api.github.com/repos/hacs/integration": 1, + "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, + "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, + "https://api.github.com/repos/hacs/integration/git/trees/main": 1, + "https://api.github.com/repos/hacs/integration/releases": 1 + } +} \ No newline at end of file diff --git a/tests/test_brands.py b/tests/test_brands.py index b3a80ddf330..92e1849036b 100644 --- a/tests/test_brands.py +++ b/tests/test_brands.py @@ -155,6 +155,30 @@ def _write_icon() -> None: assert response.headers["Location"] == f"/api/hacs/repository/{REPOSITORY_ID}/icon.png" +async def test_icon_view_downloaded_icon_invalid_content( + hass: HomeAssistant, + setup_integration: Generator, +) -> None: + """Test that a local file that is not a PNG image is not served.""" + hacs = get_hacs(hass) + repository = hacs.repositories.get_by_full_name(REPOSITORY_FULL_NAME) + repository.data.installed = True + + brand_dir = hass.config.path("custom_components/example/brand") + + def _write_icon() -> None: + os.makedirs(brand_dir, exist_ok=True) + with open(os.path.join(brand_dir, "icon.png"), mode="wb") as icon_file: + icon_file.write(b"not a png") + + await hass.async_add_executor_job(_write_icon) + + response = await _get_icon(hass, REPOSITORY_ID, "icon.png") + + assert response.status == 302 + assert response.headers["Location"] == "https://brands.home-assistant.io/_/example/icon.png" + + async def test_icon_view_downloaded_icon_missing( hass: HomeAssistant, setup_integration: Generator, From 14dd794c63d36d51fe5c489cc2dade197b3b2693 Mon Sep 17 00:00:00 2001 From: Niek van der Maas Date: Wed, 15 Jul 2026 14:58:05 +0200 Subject: [PATCH 3/4] fix: harden repository brand icon serving --- custom_components/hacs/__init__.py | 2 +- custom_components/hacs/brands.py | 153 +++++++++++++----- tests/common.py | 17 ++ ...-view-remote-branch-cache-uses-commit.json | 10 ++ ...test-icon-view-remote-content-in-root.json | 10 ++ ...-icon-view-remote-error-is-not-cached.json | 10 ++ ...test-icon-view-remote-icon-size-limit.json | 10 ++ tests/test_brands.py | 141 +++++++++++++++- 8 files changed, 304 insertions(+), 49 deletions(-) create mode 100644 tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-branch-cache-uses-commit.json create mode 100644 tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-content-in-root.json create mode 100644 tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-error-is-not-cached.json create mode 100644 tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-icon-size-limit.json diff --git a/custom_components/hacs/__init__.py b/custom_components/hacs/__init__.py index e4c14b4f864..72cb973fa26 100644 --- a/custom_components/hacs/__init__.py +++ b/custom_components/hacs/__init__.py @@ -144,7 +144,7 @@ async def async_startup(): async_register_websocket_commands(hass) await async_register_frontend(hass, hacs) - async_register_icon_view(hass, hacs) + async_register_icon_view(hass) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) diff --git a/custom_components/hacs/brands.py b/custom_components/hacs/brands.py index 4124be2d599..eb93cde0d5b 100644 --- a/custom_components/hacs/brands.py +++ b/custom_components/hacs/brands.py @@ -16,17 +16,19 @@ from __future__ import annotations import asyncio +from http import HTTPStatus from pathlib import Path import re import time from typing import TYPE_CHECKING from urllib.parse import quote -from aiohttp import web +from aiohttp import ClientError, ClientTimeout, hdrs, web -from homeassistant.components.http import HomeAssistantView +from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView from homeassistant.core import HomeAssistant, callback +from .const import DOMAIN from .enums import HacsCategory if TYPE_CHECKING: @@ -37,14 +39,17 @@ BRANDS_CDN_URL = "https://brands.home-assistant.io/_" CACHE_DIR = ".storage/hacs.icons" CACHE_CONTROL = f"public, max-age={60 * 60 * 24}" +NO_CACHE = "no-store" NEGATIVE_CACHE_TTL = 60 * 60 * 24 * 7 MAX_ICON_SIZE = 1024 * 1024 +DOWNLOAD_CHUNK_SIZE = 64 * 1024 PNG_MAGIC = b"\x89PNG\r\n\x1a\n" VALID_FILENAMES = ("icon.png", "dark_icon.png") +BRANDS_DOMAIN = "brands" _DOMAIN_RE = re.compile(r"[a-z0-9_]+") -_VIEW_REGISTERED = "hacs_repository_icon_view_registered" +_VIEW_REGISTERED = "hacs_repository_icon_view" def _read_file(path: Path) -> bytes | None: @@ -101,12 +106,24 @@ class HacsRepositoryIconView(HomeAssistantView): name = "api:hacs:repository:icon" requires_auth = False - def __init__(self, hacs: HacsBase) -> None: + def __init__(self, hass: HomeAssistant) -> None: """Initialize the view.""" - self.hacs = hacs - self._cache_dir = Path(hacs.hass.config.path(CACHE_DIR)) + self._hass = hass + self._cache_dir = Path(hass.config.path(CACHE_DIR)) self._locks: dict[str, asyncio.Lock] = {} + def _authenticate(self, request: web.Request) -> None: + """Authenticate with Home Assistant or a brands access token.""" + access_tokens = self._hass.data.get(BRANDS_DOMAIN, ()) + authenticated = request.get(KEY_AUTHENTICATED, False) or ( + request.query.get("token") in access_tokens + ) + if authenticated: + return + if hdrs.AUTHORIZATION in request.headers: + raise web.HTTPUnauthorized + raise web.HTTPForbidden + async def get( self, request: web.Request, @@ -114,7 +131,11 @@ async def get( filename: str, ) -> web.StreamResponse: """Serve the brand icon for a repository.""" - repository = self.hacs.repositories.get_by_id(repository_id) + self._authenticate(request) + if (hacs := self._hass.data.get(DOMAIN)) is None: + raise web.HTTPServiceUnavailable + + repository = hacs.repositories.get_by_id(repository_id) if ( filename not in VALID_FILENAMES or repository is None @@ -124,66 +145,101 @@ async def get( ): raise web.HTTPNotFound + token = request.query.get("token") if repository.data.installed: - return await self._async_serve_local(repository, filename) - return await self._async_serve_remote(repository, filename) + return await self._async_serve_local(repository, filename, token) + return await self._async_serve_remote(hacs, repository, filename, token) async def _async_serve_local( - self, repository: HacsRepository, filename: str + self, repository: HacsRepository, filename: str, token: str | None ) -> web.StreamResponse: """Serve the icon from the downloaded integration.""" brand_path = Path( - self.hacs.hass.config.path( - "custom_components", repository.data.domain, "brand", filename - ) - ) - content = _validate_icon( - await self.hacs.hass.async_add_executor_job(_read_file, brand_path) + self._hass.config.path("custom_components", repository.data.domain, "brand", filename) ) + content = _validate_icon(await self._hass.async_add_executor_job(_read_file, brand_path)) if content is not None: return self._icon_response(content) - return self._fallback_response(repository, filename) + return self._fallback_response(repository, filename, token=token) async def _async_serve_remote( - self, repository: HacsRepository, filename: str + self, + hacs: HacsBase, + repository: HacsRepository, + filename: str, + token: str | None, ) -> web.StreamResponse: """Serve the icon from the GitHub repository content, with caching.""" - if (ref := repository.data.last_version or repository.data.default_branch) is None: - return self._fallback_response(repository, filename) + ref = repository.data.last_version or repository.data.default_branch + cache_ref = repository.data.last_version or repository.data.last_commit or ref + if ref is None or cache_ref is None: + return self._fallback_response(repository, filename, token=token) prefix = f"{repository.data.id}-" - cache_file = self._cache_dir / f"{prefix}{quote(ref, safe='')}-{filename}" + cache_file = self._cache_dir / f"{prefix}{quote(cache_ref, safe='')}-{filename}" marker_file = cache_file.parent / f"{cache_file.name}.missing" + retry = False lock = self._locks.setdefault(str(repository.data.id), asyncio.Lock()) async with lock: - content, missing = await self.hacs.hass.async_add_executor_job( + content, missing = await self._hass.async_add_executor_job( _cache_lookup, cache_file, marker_file ) if content is None and not missing: - content = await self._async_download_icon(repository, ref, filename) - await self.hacs.hass.async_add_executor_job( - _cache_write, - self._cache_dir, - prefix, - filename, - cache_file if content is not None else marker_file, - content, + content, cache_missing = await self._async_download_icon( + hacs, repository, ref, filename ) + if content is not None or cache_missing: + await self._hass.async_add_executor_job( + _cache_write, + self._cache_dir, + prefix, + filename, + cache_file if content is not None else marker_file, + content, + ) + else: + retry = True if content is not None: return self._icon_response(content) - return self._fallback_response(repository, filename) + return self._fallback_response(repository, filename, cache=not retry, token=token) async def _async_download_icon( - self, repository: HacsRepository, ref: str, filename: str - ) -> bytes | None: - """Download the icon from the repository content.""" + self, hacs: HacsBase, repository: HacsRepository, ref: str, filename: str + ) -> tuple[bytes | None, bool]: + """Download the icon and report whether a missing result can be cached.""" + brand_path = ( + "brand" + if repository.repository_manifest.content_in_root + else f"custom_components/{repository.data.domain}/brand" + ) url = ( - f"https://raw.githubusercontent.com/{repository.data.full_name}/{ref}" - f"/custom_components/{repository.data.domain}/brand/{filename}" + f"https://raw.githubusercontent.com/{repository.data.full_name}/" + f"{quote(ref, safe='/')}/{brand_path}/{filename}" ) - return _validate_icon(await self.hacs.async_download_file(url, keep_url=True, nolog=True)) + try: + response = await hacs.session.get(url, timeout=ClientTimeout(total=60)) + except (ClientError, TimeoutError): + return None, False + + try: + if response.status == HTTPStatus.NOT_FOUND: + return None, True + if response.status != HTTPStatus.OK: + return None, False + + content = bytearray() + async for chunk in response.content.iter_chunked(DOWNLOAD_CHUNK_SIZE): + content.extend(chunk) + if len(content) > MAX_ICON_SIZE: + return None, True + validated = _validate_icon(bytes(content)) + return validated, validated is None + except (ClientError, TimeoutError): + return None, False + finally: + response.release() def _icon_response(self, content: bytes) -> web.Response: """Return the icon content.""" @@ -193,19 +249,32 @@ def _icon_response(self, content: bytes) -> web.Response: headers={"Cache-Control": CACHE_CONTROL}, ) - def _fallback_response(self, repository: HacsRepository, filename: str) -> web.StreamResponse: + def _fallback_response( + self, + repository: HacsRepository, + filename: str, + *, + cache: bool = True, + token: str | None = None, + ) -> web.StreamResponse: """Redirect to the icon variant, or to the brands CDN.""" if filename != "icon.png": location = f"{URL_BASE}/{repository.data.id}/icon.png" + if token is not None: + location = f"{location}?token={quote(token, safe='')}" else: location = f"{BRANDS_CDN_URL}/{repository.data.domain}/{filename}" - return web.HTTPFound(location, headers={"Cache-Control": CACHE_CONTROL}) + return web.HTTPFound( + location, + headers={"Cache-Control": CACHE_CONTROL if cache else NO_CACHE}, + ) @callback -def async_register_icon_view(hass: HomeAssistant, hacs: HacsBase) -> None: +def async_register_icon_view(hass: HomeAssistant) -> None: """Register the repository icon view.""" if hass.data.get(_VIEW_REGISTERED): return - hass.data[_VIEW_REGISTERED] = True - hass.http.register_view(HacsRepositoryIconView(hacs)) + view = HacsRepositoryIconView(hass) + hass.data[_VIEW_REGISTERED] = view + hass.http.register_view(view) diff --git a/tests/common.py b/tests/common.py index f7f193374c8..0d47b5eb3a1 100644 --- a/tests/common.py +++ b/tests/common.py @@ -402,6 +402,10 @@ def url(self): def headers(self): return self.kwargs.get("headers", {}) + @property + def content(self): + return MockedResponseContent(self) + async def read(self, **kwargs): if (content := self.kwargs.get("content")) is not None: return content @@ -421,6 +425,19 @@ def raise_for_status(self) -> None: if self.status >= 300: raise ClientError(self.status) + def release(self) -> None: + """Release the mocked response.""" + + +class MockedResponseContent: + def __init__(self, response: MockedResponse) -> None: + self.response = response + + async def iter_chunked(self, size: int): + content = await self.response.read() + for offset in range(0, len(content), size): + yield content[offset : offset + size] + class ResponseMocker: calls: list[dict[str, Any]] = [] diff --git a/tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-branch-cache-uses-commit.json b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-branch-cache-uses-commit.json new file mode 100644 index 00000000000..681ea154220 --- /dev/null +++ b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-branch-cache-uses-commit.json @@ -0,0 +1,10 @@ +{ + "tests/test_brands.py::test_icon_view_remote_branch_cache_uses_commit": { + "https://api.github.com/repos/hacs/integration": 1, + "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, + "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, + "https://api.github.com/repos/hacs/integration/git/trees/main": 1, + "https://api.github.com/repos/hacs/integration/releases": 1, + "https://raw.githubusercontent.com/hacs-test-org/integration-basic/main/custom_components/example/brand/icon.png": 2 + } +} \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-content-in-root.json b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-content-in-root.json new file mode 100644 index 00000000000..d42b0f7acd7 --- /dev/null +++ b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-content-in-root.json @@ -0,0 +1,10 @@ +{ + "tests/test_brands.py::test_icon_view_remote_content_in_root": { + "https://api.github.com/repos/hacs/integration": 1, + "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, + "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, + "https://api.github.com/repos/hacs/integration/git/trees/main": 1, + "https://api.github.com/repos/hacs/integration/releases": 1, + "https://raw.githubusercontent.com/hacs-test-org/integration-basic/1.0.0/brand/icon.png": 1 + } +} \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-error-is-not-cached.json b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-error-is-not-cached.json new file mode 100644 index 00000000000..8b08054a57c --- /dev/null +++ b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-error-is-not-cached.json @@ -0,0 +1,10 @@ +{ + "tests/test_brands.py::test_icon_view_remote_error_is_not_cached": { + "https://api.github.com/repos/hacs/integration": 1, + "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, + "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, + "https://api.github.com/repos/hacs/integration/git/trees/main": 1, + "https://api.github.com/repos/hacs/integration/releases": 1, + "https://raw.githubusercontent.com/hacs-test-org/integration-basic/1.0.0/custom_components/example/brand/icon.png": 2 + } +} \ No newline at end of file diff --git a/tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-icon-size-limit.json b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-icon-size-limit.json new file mode 100644 index 00000000000..3e4eae2799c --- /dev/null +++ b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-icon-size-limit.json @@ -0,0 +1,10 @@ +{ + "tests/test_brands.py::test_icon_view_remote_icon_size_limit": { + "https://api.github.com/repos/hacs/integration": 1, + "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, + "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, + "https://api.github.com/repos/hacs/integration/git/trees/main": 1, + "https://api.github.com/repos/hacs/integration/releases": 1, + "https://raw.githubusercontent.com/hacs-test-org/integration-basic/1.0.0/custom_components/example/brand/icon.png": 1 + } +} \ No newline at end of file diff --git a/tests/test_brands.py b/tests/test_brands.py index 92e1849036b..fb878b2e313 100644 --- a/tests/test_brands.py +++ b/tests/test_brands.py @@ -2,13 +2,15 @@ from collections.abc import Generator import os +from unittest.mock import MagicMock from aiohttp import web from aiohttp.test_utils import make_mocked_request from homeassistant.core import HomeAssistant import pytest -from custom_components.hacs.brands import PNG_MAGIC, HacsRepositoryIconView +from custom_components.hacs.brands import MAX_ICON_SIZE, PNG_MAGIC, HacsRepositoryIconView +from custom_components.hacs.const import DOMAIN from tests.common import MockedResponse, ResponseMocker, get_hacs @@ -16,6 +18,7 @@ REPOSITORY_FULL_NAME = "hacs-test-org/integration-basic" ICON_CONTENT = PNG_MAGIC + b"icon-content" DARK_ICON_CONTENT = PNG_MAGIC + b"dark-icon-content" +ACCESS_TOKEN = "test-token" RAW_ICON_URL = ( "https://raw.githubusercontent.com/hacs-test-org/integration-basic" "/1.0.0/custom_components/example/brand/icon.png" @@ -26,12 +29,31 @@ ) -async def _get_icon(hass: HomeAssistant, repository_id: str, filename: str) -> web.StreamResponse: - view = HacsRepositoryIconView(get_hacs(hass)) - request = make_mocked_request("GET", f"/api/hacs/repository/{repository_id}/{filename}") +async def _get_icon( + hass: HomeAssistant, + repository_id: str, + filename: str, + *, + authenticated: bool = True, + view: HacsRepositoryIconView | None = None, +) -> web.StreamResponse: + view = view or HacsRepositoryIconView(hass) + url = f"/api/hacs/repository/{repository_id}/{filename}" + if authenticated: + hass.data["brands"] = (ACCESS_TOKEN,) + url = f"{url}?token={ACCESS_TOKEN}" + request = make_mocked_request("GET", url) return await view.get(request, repository_id=repository_id, filename=filename) +async def test_icon_view_requires_authentication( + hass: HomeAssistant, +) -> None: + """Test that unauthenticated icon requests are rejected.""" + with pytest.raises(web.HTTPForbidden): + await _get_icon(hass, REPOSITORY_ID, "icon.png", authenticated=False) + + @pytest.mark.parametrize( ("repository_id", "filename"), [ @@ -122,7 +144,112 @@ async def test_icon_view_remote_dark_icon_fallback( response = await _get_icon(hass, REPOSITORY_ID, "dark_icon.png") assert response.status == 302 - assert response.headers["Location"] == f"/api/hacs/repository/{REPOSITORY_ID}/icon.png" + assert response.headers["Location"] == ( + f"/api/hacs/repository/{REPOSITORY_ID}/icon.png?token={ACCESS_TOKEN}" + ) + + +async def test_icon_view_remote_error_is_not_cached( + hass: HomeAssistant, + setup_integration: Generator, + response_mocker: ResponseMocker, +) -> None: + """Test that transient download errors are retried on the next request.""" + response_mocker.add(RAW_ICON_URL, MockedResponse(status=500)) + + response = await _get_icon(hass, REPOSITORY_ID, "icon.png") + + assert response.status == 302 + assert response.headers["Cache-Control"] == "no-store" + marker_file = hass.config.path(".storage/hacs.icons/1296269-1.0.0-icon.png.missing") + assert not os.path.exists(marker_file) + + response_mocker.add(RAW_ICON_URL, MockedResponse(content=ICON_CONTENT)) + response = await _get_icon(hass, REPOSITORY_ID, "icon.png") + + assert response.status == 200 + assert response.body == ICON_CONTENT + + +async def test_icon_view_remote_icon_size_limit( + hass: HomeAssistant, + setup_integration: Generator, + response_mocker: ResponseMocker, +) -> None: + """Test that oversized remote icons are rejected while streaming.""" + response_mocker.add( + RAW_ICON_URL, + MockedResponse(content=PNG_MAGIC + bytes(MAX_ICON_SIZE)), + ) + + response = await _get_icon(hass, REPOSITORY_ID, "icon.png") + + assert response.status == 302 + marker_file = hass.config.path(".storage/hacs.icons/1296269-1.0.0-icon.png.missing") + assert os.path.exists(marker_file) + + +async def test_icon_view_remote_content_in_root( + hass: HomeAssistant, + setup_integration: Generator, + response_mocker: ResponseMocker, +) -> None: + """Test loading an icon from a repository with content in its root.""" + repository = get_hacs(hass).repositories.get_by_full_name(REPOSITORY_FULL_NAME) + repository.repository_manifest.content_in_root = True + response_mocker.add( + "https://raw.githubusercontent.com/hacs-test-org/integration-basic/1.0.0/brand/icon.png", + MockedResponse(content=ICON_CONTENT), + ) + + response = await _get_icon(hass, REPOSITORY_ID, "icon.png") + + assert response.status == 200 + assert response.body == ICON_CONTENT + + +async def test_icon_view_remote_branch_cache_uses_commit( + hass: HomeAssistant, + setup_integration: Generator, + response_mocker: ResponseMocker, +) -> None: + """Test refreshing cached branch icons when the latest commit changes.""" + repository = get_hacs(hass).repositories.get_by_full_name(REPOSITORY_FULL_NAME) + repository.data.last_version = None + repository.data.default_branch = "main" + repository.data.last_commit = "abc1234" + url = ( + "https://raw.githubusercontent.com/hacs-test-org/integration-basic" + "/main/custom_components/example/brand/icon.png" + ) + response_mocker.add(url, MockedResponse(content=ICON_CONTENT)) + + response = await _get_icon(hass, REPOSITORY_ID, "icon.png") + + assert response.status == 200 + assert os.path.exists(hass.config.path(".storage/hacs.icons/1296269-abc1234-icon.png")) + + repository.data.last_commit = "def5678" + response_mocker.add(url, MockedResponse(content=DARK_ICON_CONTENT)) + response = await _get_icon(hass, REPOSITORY_ID, "icon.png") + + assert response.status == 200 + assert response.body == DARK_ICON_CONTENT + + +async def test_icon_view_uses_current_hacs_instance( + hass: HomeAssistant, +) -> None: + """Test that a registered view follows the HACS instance across reloads.""" + view = HacsRepositoryIconView(hass) + replacement_hacs = MagicMock() + replacement_hacs.repositories.get_by_id.return_value = None + hass.data[DOMAIN] = replacement_hacs + + with pytest.raises(web.HTTPNotFound): + await _get_icon(hass, REPOSITORY_ID, "icon.png", view=view) + + replacement_hacs.repositories.get_by_id.assert_called_once_with(REPOSITORY_ID) async def test_icon_view_downloaded_icon( @@ -152,7 +279,9 @@ def _write_icon() -> None: response = await _get_icon(hass, REPOSITORY_ID, "dark_icon.png") assert response.status == 302 - assert response.headers["Location"] == f"/api/hacs/repository/{REPOSITORY_ID}/icon.png" + assert response.headers["Location"] == ( + f"/api/hacs/repository/{REPOSITORY_ID}/icon.png?token={ACCESS_TOKEN}" + ) async def test_icon_view_downloaded_icon_invalid_content( From dcc288cf2d1cb7f235a6fa46d9a9aa6335dfae28 Mon Sep 17 00:00:00 2001 From: Niek van der Maas Date: Mon, 10 Aug 2026 11:42:28 +0200 Subject: [PATCH 4/4] fix: fall back to main for repository brand icons --- custom_components/hacs/brands.py | 5 ++-- ...on-view-remote-without-any-stored-ref.json | 10 ++++++++ tests/test_brands.py | 23 +++++++++++++++++++ 3 files changed, 35 insertions(+), 3 deletions(-) create mode 100644 tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-without-any-stored-ref.json diff --git a/custom_components/hacs/brands.py b/custom_components/hacs/brands.py index eb93cde0d5b..5db85655435 100644 --- a/custom_components/hacs/brands.py +++ b/custom_components/hacs/brands.py @@ -43,6 +43,7 @@ NEGATIVE_CACHE_TTL = 60 * 60 * 24 * 7 MAX_ICON_SIZE = 1024 * 1024 DOWNLOAD_CHUNK_SIZE = 64 * 1024 +DEFAULT_REF = "main" PNG_MAGIC = b"\x89PNG\r\n\x1a\n" VALID_FILENAMES = ("icon.png", "dark_icon.png") BRANDS_DOMAIN = "brands" @@ -170,10 +171,8 @@ async def _async_serve_remote( token: str | None, ) -> web.StreamResponse: """Serve the icon from the GitHub repository content, with caching.""" - ref = repository.data.last_version or repository.data.default_branch + ref = repository.data.last_version or repository.data.default_branch or DEFAULT_REF cache_ref = repository.data.last_version or repository.data.last_commit or ref - if ref is None or cache_ref is None: - return self._fallback_response(repository, filename, token=token) prefix = f"{repository.data.id}-" cache_file = self._cache_dir / f"{prefix}{quote(cache_ref, safe='')}-{filename}" diff --git a/tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-without-any-stored-ref.json b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-without-any-stored-ref.json new file mode 100644 index 00000000000..7fd1b693f68 --- /dev/null +++ b/tests/snapshots/api-usage/tests/test_brandstest-icon-view-remote-without-any-stored-ref.json @@ -0,0 +1,10 @@ +{ + "tests/test_brands.py::test_icon_view_remote_without_any_stored_ref": { + "https://api.github.com/repos/hacs/integration": 1, + "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, + "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, + "https://api.github.com/repos/hacs/integration/git/trees/main": 1, + "https://api.github.com/repos/hacs/integration/releases": 1, + "https://raw.githubusercontent.com/hacs-test-org/integration-basic/main/custom_components/example/brand/icon.png": 1 + } +} \ No newline at end of file diff --git a/tests/test_brands.py b/tests/test_brands.py index fb878b2e313..a6801f6b06f 100644 --- a/tests/test_brands.py +++ b/tests/test_brands.py @@ -237,6 +237,29 @@ async def test_icon_view_remote_branch_cache_uses_commit( assert response.body == DARK_ICON_CONTENT +async def test_icon_view_remote_without_any_stored_ref( + hass: HomeAssistant, + setup_integration: Generator, + response_mocker: ResponseMocker, +) -> None: + """Test loading an icon before repository refs have been hydrated.""" + repository = get_hacs(hass).repositories.get_by_full_name(REPOSITORY_FULL_NAME) + repository.data.last_version = None + repository.data.default_branch = None + repository.data.last_commit = None + response_mocker.add( + "https://raw.githubusercontent.com/hacs-test-org/integration-basic" + "/main/custom_components/example/brand/icon.png", + MockedResponse(content=ICON_CONTENT), + ) + + response = await _get_icon(hass, REPOSITORY_ID, "icon.png") + + assert response.status == 200 + assert response.body == ICON_CONTENT + assert os.path.exists(hass.config.path(".storage/hacs.icons/1296269-main-icon.png")) + + async def test_icon_view_uses_current_hacs_instance( hass: HomeAssistant, ) -> None: