Guard ConfigEntry from being mutated externally without using the built-in interfaces (#110023)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>pull/110898/head
parent
4c11371898
commit
d449eadac3
|
@ -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."""
|
||||
|
@ -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,
|
||||
*,
|
||||
|
@ -270,44 +295,45 @@ class ConfigEntry:
|
|||
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(
|
||||
|
@ -337,7 +363,7 @@ class ConfigEntry:
|
|||
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
|
||||
|
@ -366,6 +392,33 @@ class ConfigEntry:
|
|||
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."""
|
||||
|
@ -660,8 +713,9 @@ class ConfigEntry:
|
|||
"""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
|
||||
)
|
||||
|
@ -1205,7 +1259,7 @@ class ConfigEntryItems(UserDict[str, ConfigEntry]):
|
|||
"""
|
||||
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]:
|
||||
|
@ -1530,7 +1584,11 @@ class ConfigEntries:
|
|||
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
|
||||
|
@ -1547,16 +1605,16 @@ class ConfigEntries:
|
|||
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
|
||||
|
|
|
@ -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)
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -2026,7 +2027,7 @@ async def test_unique_id_update_existing_entry_with_reload(
|
|||
|
||||
# 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:
|
||||
|
@ -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()
|
||||
|
@ -3430,7 +3430,7 @@ async def test_setup_raise_auth_failed_from_first_coordinator_update(
|
|||
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()
|
||||
|
@ -3480,7 +3480,7 @@ async def test_setup_raise_auth_failed_from_future_coordinator_update(
|
|||
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()
|
||||
|
@ -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")
|
||||
|
|
Loading…
Reference in New Issue