186 files

This commit is contained in:
Home Assistant Version Control
2026-09-14 12:56:59 +00:00
parent 6be1e92941
commit bd8280defe
186 changed files with 14904 additions and 1087 deletions
@@ -47,6 +47,9 @@ LIBRARY_REPOSITORY_SEGMENTS = ("bramstroker", "homeassistant-powercalc")
LIBRARY_RESOURCE_DIRECTORY = "profile_library"
MAX_RESOURCE_SIZE = 10 * 1024 * 1024
MAX_MANIFEST_SIZE = 1024 * 1024
MAX_LIBRARY_SIZE = 10 * 1024 * 1024
MAX_PROFILE_RESOURCES = 256
MAX_PROFILE_DOWNLOAD_SIZE = 25 * 1024 * 1024
DOWNLOAD_CHUNK_SIZE = 64 * 1024
@@ -104,6 +107,48 @@ async def _read_capped(response: aiohttp.ClientResponse, limit: int, description
return b"".join(chunks)
def _validate_path_segment(value: object, description: str) -> str:
"""Validate a library identifier before using it as one directory component."""
if (
not isinstance(value, str)
or not value
or value in {".", ".."}
or any(character in value for character in ("/", "\\", ":", "\0"))
):
raise ProfileDownloadError(f"Library index contains an invalid {description}")
return value
def _validate_library_contents(contents: object) -> dict[str, Any]:
"""Validate the parts of library.json that determine local storage paths."""
if not isinstance(contents, dict) or not isinstance(manufacturers := contents.get("manufacturers"), list):
raise ProfileDownloadError("Library index contains invalid manufacturers")
for manufacturer in manufacturers:
if not isinstance(manufacturer, dict):
raise ProfileDownloadError("Library index contains an invalid manufacturer")
_validate_path_segment(manufacturer.get("dir_name"), "manufacturer directory")
models = manufacturer.get("models", [])
if not isinstance(models, list):
raise ProfileDownloadError("Library index contains invalid models")
for model in models:
if not isinstance(model, dict):
raise ProfileDownloadError("Library index contains an invalid model")
_validate_path_segment(model.get("id"), "model ID")
return cast(dict[str, Any], contents)
def _decode_library_json(data: bytes, description: str) -> dict[str, Any]:
"""Decode and validate a downloaded library index."""
try:
contents = json.loads(data)
except (JSONDecodeError, UnicodeDecodeError, RecursionError) as err:
raise ProfileDownloadError(f"{description} is not valid JSON") from err
return _validate_library_contents(contents)
def _resolve_resource_path(storage_path: str, resource_path: object) -> Path:
"""Resolve and validate a resource path within the profile storage directory."""
if not isinstance(resource_path, str) or not resource_path or "\0" in resource_path:
@@ -127,6 +172,10 @@ def _validate_resources(resources: object, storage_path: str) -> list[tuple[str,
"""Validate all resources in a remote profile response."""
if not isinstance(resources, list) or not all(isinstance(resource, dict) for resource in resources):
raise ProfileDownloadError("Remote profile response contains invalid resources")
if len(resources) > MAX_PROFILE_RESOURCES:
raise ProfileDownloadError(
f"Remote profile contains more than the maximum of {MAX_PROFILE_RESOURCES} resources",
)
return [
(_validate_resource_url(resource.get("url")), _resolve_resource_path(storage_path, resource.get("path")))
@@ -221,7 +270,7 @@ class RemoteLoader(Loader):
powercalc_version = AwesomeVersion(str(integration.version))
self._clear_caches()
self.library_contents = await self.load_library_json(prefer_cached)
self.library_contents = _validate_library_contents(await self.load_library_json(prefer_cached))
self.profile_hashes = await self.hass.async_add_executor_job(self._load_profile_hashes)
self.model_infos.clear()
@@ -342,9 +391,12 @@ class RemoteLoader(Loader):
if not os.path.exists(local_path):
return None
try:
with open(local_path) as f:
return cast(dict[str, Any], json.load(f))
except (JSONDecodeError, OSError) as err:
with open(local_path, "rb") as f:
data = f.read(MAX_LIBRARY_SIZE + 1)
if len(data) > MAX_LIBRARY_SIZE:
raise ProfileDownloadError(f"Local library is larger than the maximum of {MAX_LIBRARY_SIZE} bytes")
return _decode_library_json(data, "Local library")
except (OSError, ProfileDownloadError) as err:
_LOGGER.warning("Local library.json is unusable (%s), discarding it and downloading a fresh copy", err)
return None
@@ -372,14 +424,15 @@ class RemoteLoader(Loader):
f"Failed to download library.json, unexpected status code: {resp.status}",
)
data = await resp.read()
data = await _read_capped(resp, MAX_LIBRARY_SIZE, "Remote library")
except (TimeoutError, ClientError) as err:
raise ProfileDownloadError(f"Failed to download library.json: {err}") from err
library_contents = _decode_library_json(data, "Remote library")
await self.hass.async_add_executor_job(_save_resource, data, Path(local_path))
return cast(dict[str, Any], json.loads(data))
return library_contents
@async_cache
async def get_manufacturer_listing(
@@ -611,7 +664,16 @@ class RemoteLoader(Loader):
def get_storage_path(self, manufacturer: str, model: str) -> str:
"""Retrieve the storage path for a given manufacturer and model."""
return str(self.hass.config.path(STORAGE_DIR, BUILT_IN_LIBRARY_DIR, manufacturer, model))
manufacturer = _validate_path_segment(manufacturer, "manufacturer directory")
model = _validate_path_segment(model, "model ID")
storage_root = Path(self.hass.config.path(STORAGE_DIR, BUILT_IN_LIBRARY_DIR)).resolve()
try:
storage_path = (storage_root / manufacturer / model).resolve()
except (OSError, RuntimeError, ValueError) as err:
raise ProfileDownloadError("Remote profile has an invalid storage path") from err
if not storage_path.is_relative_to(storage_root):
raise ProfileDownloadError("Remote profile storage path is outside the profile library")
return str(storage_path)
async def download_with_retry(
self,
@@ -672,12 +734,30 @@ class RemoteLoader(Loader):
# Download the files
downloaded_resources: list[tuple[bytes, Path]] = []
downloaded_size = 0
for url, destination in validated_resources:
async with session.get(url, allow_redirects=False) as resp:
if resp.status != 200:
raise ProfileDownloadError(f"Failed to download github URL: {url}")
contents = await _read_capped(resp, MAX_RESOURCE_SIZE, f"Remote profile resource {url}")
remaining_size = MAX_PROFILE_DOWNLOAD_SIZE - downloaded_size
if remaining_size <= 0:
raise ProfileDownloadError(
f"Remote profile is larger than the maximum of {MAX_PROFILE_DOWNLOAD_SIZE} bytes",
)
try:
contents = await _read_capped(
resp,
min(MAX_RESOURCE_SIZE, remaining_size),
f"Remote profile resource {url}",
)
except ProfileDownloadError as err:
if remaining_size < MAX_RESOURCE_SIZE:
raise ProfileDownloadError(
f"Remote profile is larger than the maximum of {MAX_PROFILE_DOWNLOAD_SIZE} bytes",
) from err
raise
downloaded_size += len(contents)
downloaded_resources.append((contents, destination))
await self.hass.async_add_executor_job(_save_resources, downloaded_resources)