Guard ConfigEntry from being mutated externally without using the built-in interfaces (#110023)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
parent
4c11371898
commit
d449eadac3
2 changed files with 161 additions and 41 deletions
|
@ -217,6 +217,18 @@ class OperationNotAllowed(ConfigError):
|
||||||
|
|
||||||
UpdateListenerType = Callable[[HomeAssistant, "ConfigEntry"], Coroutine[Any, Any, None]]
|
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:
|
class ConfigEntry:
|
||||||
"""Hold a configuration entry."""
|
"""Hold a configuration entry."""
|
||||||
|
@ -252,6 +264,19 @@ class ConfigEntry:
|
||||||
"_supports_options",
|
"_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__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
@ -270,44 +295,45 @@ class ConfigEntry:
|
||||||
disabled_by: ConfigEntryDisabler | None = None,
|
disabled_by: ConfigEntryDisabler | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize a config entry."""
|
"""Initialize a config entry."""
|
||||||
|
_setter = object.__setattr__
|
||||||
# Unique id of the config entry
|
# 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.
|
# Version of the configuration.
|
||||||
self.version = version
|
_setter(self, "version", version)
|
||||||
self.minor_version = minor_version
|
_setter(self, "minor_version", minor_version)
|
||||||
|
|
||||||
# Domain the configuration belongs to
|
# Domain the configuration belongs to
|
||||||
self.domain = domain
|
_setter(self, "domain", domain)
|
||||||
|
|
||||||
# Title of the configuration
|
# Title of the configuration
|
||||||
self.title = title
|
_setter(self, "title", title)
|
||||||
|
|
||||||
# Config data
|
# Config data
|
||||||
self.data = MappingProxyType(data)
|
_setter(self, "data", MappingProxyType(data))
|
||||||
|
|
||||||
# Entry options
|
# Entry options
|
||||||
self.options = MappingProxyType(options or {})
|
_setter(self, "options", MappingProxyType(options or {}))
|
||||||
|
|
||||||
# Entry system options
|
# Entry system options
|
||||||
if pref_disable_new_entities is None:
|
if pref_disable_new_entities is None:
|
||||||
pref_disable_new_entities = False
|
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:
|
if pref_disable_polling is None:
|
||||||
pref_disable_polling = False
|
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)
|
# Source of the configuration (user, discovery, cloud)
|
||||||
self.source = source
|
self.source = source
|
||||||
|
|
||||||
# State of the entry (LOADED, NOT_LOADED)
|
# State of the entry (LOADED, NOT_LOADED)
|
||||||
self.state = state
|
_setter(self, "state", state)
|
||||||
|
|
||||||
# Unique ID of this entry.
|
# Unique ID of this entry.
|
||||||
self.unique_id = unique_id
|
_setter(self, "unique_id", unique_id)
|
||||||
|
|
||||||
# Config entry is disabled
|
# Config entry is disabled
|
||||||
if isinstance(disabled_by, str) and not isinstance(
|
if isinstance(disabled_by, str) and not isinstance(
|
||||||
|
@ -337,7 +363,7 @@ class ConfigEntry:
|
||||||
self.update_listeners: list[UpdateListenerType] = []
|
self.update_listeners: list[UpdateListenerType] = []
|
||||||
|
|
||||||
# Reason why config entry is in a failed state
|
# Reason why config entry is in a failed state
|
||||||
self.reason: str | None = None
|
_setter(self, "reason", None)
|
||||||
|
|
||||||
# Function to cancel a scheduled retry
|
# Function to cancel a scheduled retry
|
||||||
self._async_cancel_retry_setup: Callable[[], Any] | None = None
|
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}>"
|
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
|
@property
|
||||||
def supports_options(self) -> bool:
|
def supports_options(self) -> bool:
|
||||||
"""Return if entry supports config options."""
|
"""Return if entry supports config options."""
|
||||||
|
@ -660,8 +713,9 @@ class ConfigEntry:
|
||||||
"""Set the state of the config entry."""
|
"""Set the state of the config entry."""
|
||||||
if state not in NO_RESET_TRIES_STATES:
|
if state not in NO_RESET_TRIES_STATES:
|
||||||
self._tries = 0
|
self._tries = 0
|
||||||
self.state = state
|
_setter = object.__setattr__
|
||||||
self.reason = reason
|
_setter(self, "state", state)
|
||||||
|
_setter(self, "reason", reason)
|
||||||
async_dispatcher_send(
|
async_dispatcher_send(
|
||||||
hass, SIGNAL_CONFIG_ENTRY_CHANGED, ConfigEntryChange.UPDATED, self
|
hass, SIGNAL_CONFIG_ENTRY_CHANGED, ConfigEntryChange.UPDATED, self
|
||||||
)
|
)
|
||||||
|
@ -1205,7 +1259,7 @@ class ConfigEntryItems(UserDict[str, ConfigEntry]):
|
||||||
"""
|
"""
|
||||||
entry_id = entry.entry_id
|
entry_id = entry.entry_id
|
||||||
self._unindex_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)
|
self._index_entry(entry)
|
||||||
|
|
||||||
def get_entries_for_domain(self, domain: str) -> list[ConfigEntry]:
|
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
|
If the entry was not changed, the update_listeners are
|
||||||
not fired and this function returns False
|
not fired and this function returns False
|
||||||
"""
|
"""
|
||||||
|
if entry.entry_id not in self._entries:
|
||||||
|
raise UnknownEntry(entry.entry_id)
|
||||||
|
|
||||||
changed = False
|
changed = False
|
||||||
|
_setter = object.__setattr__
|
||||||
|
|
||||||
if unique_id is not UNDEFINED and entry.unique_id != unique_id:
|
if unique_id is not UNDEFINED and entry.unique_id != unique_id:
|
||||||
# Reindex the entry if the unique_id has changed
|
# Reindex the entry if the unique_id has changed
|
||||||
|
@ -1547,16 +1605,16 @@ class ConfigEntries:
|
||||||
if value is UNDEFINED or getattr(entry, attr) == value:
|
if value is UNDEFINED or getattr(entry, attr) == value:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
setattr(entry, attr, value)
|
_setter(entry, attr, value)
|
||||||
changed = True
|
changed = True
|
||||||
|
|
||||||
if data is not UNDEFINED and entry.data != data:
|
if data is not UNDEFINED and entry.data != data:
|
||||||
changed = True
|
changed = True
|
||||||
entry.data = MappingProxyType(data)
|
_setter(entry, "data", MappingProxyType(data))
|
||||||
|
|
||||||
if options is not UNDEFINED and entry.options != options:
|
if options is not UNDEFINED and entry.options != options:
|
||||||
changed = True
|
changed = True
|
||||||
entry.options = MappingProxyType(options)
|
_setter(entry, "options", MappingProxyType(options))
|
||||||
|
|
||||||
if not changed:
|
if not changed:
|
||||||
return False
|
return False
|
||||||
|
|
|
@ -151,10 +151,11 @@ async def test_call_async_migrate_entry(
|
||||||
hass: HomeAssistant, major_version: int, minor_version: int
|
hass: HomeAssistant, major_version: int, minor_version: int
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test we call <component>.async_migrate_entry when version mismatch."""
|
"""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
|
assert not entry.supports_unload
|
||||||
entry.version = major_version
|
|
||||||
entry.minor_version = minor_version
|
|
||||||
entry.add_to_hass(hass)
|
entry.add_to_hass(hass)
|
||||||
|
|
||||||
mock_migrate_entry = AsyncMock(return_value=True)
|
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
|
hass: HomeAssistant, major_version: int, minor_version: int
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test migration fails if returns false."""
|
"""Test migration fails if returns false."""
|
||||||
entry = MockConfigEntry(domain="comp")
|
entry = MockConfigEntry(
|
||||||
entry.version = major_version
|
domain="comp", version=major_version, minor_version=minor_version
|
||||||
entry.minor_version = minor_version
|
)
|
||||||
entry.add_to_hass(hass)
|
entry.add_to_hass(hass)
|
||||||
assert not entry.supports_unload
|
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
|
hass: HomeAssistant, major_version: int, minor_version: int
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test migration fails if exception raised."""
|
"""Test migration fails if exception raised."""
|
||||||
entry = MockConfigEntry(domain="comp")
|
entry = MockConfigEntry(
|
||||||
entry.version = major_version
|
domain="comp", version=major_version, minor_version=minor_version
|
||||||
entry.minor_version = minor_version
|
)
|
||||||
entry.add_to_hass(hass)
|
entry.add_to_hass(hass)
|
||||||
assert not entry.supports_unload
|
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
|
hass: HomeAssistant, major_version: int, minor_version: int
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test migration fails if boolean not returned."""
|
"""Test migration fails if boolean not returned."""
|
||||||
entry = MockConfigEntry(domain="comp")
|
entry = MockConfigEntry(
|
||||||
entry.version = major_version
|
domain="comp", version=major_version, minor_version=minor_version
|
||||||
entry.minor_version = minor_version
|
)
|
||||||
entry.add_to_hass(hass)
|
entry.add_to_hass(hass)
|
||||||
assert not entry.supports_unload
|
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
|
hass: HomeAssistant, major_version: int, minor_version: int
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test migration fails if async_migrate_entry not implemented."""
|
"""Test migration fails if async_migrate_entry not implemented."""
|
||||||
entry = MockConfigEntry(domain="comp")
|
entry = MockConfigEntry(
|
||||||
entry.version = major_version
|
domain="comp", version=major_version, minor_version=minor_version
|
||||||
entry.minor_version = minor_version
|
)
|
||||||
entry.add_to_hass(hass)
|
entry.add_to_hass(hass)
|
||||||
assert not entry.supports_unload
|
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
|
hass: HomeAssistant, major_version: int, minor_version: int
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test migration without async_migrate_entry and minor version changed."""
|
"""Test migration without async_migrate_entry and minor version changed."""
|
||||||
entry = MockConfigEntry(domain="comp")
|
entry = MockConfigEntry(
|
||||||
entry.version = major_version
|
domain="comp", version=major_version, minor_version=minor_version
|
||||||
entry.minor_version = minor_version
|
)
|
||||||
entry.add_to_hass(hass)
|
entry.add_to_hass(hass)
|
||||||
assert not entry.supports_unload
|
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
|
# Test we don't reload if entry not started
|
||||||
updates["host"] = "2.2.2.2"
|
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(
|
with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}), patch(
|
||||||
"homeassistant.config_entries.ConfigEntries.async_reload"
|
"homeassistant.config_entries.ConfigEntries.async_reload"
|
||||||
) as 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"}
|
assert flows[0]["context"]["title_placeholders"] == {"name": "test_title"}
|
||||||
|
|
||||||
caplog.clear()
|
caplog.clear()
|
||||||
entry.state = config_entries.ConfigEntryState.NOT_LOADED
|
entry._async_set_state(hass, config_entries.ConfigEntryState.NOT_LOADED, None)
|
||||||
entry.reason = None
|
|
||||||
|
|
||||||
await entry.async_setup(hass)
|
await entry.async_setup(hass)
|
||||||
await hass.async_block_till_done()
|
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
|
assert flows[0]["context"]["source"] == config_entries.SOURCE_REAUTH
|
||||||
|
|
||||||
caplog.clear()
|
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 entry.async_setup(hass)
|
||||||
await hass.async_block_till_done()
|
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
|
assert flows[0]["context"]["source"] == config_entries.SOURCE_REAUTH
|
||||||
|
|
||||||
caplog.clear()
|
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 entry.async_setup(hass)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
@ -4323,3 +4323,65 @@ async def test_hashable_non_string_unique_id(
|
||||||
del entries[entry.entry_id]
|
del entries[entry.entry_id]
|
||||||
assert not entries
|
assert not entries
|
||||||
assert entries.get_entry_by_domain_and_unique_id("test", unique_id) is None
|
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…
Add table
Reference in a new issue