From 430f8abc12f7c85abe7306da78e1013d011de4d2 Mon Sep 17 00:00:00 2001 From: Joshua Date: Sat, 26 Sep 2026 07:38:18 -0400 Subject: [PATCH] Reject edge whitespace and controls in icon keys instead of trimming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit validate_icon stripped the whole value before validating, so `emoji:🐕 `, `emoji:🐕\n` and `emoji:🐕\x1c` were silently accepted as `emoji:🐕`. Validate the raw namespace, name and emoji payload; a whitespace-only value still clears the icon. Padded keys such as `lucide:dog ` are now rejected naming `icon` (NQ-241-3). Co-Authored-By: Claude Opus 5.5 --- custom_components/nestquest/core/icons.py | 15 ++++++++------- tests/test_core_icons.py | 10 ++++++++-- tests/test_quest_definitions.py | 4 ++-- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/custom_components/nestquest/core/icons.py b/custom_components/nestquest/core/icons.py index 01bb14b..7a2a04b 100644 --- a/custom_components/nestquest/core/icons.py +++ b/custom_components/nestquest/core/icons.py @@ -55,7 +55,9 @@ def validate_icon(value: object) -> str | None: """Return the canonical namespaced icon key, or raise naming ``icon``. ``None`` or an empty/whitespace-only string returns ``None`` (the - icon is cleared). A value with no ``:`` is a legacy bare Lucide + icon is cleared). Otherwise the raw value is validated untrimmed, so + edge whitespace or control characters are rejected rather than + silently stripped. A value with no ``:`` is a legacy bare Lucide name and is stored as ``lucide:``. An unknown namespace, an empty or malformed name, or an invalid emoji payload raises ValueError. @@ -64,17 +66,16 @@ def validate_icon(value: object) -> str | None: return None if not isinstance(value, str): raise ValueError(f"icon must be a string, got {value!r}") - trimmed = value.strip() - if not trimmed: + if not value.strip(): return None - namespace, separator, name = trimmed.partition(":") + namespace, separator, name = value.partition(":") if not separator: - if not _is_kebab_name(trimmed): + if not _is_kebab_name(value): raise ValueError( f"icon must be a lowercase kebab name, got {value!r}" ) - return f"{ICON_NAMESPACE_LUCIDE}:{trimmed}" + return f"{ICON_NAMESPACE_LUCIDE}:{value}" if namespace not in ICON_NAMESPACES: raise ValueError( @@ -93,7 +94,7 @@ def validate_icon(value: object) -> str | None: f"icon {namespace} name must be a lowercase kebab name, " f"got {value!r}" ) - return trimmed + return value def normalize_icon_for_read(value: str | None) -> str | None: diff --git a/tests/test_core_icons.py b/tests/test_core_icons.py index f4a7bf2..de454d5 100644 --- a/tests/test_core_icons.py +++ b/tests/test_core_icons.py @@ -34,8 +34,6 @@ def test_namespace_and_cap_constants() -> None: ("emoji:" + "⭐" * EMOJI_MAX_CODE_POINTS, "emoji:" + "⭐" * 8), # Legacy bare Lucide names normalize to the lucide namespace. ("dog", "lucide:dog"), - (" toy-box ", "lucide:toy-box"), - (" fa:dog ", "fa:dog"), ], ) def test_validate_icon_accepts(value: str, expected: str) -> None: @@ -72,6 +70,14 @@ def test_validate_icon_clears(value: str | None) -> None: "emoji:a&b", # entity start "emoji:🐕\x00", # control character "emoji:🐕\x07🐕", # control character between emoji + # Edge whitespace/controls are part of the value, never trimmed away. + "emoji:🐕 ", # trailing space + "emoji:🐕\n", # trailing newline + "emoji:🐕\x1c", # trailing control character (str.strip() eats it) + " emoji:🐕", # leading space + "lucide:dog ", # trailing space + " fa:dog ", # padded namespaced key + " toy-box ", # padded legacy bare name ], ) def test_validate_icon_rejects_naming_the_field(value: str) -> None: diff --git a/tests/test_quest_definitions.py b/tests/test_quest_definitions.py index 859a8bd..c344a1a 100644 --- a/tests/test_quest_definitions.py +++ b/tests/test_quest_definitions.py @@ -106,7 +106,7 @@ def test_create_one_child_one_window(tmp_path) -> None: [child.id], ["morning"], description=" Twice daily ", - icon=" lucide:tooth ", + icon="lucide:tooth", ) assert isinstance(result, CreatedQuestDefinition) definition = result.definition @@ -625,7 +625,7 @@ def test_edit_title_description_icon(tmp_path) -> None: created.definition.id, title=" Floss ", description=" New description ", - icon=" fa:new ", + icon="fa:new", ) assert result.definition.title == "Floss" assert result.definition.description == "New description"