Reject edge whitespace and controls in icon keys instead of trimming
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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:<value>``. 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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user