Skip to content

Commit

Permalink
Guard ConfigEntry from being mutated externally without using the bui…
Browse files Browse the repository at this point in the history
…lt-in interfaces (#110023)

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
  • Loading branch information
bdraco and MartinHjelmare committed Feb 16, 2024
1 parent 4c11371 commit d449ead
Show file tree
Hide file tree
Showing 2 changed files with 161 additions and 41 deletions.
94 changes: 76 additions & 18 deletions homeassistant/config_entries.py
Expand Up @@ -217,6 +217,18 @@ class OperationNotAllowed(ConfigError):

UpdateListenerType = Callable[[HomeAssistant, "ConfigEntry"], Coroutine[Any, Any, None]]

FROZEN_CONFIG_ENTRY_ATTRS = {"entry_id", "domain", "state", "reason"}
UPDATE_ENTRY_CONFIG_ENTRY_ATTRS = {
"unique_id",
"title",
"data",
"options",
"pref_disable_new_entities",
"pref_disable_polling",
"minor_version",
"version",
}


class ConfigEntry:
"""Hold a configuration entry."""
Expand Down Expand Up @@ -252,6 +264,19 @@ class ConfigEntry:
"_supports_options",
)

entry_id: str
domain: str
title: str
data: MappingProxyType[str, Any]
options: MappingProxyType[str, Any]
unique_id: str | None
state: ConfigEntryState
reason: str | None
pref_disable_new_entities: bool
pref_disable_polling: bool
version: int
minor_version: int

