Core: reject U+FEFF in emoji icon payloads to match the JS resolver
Python's validate_icon accepted emoji: (not str.isspace(), category Cf) while the generated JS isEmojiPayload rejects it via \s, so the backend could store a key the admin and panel silently fall back on. An exhaustive code-point sweep shows U+FEFF is the only divergence; add it to the forbidden set. ZWJ U+200D stays valid. Add tools/icons/emoji-payload-cases.json, a shared verdict table asserted by both tests/test_core_icons.py (validate_icon) and the admin Vitest suite (resolveIcon), so any future divergence fails one of the suites. Task: 8286a894-133f-4582-90fb-88f47eef6628 (review finding NQ-ICON-01) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -3,12 +3,18 @@ import { join } from "node:path";
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
// The offline generator is the only writer of both registry artefacts.
|
// The offline generator is the only writer of both registry artefacts.
|
||||||
import { OUTPUT_PATHS, REPO_ROOT, SOURCE_PATH, renderRegistry } from "../../../tools/icons/generate.mjs";
|
import { OUTPUT_PATHS, REPO_ROOT, SOURCE_PATH, renderRegistry } from "../../../tools/icons/generate.mjs";
|
||||||
import { FALLBACK_ICON, FA_ICONS, LUCIDE_ICONS, resolveIcon } from "./registry.generated";
|
import { EMOJI_MAX_CODE_POINTS, FALLBACK_ICON, FA_ICONS, LUCIDE_ICONS, resolveIcon } from "./registry.generated";
|
||||||
|
|
||||||
type Curated = { lucide: { name: string; label: string }[]; fa: { name: string; label: string }[] };
|
type Curated = { lucide: { name: string; label: string }[]; fa: { name: string; label: string }[] };
|
||||||
|
|
||||||
const curated = JSON.parse(readFileSync(SOURCE_PATH, "utf8")) as Curated;
|
const curated = JSON.parse(readFileSync(SOURCE_PATH, "utf8")) as Curated;
|
||||||
|
|
||||||
|
type EmojiPayloadCase = { payload: string; valid: boolean; why: string };
|
||||||
|
|
||||||
|
const emojiPayloadCases = JSON.parse(
|
||||||
|
readFileSync(join(REPO_ROOT, "tools", "icons", "emoji-payload-cases.json"), "utf8"),
|
||||||
|
) as { cases: EmojiPayloadCase[] };
|
||||||
|
|
||||||
describe("shared icon registry — one source of truth", () => {
|
describe("shared icon registry — one source of truth", () => {
|
||||||
it("both committed artefacts are byte-identical to a fresh generation", async () => {
|
it("both committed artefacts are byte-identical to a fresh generation", async () => {
|
||||||
const fresh = await renderRegistry();
|
const fresh = await renderRegistry();
|
||||||
@@ -86,6 +92,24 @@ describe("resolveIcon", () => {
|
|||||||
}
|
}
|
||||||
expect(FALLBACK_ICON).toEqual({ kind: "fallback", key: null });
|
expect(FALLBACK_ICON).toEqual({ kind: "fallback", key: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// tests/test_core_icons.py asserts the same table against the backend's
|
||||||
|
// validate_icon, so the two emoji rules cannot drift apart silently.
|
||||||
|
it.each(emojiPayloadCases.cases)("emoji verdict matches the shared table: $why", ({ payload, valid }) => {
|
||||||
|
const value = `emoji:${payload}`;
|
||||||
|
expect(resolveIcon(value)).toEqual(valid ? { kind: "emoji", key: value, text: payload } : FALLBACK_ICON);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the shared emoji table covers the contract edges", () => {
|
||||||
|
const verdicts = new Map(emojiPayloadCases.cases.map(({ payload, valid }) => [payload, valid]));
|
||||||
|
expect(verdicts.get("\ufeff")).toBe(false);
|
||||||
|
expect(verdicts.get("👨\u200d👩\u200d👧\u200d👦")).toBe(true);
|
||||||
|
expect(verdicts.get("🐕 ")).toBe(false);
|
||||||
|
expect(verdicts.get("🐕\u0000")).toBe(false);
|
||||||
|
for (const markup of ["<b>", "a>b", "a&b"]) expect(verdicts.get(markup)).toBe(false);
|
||||||
|
expect(verdicts.get("⭐".repeat(EMOJI_MAX_CODE_POINTS))).toBe(true);
|
||||||
|
expect(verdicts.get("⭐".repeat(EMOJI_MAX_CODE_POINTS + 1))).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function filesUnder(dir: string): string[] {
|
function filesUnder(dir: string): string[] {
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ namespace is one of :data:`ICON_NAMESPACES`:
|
|||||||
- ``lucide:<name>`` / ``fa:<name>`` — ``<name>`` is a lowercase kebab
|
- ``lucide:<name>`` / ``fa:<name>`` — ``<name>`` is a lowercase kebab
|
||||||
name (ASCII ``[a-z0-9-]``, not starting or ending with ``-``).
|
name (ASCII ``[a-z0-9-]``, not starting or ending with ``-``).
|
||||||
- ``emoji:<payload>`` — a short emoji string of at most
|
- ``emoji:<payload>`` — a short emoji string of at most
|
||||||
:data:`EMOJI_MAX_CODE_POINTS` code points, with no whitespace, no
|
:data:`EMOJI_MAX_CODE_POINTS` code points, with no whitespace (JS
|
||||||
control characters and none of ``<``, ``>``, ``&``.
|
``\\s``, so including U+FEFF), no control characters and none of
|
||||||
|
``<``, ``>``, ``&``.
|
||||||
|
|
||||||
Rows written before namespacing hold a bare Lucide name (``dog``).
|
Rows written before namespacing hold a bare Lucide name (``dog``).
|
||||||
:func:`validate_icon` (writes) normalizes such a legacy value to
|
:func:`validate_icon` (writes) normalizes such a legacy value to
|
||||||
@@ -33,7 +34,12 @@ ICON_NAMESPACES = frozenset(
|
|||||||
EMOJI_MAX_CODE_POINTS = 8
|
EMOJI_MAX_CODE_POINTS = 8
|
||||||
|
|
||||||
_KEBAB_NAME = re.compile(r"[a-z0-9](?:[a-z0-9-]*[a-z0-9])?")
|
_KEBAB_NAME = re.compile(r"[a-z0-9](?:[a-z0-9-]*[a-z0-9])?")
|
||||||
_EMOJI_FORBIDDEN = frozenset("<>&")
|
# U+FEFF (BOM / zero-width no-break space) is neither str.isspace() nor
|
||||||
|
# ``Cc`` in Python, but JS ``\s`` matches it, so it is listed here to keep
|
||||||
|
# this rule identical to the frontends' isEmojiPayload (/[\s<>&]|\p{Cc}/u).
|
||||||
|
# Other format characters, notably the ZWJ U+200D that emoji sequences
|
||||||
|
# need, stay valid in both runtimes.
|
||||||
|
_EMOJI_FORBIDDEN = frozenset("<>&\ufeff")
|
||||||
|
|
||||||
|
|
||||||
def _is_kebab_name(value: str) -> bool:
|
def _is_kebab_name(value: str) -> bool:
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ lets legacy bare Lucide names render without a data migration.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from custom_components.nestquest.core.icons import (
|
from custom_components.nestquest.core.icons import (
|
||||||
@@ -70,6 +73,8 @@ def test_validate_icon_clears(value: str | None) -> None:
|
|||||||
"emoji:a&b", # entity start
|
"emoji:a&b", # entity start
|
||||||
"emoji:🐕\x00", # control character
|
"emoji:🐕\x00", # control character
|
||||||
"emoji:🐕\x07🐕", # control character between emoji
|
"emoji:🐕\x07🐕", # control character between emoji
|
||||||
|
"emoji:\ufeff", # U+FEFF BOM: JS \s matches it, so reject to agree
|
||||||
|
"emoji:🐕\ufeff", # trailing U+FEFF
|
||||||
# Edge whitespace/controls are part of the value, never trimmed away.
|
# Edge whitespace/controls are part of the value, never trimmed away.
|
||||||
"emoji:🐕 ", # trailing space
|
"emoji:🐕 ", # trailing space
|
||||||
"emoji:🐕\n", # trailing newline
|
"emoji:🐕\n", # trailing newline
|
||||||
@@ -109,3 +114,39 @@ def test_validate_icon_rejects_non_strings(value: object) -> None:
|
|||||||
)
|
)
|
||||||
def test_normalize_icon_for_read(value: str | None, expected: str | None) -> None:
|
def test_normalize_icon_for_read(value: str | None, expected: str | None) -> None:
|
||||||
assert normalize_icon_for_read(value) == expected
|
assert normalize_icon_for_read(value) == expected
|
||||||
|
|
||||||
|
|
||||||
|
# The emoji-payload rule is duplicated in the generated JS resolver
|
||||||
|
# (``isEmojiPayload`` in tools/icons/generate.mjs). Both suites assert
|
||||||
|
# this one table — admin/src/icons/registry.test.ts runs it through
|
||||||
|
# resolveIcon — so a divergence between the runtimes fails a test.
|
||||||
|
_EMOJI_PARITY_CASES = json.loads(
|
||||||
|
(
|
||||||
|
Path(__file__).parent.parent / "tools" / "icons" / "emoji-payload-cases.json"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
)["cases"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"case", _EMOJI_PARITY_CASES, ids=[case["why"] for case in _EMOJI_PARITY_CASES]
|
||||||
|
)
|
||||||
|
def test_validate_icon_emoji_verdict_matches_shared_table(case: dict) -> None:
|
||||||
|
value = "emoji:" + case["payload"]
|
||||||
|
if case["valid"]:
|
||||||
|
assert validate_icon(value) == value
|
||||||
|
else:
|
||||||
|
with pytest.raises(ValueError, match=r"^icon\b"):
|
||||||
|
validate_icon(value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_emoji_parity_table_covers_the_contract_edges() -> None:
|
||||||
|
payloads = {case["payload"]: case["valid"] for case in _EMOJI_PARITY_CASES}
|
||||||
|
assert payloads["\ufeff"] is False
|
||||||
|
assert payloads["🐕 "] is False
|
||||||
|
assert payloads["🐕\x00"] is False
|
||||||
|
assert payloads["<b>"] is False and payloads["a>b"] is False
|
||||||
|
assert payloads["a&b"] is False
|
||||||
|
assert payloads["⭐" * EMOJI_MAX_CODE_POINTS] is True
|
||||||
|
assert payloads["⭐" * (EMOJI_MAX_CODE_POINTS + 1)] is False
|
||||||
|
family = "👨\u200d👩\u200d👧\u200d👦"
|
||||||
|
assert payloads[family] is True
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"_comment": "Shared emoji-payload verdicts. tests/test_core_icons.py (validate_icon) and admin/src/icons/registry.test.ts (resolveIcon) both assert every row, so the Python and JS rules cannot diverge silently.",
|
||||||
|
"cases": [
|
||||||
|
{ "payload": "🐕", "valid": true, "why": "plain emoji" },
|
||||||
|
{ "payload": "👨👩👧👦", "valid": true, "why": "ZWJ family (U+200D allowed)" },
|
||||||
|
{ "payload": "⭐⭐⭐⭐⭐⭐⭐⭐", "valid": true, "why": "exactly 8 code points" },
|
||||||
|
{ "payload": "⭐⭐⭐⭐⭐⭐⭐⭐⭐", "valid": false, "why": "9 code points, over the cap" },
|
||||||
|
{ "payload": "", "valid": false, "why": "empty" },
|
||||||
|
{ "payload": "", "valid": false, "why": "U+FEFF BOM alone" },
|
||||||
|
{ "payload": "🐕", "valid": false, "why": "trailing U+FEFF" },
|
||||||
|
{ "payload": "🐕 ", "valid": false, "why": "trailing space" },
|
||||||
|
{ "payload": "a b", "valid": false, "why": "inner space" },
|
||||||
|
{ "payload": "🐕 ", "valid": false, "why": "no-break space" },
|
||||||
|
{ "payload": "🐕 ", "valid": false, "why": "ideographic space" },
|
||||||
|
{ "payload": "🐕
", "valid": false, "why": "line separator" },
|
||||||
|
{ "payload": "🐕\n", "valid": false, "why": "newline" },
|
||||||
|
{ "payload": "🐕\u0000", "valid": false, "why": "NUL control" },
|
||||||
|
{ "payload": "🐕\u0007🐕", "valid": false, "why": "BEL control" },
|
||||||
|
{ "payload": "🐕\u001c", "valid": false, "why": "file-separator control" },
|
||||||
|
{ "payload": "🐕\u0085", "valid": false, "why": "NEL C1 control" },
|
||||||
|
{ "payload": "🐕\u007f", "valid": false, "why": "DEL control" },
|
||||||
|
{ "payload": "<b>", "valid": false, "why": "markup <" },
|
||||||
|
{ "payload": "a>b", "valid": false, "why": "markup >" },
|
||||||
|
{ "payload": "a&b", "valid": false, "why": "entity start &" }
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user