From 4cb0fb64afda9ecf8b3661316bafeb4eeac952cf Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 26 Jul 2026 23:52:33 +0200 Subject: [PATCH] fix: decode numeric HTML entities in meta tag content Link previews left " / " literal because replaceCharRefs only whitelisted named entities. Parse terminated numeric refs as code points. Fixes #3723 --- .../commons/preview/MetaTagsParser.kt | 26 +++++++++-- .../preview/MetaTagsParserCharRefTest.kt | 45 +++++++++++++++++++ 2 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/preview/MetaTagsParserCharRefTest.kt diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/MetaTagsParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/MetaTagsParser.kt index 1015b1a6aa..77092cadb2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/MetaTagsParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/MetaTagsParser.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.commons.preview +import com.vitorpamplona.amethyst.commons.util.codePointToChars import kotlinx.collections.immutable.toImmutableMap data class MetaTag( @@ -179,13 +180,32 @@ object MetaTagsParser { ) fun replaceCharRefs(match: MatchResult): String { - val bcr = BASE_CHAR_REFS[match.groupValues[2]] + val isNumeric = match.groupValues[1].isNotEmpty() + val ref = match.groupValues[2] + val terminated = match.groupValues[3].isNotEmpty() + + // Numeric character references (" / ") must be terminated by ';' + if (isNumeric) { + if (!terminated) return match.value + val codePoint = + if (ref.startsWith("x", ignoreCase = true)) { + ref.drop(1).toIntOrNull(16) + } else { + ref.toIntOrNull(10) + } ?: return match.value + if (codePoint !in 0..0x10FFFF || codePoint in 0xD800..0xDFFF) { + return match.value + } + return codePointToChars(codePoint).concatToString() + } + + val bcr = BASE_CHAR_REFS[ref] if (bcr != null) { return bcr } // non-base char refs must be terminated by ';' - if (match.groupValues[3].isNotEmpty()) { - val cr = CHAR_REFS[match.groupValues[2]] + if (terminated) { + val cr = CHAR_REFS[ref] if (cr != null) { return cr } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/preview/MetaTagsParserCharRefTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/preview/MetaTagsParserCharRefTest.kt new file mode 100644 index 0000000000..09056a3f2f --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/preview/MetaTagsParserCharRefTest.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.preview + +import kotlin.test.Test +import kotlin.test.assertEquals + +class MetaTagsParserCharRefTest { + @Test + fun decodesNumericCharacterReferencesInContent() { + val input = + """ + | + | + | + | + """.trimMargin() + + val metaTags = MetaTagsParser.parse(input).toList() + assertEquals(2, metaTags.size) + assertEquals( + "\"You can always buy more tokens, not more time.\"", + metaTags[0].attr("content"), + ) + assertEquals("\"hex quotes\"", metaTags[1].attr("content")) + } +}