diff --git a/supervisor/api/supervisor.py b/supervisor/api/supervisor.py index d6e826283..93e1f924c 100644 --- a/supervisor/api/supervisor.py +++ b/supervisor/api/supervisor.py @@ -142,6 +142,10 @@ class APISupervisor(CoreSysAttributes): if ATTR_ADDONS_REPOSITORIES in body: new = set(body[ATTR_ADDONS_REPOSITORIES]) await asyncio.shield(self.sys_store.update_repositories(new)) + if sorted(body[ATTR_ADDONS_REPOSITORIES]) != sorted( + self.sys_config.addons_repositories + ): + raise APIError("Not a valid add-on repository") self.sys_updater.save_data() self.sys_config.save_data() diff --git a/supervisor/store/__init__.py b/supervisor/store/__init__.py index cc0fbe750..33163aee9 100644 --- a/supervisor/store/__init__.py +++ b/supervisor/store/__init__.py @@ -1,10 +1,17 @@ """Add-on Store handler.""" import asyncio import logging +from pathlib import Path from typing import Dict, List +import voluptuous as vol + +from supervisor.store.validate import SCHEMA_REPOSITORY_CONFIG +from supervisor.utils.json import read_json_file + from ..const import REPOSITORY_CORE, REPOSITORY_LOCAL from ..coresys import CoreSys, CoreSysAttributes +from ..exceptions import JsonFileError from .addon import AddonStore from .data import StoreData from .repository import Repository @@ -60,19 +67,31 @@ class StoreManager(CoreSysAttributes): if not await repository.load(): _LOGGER.error("Can't load from repository %s", url) return - self.repositories[url] = repository # don't add built-in repository to config if url not in BUILTIN_REPOSITORIES: + # Verify that it is a add-on repository + repository_file = Path(repository.git.path, "repository.json") + try: + await self.sys_run_in_executor( + SCHEMA_REPOSITORY_CONFIG, read_json_file(repository_file) + ) + except (JsonFileError, vol.Invalid) as err: + _LOGGER.error("%s is not a valid add-on repository. %s", url, err) + await repository.remove() + return + self.sys_config.add_addon_repository(url) + self.repositories[url] = repository + tasks = [_add_repository(url) for url in new_rep - old_rep] if tasks: await asyncio.wait(tasks) # del new repository for url in old_rep - new_rep - BUILTIN_REPOSITORIES: - self.repositories.pop(url).remove() + await self.repositories.pop(url).remove() self.sys_config.drop_addon_repository(url) # update data diff --git a/supervisor/store/git.py b/supervisor/store/git.py index 3fe537543..e1a4788a3 100644 --- a/supervisor/store/git.py +++ b/supervisor/store/git.py @@ -94,7 +94,7 @@ class GitRepo(CoreSysAttributes): async def pull(self): """Pull Git add-on repo.""" if self.lock.locked(): - _LOGGER.warning("It is already a task in progress") + _LOGGER.warning("There is already a task in progress") return False async with self.lock: @@ -128,8 +128,12 @@ class GitRepo(CoreSysAttributes): return True - def _remove(self): + async def _remove(self): """Remove a repository.""" + if self.lock.locked(): + _LOGGER.warning("There is already a task in progress") + return + if not self.path.is_dir(): return @@ -137,7 +141,9 @@ class GitRepo(CoreSysAttributes): """Log error.""" _LOGGER.warning("Can't remove %s", path) - shutil.rmtree(self.path, onerror=log_err) + await self.sys_run_in_executor( + ft.partial(shutil.rmtree, self.path, onerror=log_err) + ) class GitRepoHassIO(GitRepo): @@ -157,7 +163,7 @@ class GitRepoCustom(GitRepo): super().__init__(coresys, path, url) - def remove(self): + async def remove(self): """Remove a custom repository.""" _LOGGER.info("Remove custom add-on repository %s", self.url) - self._remove() + await self._remove() diff --git a/supervisor/store/repository.py b/supervisor/store/repository.py index 5e8f05b03..3077f949a 100644 --- a/supervisor/store/repository.py +++ b/supervisor/store/repository.py @@ -67,9 +67,9 @@ class Repository(CoreSysAttributes): return await self.git.pull() return True - def remove(self): + async def remove(self): """Remove add-on repository.""" if self.slug in (REPOSITORY_CORE, REPOSITORY_LOCAL): raise APIError("Can't remove built-in repositories!") - self.git.remove() + await self.git.remove() diff --git a/tests/conftest.py b/tests/conftest.py index 4e941860e..ab0522212 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -141,3 +141,12 @@ async def network_interface(dbus): await interface.connect(dbus, "/org/freedesktop/NetworkManager/ActiveConnection/1") await interface.connection.update_information() yield interface + + +@pytest.fixture +def store_manager(coresys: CoreSys): + """Fixture for the store manager.""" + sm_obj = coresys.store + sm_obj.repositories = set(coresys.config.addons_repositories) + with patch("supervisor.store.data.StoreData.update", return_value=MagicMock()): + yield sm_obj diff --git a/tests/store/test_custom_repository.py b/tests/store/test_custom_repository.py new file mode 100644 index 000000000..3c808a235 --- /dev/null +++ b/tests/store/test_custom_repository.py @@ -0,0 +1,29 @@ +"""Test add custom repository.""" +import json +from unittest.mock import patch + +import pytest + + +@pytest.mark.asyncio +async def test_add_valid_repository(coresys, store_manager): + """Test add custom repository.""" + current = coresys.config.addons_repositories + with patch("supervisor.store.repository.Repository.load", return_value=True), patch( + "pathlib.Path.read_text", + return_value=json.dumps({"name": "Awesome repository"}), + ): + await store_manager.update_repositories(current + ["http://example.com"]) + assert "http://example.com" in coresys.config.addons_repositories + + +@pytest.mark.asyncio +async def test_add_invalid_repository(coresys, store_manager): + """Test add custom repository.""" + current = coresys.config.addons_repositories + with patch("supervisor.store.repository.Repository.load", return_value=True), patch( + "pathlib.Path.read_text", + return_value="", + ): + await store_manager.update_repositories(current + ["http://example.com"]) + assert "http://example.com" not in coresys.config.addons_repositories