From f751ac0d36d01d2e7308f7e9a5b68f5f832c3445 Mon Sep 17 00:00:00 2001 From: OneWay Date: Sun, 16 Aug 2026 11:13:46 +0300 Subject: [PATCH] Harden DSL parsing against escapes and malformed tags Fixed escape-aware headword parsing for \{, \}, \[ and trailing backslash. Fixed O(n^2) tag scan and nested bracket handling in article rendering. --- .../research/data/parser/DslHeadwordParser.kt | 102 +++++++++++++++--- .../research/ui/article/DslAnnotatedParser.kt | 15 ++- 2 files changed, 103 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/example/research/data/parser/DslHeadwordParser.kt b/app/src/main/java/com/example/research/data/parser/DslHeadwordParser.kt index e6884d3..ec28974 100644 --- a/app/src/main/java/com/example/research/data/parser/DslHeadwordParser.kt +++ b/app/src/main/java/com/example/research/data/parser/DslHeadwordParser.kt @@ -24,10 +24,11 @@ object DslHeadwordParser { } if (trimmed.indexOf('{') == -1 && trimmed.indexOf('[') == -1) { + val unescaped = unescape(trimmed) return ParsedHeadword( - simplified = trimmed, - displayText = trimmed, - searchableText = trimmed.lowercase() + simplified = unescaped, + displayText = unescaped, + searchableText = unescaped.lowercase() ) } @@ -60,12 +61,12 @@ object DslHeadwordParser { val searchableText = createSearchableText(trimmed) return ParsedHeadword( - simplified = simplified, - displayText = displayText, - searchableText = searchableText + simplified = unescape(simplified), + displayText = unescape(displayText), + searchableText = unescape(searchableText) ) } - + private fun buildDisplayText( reconstructedPrefix: String, simplified: String, @@ -96,7 +97,7 @@ object DslHeadwordParser { .orEmpty() } } - + private fun createSearchableText(originalHeadword: String): String { return removeFormattingTags( removeCurlyBraceMatches(originalHeadword, removeEmpty = true) @@ -129,8 +130,17 @@ object DslHeadwordParser { if (value.indexOf('{') == -1 && value.indexOf('}') == -1) return value return buildString(value.length) { - value.forEach { char -> - if (char != '{' && char != '}') append(char) + var index = 0 + while (index < value.length) { + val char = value[index] + if (char == '\\' && index + 1 < value.length) { + append(char) + append(value[index + 1]) + index += 2 + } else { + if (char != '{' && char != '}') append(char) + index++ + } } } } @@ -138,6 +148,10 @@ object DslHeadwordParser { private fun firstFormattingTagIndex(value: String): Int { var index = 0 while (index < value.length) { + if (value[index] == '\\' && index + 1 < value.length) { + index += 2 + continue + } if (value[index] == '[' && formattingTagEnd(value, index) > index) { return index } @@ -149,8 +163,12 @@ object DslHeadwordParser { private fun firstCurlyContent(value: String): String? { var index = 0 while (index < value.length) { + if (value[index] == '\\' && index + 1 < value.length) { + index += 2 + continue + } if (value[index] == '{') { - val end = value.indexOf('}', startIndex = index + 1) + val end = matchingCurlyEnd(value, index + 1) if (end > index + 1) { return value.substring(index + 1, end) } @@ -161,7 +179,7 @@ object DslHeadwordParser { } private fun substringBeforeFirstBracket(value: String): String { - val bracketIndex = value.indexOf('[') + val bracketIndex = indexOfUnescaped(value, '[') return if (bracketIndex >= 0) value.substring(0, bracketIndex) else value } @@ -171,8 +189,12 @@ object DslHeadwordParser { var index = 0 while (index < value.length) { + if (value[index] == '\\' && index + 1 < value.length) { + index += 2 + continue + } if (value[index] == '{') { - val end = value.indexOf('}', startIndex = index + 1) + val end = matchingCurlyEnd(value, index + 1) if (end >= 0 && (removeEmpty || end > index + 1)) { if (builder == null) { builder = StringBuilder(value.length) @@ -197,6 +219,10 @@ object DslHeadwordParser { var index = 0 while (index < value.length) { + if (value[index] == '\\' && index + 1 < value.length) { + index += 2 + continue + } if (value[index] == '[') { val tagEnd = formattingTagEnd(value, index) if (tagEnd > index) { @@ -217,6 +243,56 @@ object DslHeadwordParser { }?.toString() ?: value } + private fun matchingCurlyEnd(value: String, startIndex: Int): Int { + var index = startIndex + while (index < value.length) { + when { + value[index] == '\\' && index + 1 < value.length -> index += 2 + value[index] == '{' -> return -1 + value[index] == '}' -> return index + else -> index++ + } + } + return -1 + } + + private fun indexOfUnescaped( + value: String, + target: Char, + startIndex: Int = 0, + ): Int { + var index = startIndex.coerceAtLeast(0) + while (index < value.length) { + if (value[index] == '\\' && index + 1 < value.length) { + index += 2 + } else if (value[index] == target) { + return index + } else { + index++ + } + } + return -1 + } + + private fun unescape(value: String): String { + val firstEscape = value.indexOf('\\') + if (firstEscape < 0) return value + + return buildString(value.length) { + append(value, 0, firstEscape) + var index = firstEscape + while (index < value.length) { + if (value[index] == '\\' && index + 1 < value.length) { + append(value[index + 1]) + index += 2 + } else { + append(value[index]) + index++ + } + } + } + } + private fun formattingTagEnd(value: String, openBracketIndex: Int): Int { val tokenStart = openBracketIndex + 1 if (tokenStart >= value.length) return -1 diff --git a/app/src/main/java/com/example/research/ui/article/DslAnnotatedParser.kt b/app/src/main/java/com/example/research/ui/article/DslAnnotatedParser.kt index 038dce8..02a0c64 100644 --- a/app/src/main/java/com/example/research/ui/article/DslAnnotatedParser.kt +++ b/app/src/main/java/com/example/research/ui/article/DslAnnotatedParser.kt @@ -119,7 +119,7 @@ object DslAnnotatedParser { i++ } '[' -> { - val end = dsl.indexOf(']', i + 1) + val end = tagCloseIndex(dsl, i + 1) if (end != -1) { val tagStart = i + 1 var tagEnd = end @@ -228,6 +228,19 @@ object DslAnnotatedParser { splitOversizedBlocks(result) } + private fun tagCloseIndex(value: String, startIndex: Int): Int { + var index = startIndex + while (index < value.length) { + when { + value[index] == '\\' && index + 1 < value.length -> index += 2 + value[index] == '[' -> return -1 + value[index] == ']' -> return index + else -> index++ + } + } + return -1 + } + private fun splitOversizedBlocks(blocks: List): List { if (blocks.none { it.text.length > MAX_BLOCK_TEXT_LENGTH }) return blocks return blocks.flatMap { block ->