def __init__(
self,
*,
Expand All @@ -270,44 +295,45 @@ def __init__(
disabled_by: ConfigEntryDisabler | None = None,
) -> None:
"""Initialize a config entry."""
_setter = object.__setattr__
# Unique id of the config entry
self.entry_id = entry_id or uuid_util.random_uuid_hex()
_setter(self, "entry_id", entry_id or uuid_util.random_uuid_hex())

# Version of the configuration.
self.version = version
self.minor_version = minor_version
_setter(self, "version", version)
_setter(self, "minor_version", minor_version)

# Domain the configuration belongs to
self.domain = domain
_setter(self, "domain", domain)

# Title of the configuration
self.title = title
_setter(self, "title", title)

# Config data
self.data = MappingProxyType(data)
_setter(self, "data", MappingProxyType(data))

# Entry options
self.options = MappingProxyType(options or {})
_setter(self, "options", MappingProxyType(options or {}))

# Entry system options
if pref_disable_new_entities is None:
pref_disable_new_entities = False

self.pref_disable_new_entities = pref_disable_new_entities
_setter(self, "pref_disable_new_entities", pref_disable_new_entities)

if pref_disable_polling is None:
pref_disable_polling = False

self.pref_disable_polling = pref_disable_polling
_setter(self, "pref_disable_polling", pref_disable_polling)

# Source of the configuration (user, discovery, cloud)
self.source = source

# State of the entry (LOADED, NOT_LOADED)
self.state = state
_setter(self, "state", state)

# Unique ID of this entry.
self.unique_id = unique_id
_setter(self, "unique_id", unique_id)

# Config entry is disabled
if isinstance(disabled_by, str) and not isinstance(
Expand Down Expand Up @@ -337,7 +363,7 @@ def __init__(
self.update_listeners: list[UpdateListenerType] = []

# Reason why config entry is in a failed state
self.reason: str | None = None
_setter(self, "reason", None)

# Function to cancel a scheduled retry
self._async_cancel_retry_setup: Callable[[], Any] | None = None
Expand Down Expand Up @@ -366,6 +392,33 @@ def __repr__(self) -> str:
f"title={self.title} state={self.state} unique_id={self.unique_id}>"
)

def __setattr__(self, key: str, value: Any) -> None:
"""Set an attribute."""
if key in UPDATE_ENTRY_CONFIG_ENTRY_ATTRS:
if key == "unique_id":
# Setting unique_id directly will corrupt internal state
# There is no deprecation period for this key
# as changing them will corrupt internal state
# so we raise an error here
raise AttributeError(
"unique_id cannot be changed directly, use async_update_entry instead"
)
report(
f'sets "{key}" directly to update a config entry. This is deprecated and will'
" stop working in Home Assistant 2024.9, it should be updated to use"
" async_update_entry instead",
error_if_core=False,
)

elif key in FROZEN_CONFIG_ENTRY_ATTRS:
# These attributes are frozen and cannot be changed
# There is no deprecation period for these
# as changing them will corrupt internal state
# so we raise an error here
raise AttributeError(f"{key} cannot be changed")

super().__setattr__(key, value)

@property
def supports_options(self) -> bool:
"""Return if entry supports config options."""
Expand Down Expand Up @@ -660,8 +713,9 @@ def _async_set_state(
"""Set the state of the config entry."""
if state not in NO_RESET_TRIES_STATES:
self._tries = 0
self.state = state
self.reason = reason
_setter = object.__setattr__
_setter(self, "state", state)
_setter(self, "reason", reason)
async_dispatcher_send(
hass, SIGNAL_CONFIG_ENTRY_CHANGED, ConfigEntryChange.UPDATED, self
)
Expand Down Expand Up @@ -1205,7 +1259,7 @@ def update_unique_id(self, entry: ConfigEntry, new_unique_id: str | None) -> Non
"""
entry_id = entry.entry_id
self._unindex_entry(entry_id)
entry.unique_id = new_unique_id
object.__setattr__(entry, "unique_id", new_unique_id)
self._index_entry(entry)

def get_entries_for_domain(self, domain: str) -> list[ConfigEntry]:
Expand Down Expand Up @@ -1530,7 +1584,11 @@ def async_update_entry(
If the entry was not changed, the update_listeners are
not fired and this function returns False
"""
if entry.entry_id not in self._entries:
raise UnknownEntry(entry.entry_id)

changed = False
_setter = object.__setattr__

if unique_id is not UNDEFINED and entry.unique_id != unique_id:
# Reindex the entry if the unique_id has changed
Expand All @@ -1547,16 +1605,16 @@ def async_update_entry(
if value is UNDEFINED or getattr(entry, attr) == value:
continue

setattr(entry, attr, value)
_setter(entry, attr, value)
changed = True

if data is not UNDEFINED and entry.data != data:
changed = True
entry.data = MappingProxyType(data)
_setter(entry, "data", MappingProxyType(data))

if options is not UNDEFINED and entry.options != options:
changed = True
entry.options = MappingProxyType(options)
_setter(entry, "options", MappingProxyType(options))

if not changed:
return False
Expand Down
108 changes: 85 additions & 23 deletions tests/test_config_entries.py
Expand Up @@ -151,10 +151,11 @@ async def test_call_async_migrate_entry(
hass: HomeAssistant, major_version: int, minor_version: int
) -> None:
"""Test we call <component>.async_migrate_entry when version mismatch."""
entry = MockConfigEntry(domain="comp")
entry = MockConfigEntry(
domain="comp", version=major_version, minor_version=minor_version
)
assert not entry.supports_unload
entry.version = major_version
entry.minor_version = minor_version

entry.add_to_hass(hass)

mock_migrate_entry = AsyncMock(return_value=True)
Expand Down Expand Up @@ -185,9 +186,9 @@ async def test_call_async_migrate_entry_failure_false(
hass: HomeAssistant, major_version: int, minor_version: int
) -> None:
"""Test migration fails if returns false."""
entry = MockConfigEntry(domain="comp")
entry.version = major_version
entry.minor_version = minor_version
entry = MockConfigEntry(
domain="comp", version=major_version, minor_version=minor_version
)
entry.add_to_hass(hass)
assert not entry.supports_unload

Expand Down Expand Up @@ -217,9 +218,9 @@ async def test_call_async_migrate_entry_failure_exception(
hass: HomeAssistant, major_version: int, minor_version: int
) -> None:
"""Test migration fails if exception raised."""
entry = MockConfigEntry(domain="comp")
entry.version = major_version
entry.minor_version = minor_version
entry = MockConfigEntry(
domain="comp", version=major_version, minor_version=minor_version
)
entry.add_to_hass(hass)
assert not entry.supports_unload

Expand Down Expand Up @@ -249,9 +250,9 @@ async def test_call_async_migrate_entry_failure_not_bool(
hass: HomeAssistant, major_version: int, minor_version: int
) -> None:
"""Test migration fails if boolean not returned."""
entry = MockConfigEntry(domain="comp")
entry.version = major_version
entry.minor_version = minor_version
entry = MockConfigEntry(
domain="comp", version=major_version, minor_version=minor_version
)
entry.add_to_hass(hass)
assert not entry.supports_unload

Expand Down Expand Up @@ -281,9 +282,9 @@ async def test_call_async_migrate_entry_failure_not_supported(
hass: HomeAssistant, major_version: int, minor_version: int
) -> None:
"""Test migration fails if async_migrate_entry not implemented."""
entry = MockConfigEntry(domain="comp")
entry.version = major_version
entry.minor_version = minor_version
entry = MockConfigEntry(
domain="comp", version=major_version, minor_version=minor_version
)
entry.add_to_hass(hass)
assert not entry.supports_unload

Expand All @@ -304,9 +305,9 @@ async def test_call_async_migrate_entry_not_supported_minor_version(
hass: HomeAssistant, major_version: int, minor_version: int
) -> None:
"""Test migration without async_migrate_entry and minor version changed."""
entry = MockConfigEntry(domain="comp")
entry.version = major_version
entry.minor_version = minor_version
entry = MockConfigEntry(
domain="comp", version=major_version, minor_version=minor_version
)
entry.add_to_hass(hass)
assert not entry.supports_unload

Expand Down Expand Up @@ -2026,7 +2027,7 @@ async def async_step_user(self, user_input=None):

# Test we don't reload if entry not started
updates["host"] = "2.2.2.2"
entry.state = config_entries.ConfigEntryState.NOT_LOADED
entry._async_set_state(hass, config_entries.ConfigEntryState.NOT_LOADED, None)
with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}), patch(
"homeassistant.config_entries.ConfigEntries.async_reload"
) as async_reload:
Expand Down Expand Up @@ -3380,8 +3381,7 @@ async def test_setup_raise_auth_failed(
assert flows[0]["context"]["title_placeholders"] == {"name": "test_title"}

caplog.clear()
entry.state = config_entries.ConfigEntryState.NOT_LOADED
entry.reason = None
entry._async_set_state(hass, config_entries.ConfigEntryState.NOT_LOADED, None)

await entry.async_setup(hass)
await hass.async_block_till_done()
Expand Down Expand Up @@ -3430,7 +3430,7 @@ async def _async_update_data():
assert flows[0]["context"]["source"] == config_entries.SOURCE_REAUTH

caplog.clear()
entry.state = config_entries.ConfigEntryState.NOT_LOADED
entry._async_set_state(hass, config_entries.ConfigEntryState.NOT_LOADED, None)

await entry.async_setup(hass)
await hass.async_block_till_done()
Expand Down Expand Up @@ -3480,7 +3480,7 @@ async def _async_update_data():
assert flows[0]["context"]["source"] == config_entries.SOURCE_REAUTH

caplog.clear()
entry.state = config_entries.ConfigEntryState.NOT_LOADED
entry._async_set_state(hass, config_entries.ConfigEntryState.NOT_LOADED, None)

await entry.async_setup(hass)
await hass.async_block_till_done()
Expand Down Expand Up @@ -4323,3 +4323,65 @@ async def test_hashable_non_string_unique_id(
del entries[entry.entry_id]
assert not entries
assert entries.get_entry_by_domain_and_unique_id("test", unique_id) is None


async def test_directly_mutating_blocked(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
) -> None:
"""Test directly mutating a ConfigEntry is blocked."""
entry = MockConfigEntry(domain="test")
entry.add_to_hass(hass)

with pytest.raises(AttributeError, match="entry_id cannot be changed"):
entry.entry_id = "new_entry_id"

with pytest.raises(AttributeError, match="domain cannot be changed"):
entry.domain = "new_domain"

with pytest.raises(AttributeError, match="state cannot be changed"):
entry.state = config_entries.ConfigEntryState.FAILED_UNLOAD

with pytest.raises(AttributeError, match="reason cannot be changed"):
entry.reason = "new_reason"

with pytest.raises(
AttributeError,
match="unique_id cannot be changed directly, use async_update_entry instead",
):
entry.unique_id = "new_id"


@pytest.mark.parametrize(
"field",
(
"data",
"options",
"title",
"pref_disable_new_entities",
"pref_disable_polling",
"minor_version",
"version",
),
)
async def test_report_direct_mutation_of_config_entry(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, field: str
) -> None:
"""Test directly mutating a ConfigEntry is reported."""
entry = MockConfigEntry(domain="test")
entry.add_to_hass(hass)

setattr(entry, field, "new_value")

assert (
f'Detected code that sets "{field}" directly to update a config entry. '
"This is deprecated and will stop working in Home Assistant 2024.9, "
"it should be updated to use async_update_entry instead. Please report this issue."
) in caplog.text


async def test_updating_non_added_entry_raises(hass: HomeAssistant) -> None:
"""Test updating a non added entry raises UnknownEntry."""
entry = MockConfigEntry(domain="test")

with pytest.raises(config_entries.UnknownEntry, match=entry.entry_id):
hass.config_entries.async_update_entry(entry, unique_id="new_id")

0 comments on commit d449ead

Please sign in to comment.