diff --git a/qs-kotlin/src/main/kotlin/io/github/techouse/qskotlin/enums/DecodeKind.kt b/qs-kotlin/src/main/kotlin/io/github/techouse/qskotlin/enums/DecodeKind.kt new file mode 100644 index 0000000..f3e49b7 --- /dev/null +++ b/qs-kotlin/src/main/kotlin/io/github/techouse/qskotlin/enums/DecodeKind.kt @@ -0,0 +1,15 @@ +package io.github.techouse.qskotlin.enums + +import io.github.techouse.qskotlin.enums.DecodeKind.KEY +import io.github.techouse.qskotlin.enums.DecodeKind.VALUE + +/** + * Decoding context for a scalar token. + * - [KEY]: the token is a key or key segment. Callers may want to preserve percent-encoded dots + * (%2E / %2e) until after key-splitting. + * - [VALUE]: the token is a value; typically fully percent-decode. + */ +enum class DecodeKind { + KEY, + VALUE, +} diff --git a/qs-kotlin/src/main/kotlin/io/github/techouse/qskotlin/internal/Decoder.kt b/qs-kotlin/src/main/kotlin/io/github/techouse/qskotlin/internal/Decoder.kt index 93f4bc6..94d1599 100644 --- a/qs-kotlin/src/main/kotlin/io/github/techouse/qskotlin/internal/Decoder.kt +++ b/qs-kotlin/src/main/kotlin/io/github/techouse/qskotlin/internal/Decoder.kt @@ -110,10 +110,12 @@ internal object Decoder { var value: Any? if (pos == -1) { - key = options.getDecoder(part, charset).toString() + // Decode a bare key (no '=') using key-aware decoding + key = options.decodeKey(part, charset).orEmpty() value = if (options.strictNullHandling) null else "" } else { - key = options.getDecoder(part.take(pos), charset).toString() + // Decode the key slice as a key; values decode as values + key = options.decodeKey(part.take(pos), charset).orEmpty() value = Utils.apply( parseListValue( @@ -124,7 +126,7 @@ internal object Decoder { } else 0, ) ) { v: Any? -> - options.getDecoder((v as String?), charset) + options.decodeValue(v as String?, charset) } } @@ -202,12 +204,15 @@ internal object Decoder { val mutableObj = LinkedHashMap(1) val cleanRoot = - if (root.startsWith("[") && root.endsWith("]")) { - root.substring(1, root.length - 1) + if (root.startsWith("[")) { + val last = root.lastIndexOf(']') + if (last > 0) root.substring(1, last) else root.substring(1) } else root val decodedRoot = - if (options.getDecodeDotInKeys) cleanRoot.replace("%2E", ".") else cleanRoot + if (options.getDecodeDotInKeys) + cleanRoot.replace("%2E", ".").replace("%2e", ".") + else cleanRoot val isPureNumeric = decodedRoot.isNotEmpty() && decodedRoot.all { it.isDigit() } val idx: Int? = if (isPureNumeric) decodedRoot.toInt() else null @@ -232,8 +237,7 @@ internal object Decoder { // Otherwise, treat it as a map with *string* key (even if numeric) else -> { - val keyForMap = decodedRoot - mutableObj[keyForMap] = leaf + mutableObj[decodedRoot] = leaf obj = mutableObj } } @@ -274,10 +278,53 @@ internal object Decoder { } /** - * Regular expression to match dots followed by non-dot and non-bracket characters. This is used - * to replace dots in keys with brackets for parsing. + * Converts a dot notation key to bracket notation at the top level. + * + * @param s The string to convert, which may contain dot notation. + * @return The converted string with brackets replacing dots at the top level. */ - private val DOT_TO_BRACKET = Regex("""\.([^.\[]+)""") + private fun dotToBracketTopLevel(s: String): String { + val sb = StringBuilder(s.length) + var depth = 0 + var i = 0 + while (i < s.length) { + val ch = s[i] + when (ch) { + '[' -> { + depth++ + sb.append(ch) + i++ + } + ']' -> { + if (depth > 0) depth-- + sb.append(ch) + i++ + } + '.' -> { + if (depth == 0) { + // collect the next segment name (stop at '.' or '[') + val start = ++i + var j = start + while (j < s.length && s[j] != '.' && s[j] != '[') j++ + if (j > start) { + sb.append('[').append(s, start, j).append(']') + i = j + } else { + sb.append('.') // nothing to convert + } + } else { + sb.append('.') + i++ + } + } + else -> { + sb.append(ch) + i++ + } + } + } + return sb.toString() + } /** * Splits a key into segments based on brackets and dots, handling depth and strictness. @@ -295,17 +342,15 @@ internal object Decoder { maxDepth: Int, strictDepth: Boolean, ): List { - // Apply dot→bracket *before* splitting, but when depth == 0, we do NOT split at all and do - // NOT throw. - val key: String = - if (allowDots) originalKey.replace(DOT_TO_BRACKET) { "[${it.groupValues[1]}]" } - else originalKey - // Depth 0 semantics: use the original key as a single segment; never throw. if (maxDepth <= 0) { - return listOf(key) + return listOf(originalKey) } + // Apply dot→bracket *before* splitting, but when depth == 0, we do NOT split at all and do + // NOT throw. + val key: String = if (allowDots) dotToBracketTopLevel(originalKey) else originalKey + val segments = ArrayList(key.count { it == '[' } + 1) val first = key.indexOf('[') diff --git a/qs-kotlin/src/main/kotlin/io/github/techouse/qskotlin/models/DecodeOptions.kt b/qs-kotlin/src/main/kotlin/io/github/techouse/qskotlin/models/DecodeOptions.kt index 1daba7c..c255652 100644 --- a/qs-kotlin/src/main/kotlin/io/github/techouse/qskotlin/models/DecodeOptions.kt +++ b/qs-kotlin/src/main/kotlin/io/github/techouse/qskotlin/models/DecodeOptions.kt @@ -1,19 +1,24 @@ package io.github.techouse.qskotlin.models +import io.github.techouse.qskotlin.enums.DecodeKind import io.github.techouse.qskotlin.enums.Duplicates import io.github.techouse.qskotlin.internal.Utils import java.nio.charset.Charset import java.nio.charset.StandardCharsets -/** - * A function that decodes a value from a query string or form data. It takes a value and an - * optional charset, returning the decoded value. - * - * @param value The encoded value to decode. - * @param charset The character set to use for decoding, if any. - * @return The decoded value, or null if the value is not present. - */ -typealias Decoder = (value: String?, charset: Charset?) -> Any? +/** Unified scalar decoder. Implementations may ignore `charset` and/or `kind`. */ +fun interface Decoder { + fun decode(value: String?, charset: Charset?, kind: DecodeKind?): Any? +} + +/** Back‑compat adapter for `(value, charset) -> Any?` decoders. */ +@Deprecated( + message = + "Use Decoder fun interface; wrap your two‑arg lambda: Decoder { v, c, _ -> legacy(v, c) }", + replaceWith = ReplaceWith("Decoder { value, charset, _ -> legacyDecoder(value, charset) }"), + level = DeprecationLevel.WARNING, +) +typealias LegacyDecoder = (String?, Charset?) -> Any? /** Options that configure the output of Qs.decode. */ data class DecodeOptions( @@ -22,6 +27,13 @@ data class DecodeOptions( /** Set a Decoder to affect the decoding of the input. */ private val decoder: Decoder? = null, + @Deprecated( + message = "Use `decoder` fun interface; this will be removed in a future major release", + replaceWith = ReplaceWith("decoder"), + level = DeprecationLevel.WARNING, + ) + @Suppress("DEPRECATION") + private val legacyDecoder: LegacyDecoder? = null, /** * Set to `true` to decode dots in keys. @@ -107,8 +119,11 @@ data class DecodeOptions( val parseLists: Boolean = true, /** - * Set to `true` to add a layer of protection by throwing an error when the limit is exceeded, - * allowing you to catch and handle such cases. + * Enforce the [depth] limit when parsing nested keys. + * + * When `true`, exceeding [depth] throws an `IndexOutOfBoundsException` during key splitting. + * When `false` (default), any remainder beyond [depth] is treated as a single trailing segment + * (matching the reference `qs` behavior). */ val strictDepth: Boolean = false, @@ -118,11 +133,21 @@ data class DecodeOptions( /** Set to `true` to throw an error when the limit is exceeded. */ val throwOnLimitExceeded: Boolean = false, ) { - /** The List encoding format to use. */ + /** + * Effective `allowDots` value. + * + * Returns `true` when `allowDots == true` **or** when `decodeDotInKeys == true` (since decoding + * dots in keys implies dot‑splitting). Otherwise returns `false`. + */ val getAllowDots: Boolean get() = allowDots ?: (decodeDotInKeys == true) - /** The List encoding format to use. */ + /** + * Effective `decodeDotInKeys` value. + * + * Defaults to `false` when unspecified. When `true`, encoded dots (`%2E`/`%2e`) inside key + * segments are mapped to `.` **after** splitting, without introducing extra dot‑splits. + */ val getDecodeDotInKeys: Boolean get() = decodeDotInKeys ?: false @@ -131,13 +156,126 @@ data class DecodeOptions( "Invalid charset" } require(parameterLimit > 0) { "Parameter limit must be positive" } - require(!getDecodeDotInKeys || getAllowDots) { + // If decodeDotInKeys is enabled, allowDots must not be explicitly false. + require(!getDecodeDotInKeys || allowDots != false) { "decodeDotInKeys requires allowDots to be true" } } - /** Decode the input using the specified Decoder. */ + /** + * Unified scalar decode with key/value context. + * + * Uses the provided [decoder] when set; otherwise falls back to [Utils.decode]. For backward + * compatibility, a [legacyDecoder] `(value, charset)` can be supplied and is adapted + * internally. The [kind] will be [DecodeKind.KEY] for keys (and key segments) and + * [DecodeKind.VALUE] for values. + */ + internal fun decode( + value: String?, + charset: Charset? = null, + kind: DecodeKind = DecodeKind.VALUE, + ): Any? { + @Suppress("DEPRECATION") + val d = decoder ?: legacyDecoder?.let { legacy -> Decoder { v, c, _ -> legacy(v, c) } } + return if (d != null) { + d.decode(value, charset, kind) // honor nulls from user decoder + } else { + defaultDecode(value, charset, kind) + } + } + + /** + * Default library decode. + * + * For [DecodeKind.KEY], protects encoded dots (`%2E`/`%2e`) **before** percent‑decoding so key + * splitting and post‑split mapping run on the intended tokens. + */ + private fun defaultDecode(value: String?, charset: Charset?, kind: DecodeKind): Any? { + if (value == null) return null + if (kind == DecodeKind.KEY) { + val protected = + protectEncodedDotsForKeys(value, includeOutsideBrackets = (allowDots == true)) + return Utils.decode(protected, charset) + } + return Utils.decode(value, charset) + } + + /** + * Double‑encode %2E/%2e in KEY strings so the percent‑decoder does not turn them into '.' too + * early. + * + * When [includeOutsideBrackets] is true, occurrences both inside and outside bracket segments + * are protected. Otherwise, only those **inside** `[...]` are protected. Note: only literal + * `[`/`]` affect depth; percent‑encoded brackets (`%5B`/`%5D`) are treated as content, not + * structure. + */ + private fun protectEncodedDotsForKeys(input: String, includeOutsideBrackets: Boolean): String { + val pct = input.indexOf('%') + if (pct < 0) return input + if (input.indexOf("2E", pct) < 0 && input.indexOf("2e", pct) < 0) return input + val n = input.length + val sb = StringBuilder(n + 8) + var depth = 0 + var i = 0 + while (i < n) { + when (val ch = input[i]) { + '[' -> { + depth++ + sb.append(ch) + i++ + } + ']' -> { + if (depth > 0) depth-- + sb.append(ch) + i++ + } + '%' -> { + if ( + i + 2 < n && + input[i + 1] == '2' && + (input[i + 2] == 'E' || input[i + 2] == 'e') + ) { + val inside = depth > 0 + if (inside || includeOutsideBrackets) { + sb.append("%25").append(if (input[i + 2] == 'E') "2E" else "2e") + } else { + sb.append('%').append('2').append(input[i + 2]) + } + i += 3 + } else { + sb.append(ch) + i++ + } + } + else -> { + sb.append(ch) + i++ + } + } + } + return sb.toString() + } + + /** + * Back‑compat helper: decode a value without key/value kind context. + * + * Prefer calling [decode] directly (or [decodeKey]/[decodeValue] for explicit context). + */ + @Deprecated( + message = + "Deprecated: use decodeKey/decodeValue (or decode(value, charset, kind)) to honor key/value context. This will be removed in the next major.", + replaceWith = ReplaceWith("decode(value, charset)"), + level = DeprecationLevel.WARNING, + ) + @Suppress("unused") @JvmOverloads - fun getDecoder(value: String?, charset: Charset? = null): Any? = - if (decoder != null) decoder.invoke(value, charset) else Utils.decode(value, charset) + fun getDecoder(value: String?, charset: Charset? = null): Any? = decode(value, charset) + + /** Convenience: decode a key to String? */ + internal fun decodeKey(value: String?, charset: Charset?): String? = + decode(value, charset, DecodeKind.KEY)?.toString() // keys are always coerced to String + + /** Convenience: decode a value */ + internal fun decodeValue(value: String?, charset: Charset?): Any? = + decode(value, charset, DecodeKind.VALUE) } diff --git a/qs-kotlin/src/main/kotlin/io/github/techouse/qskotlin/models/EncodeOptions.kt b/qs-kotlin/src/main/kotlin/io/github/techouse/qskotlin/models/EncodeOptions.kt index 634cdf2..9674605 100644 --- a/qs-kotlin/src/main/kotlin/io/github/techouse/qskotlin/models/EncodeOptions.kt +++ b/qs-kotlin/src/main/kotlin/io/github/techouse/qskotlin/models/EncodeOptions.kt @@ -51,7 +51,12 @@ data class EncodeOptions( /** The List encoding format to use. */ private val listFormat: ListFormat? = null, - @Deprecated("Use listFormat instead") val indices: Boolean? = null, + @Deprecated( + message = "Use listFormat instead", + replaceWith = ReplaceWith("listFormat"), + level = DeprecationLevel.WARNING, + ) + val indices: Boolean? = null, /** Set to `true` to use dot Map notation in the encoded output. */ private val allowDots: Boolean? = null, diff --git a/qs-kotlin/src/test/kotlin/io/github/techouse/qskotlin/unit/DecodeSpec.kt b/qs-kotlin/src/test/kotlin/io/github/techouse/qskotlin/unit/DecodeSpec.kt index a69daf5..307cd31 100644 --- a/qs-kotlin/src/test/kotlin/io/github/techouse/qskotlin/unit/DecodeSpec.kt +++ b/qs-kotlin/src/test/kotlin/io/github/techouse/qskotlin/unit/DecodeSpec.kt @@ -6,6 +6,7 @@ import io.github.techouse.qskotlin.enums.Duplicates import io.github.techouse.qskotlin.fixtures.data.EmptyTestCases import io.github.techouse.qskotlin.internal.Utils import io.github.techouse.qskotlin.models.DecodeOptions +import io.github.techouse.qskotlin.models.Decoder import io.github.techouse.qskotlin.models.RegexDelimiter import io.github.techouse.qskotlin.models.StringDelimiter import io.kotest.assertions.throwables.shouldNotThrow @@ -585,7 +586,7 @@ class DecodeSpec : } it("use number decoder, parses string that has one number with comma option enabled") { - val decoder: (str: String?, charset: Charset?) -> Any? = { str, charset -> + val decoder = Decoder { str, charset, _ -> str?.toIntOrNull() ?: Utils.decode(str, charset) } @@ -593,6 +594,8 @@ class DecodeSpec : mapOf("foo" to 1) decode("foo=0", DecodeOptions(comma = true, decoder = decoder)) shouldBe mapOf("foo" to 0) + // ensure keys are not coerced to numbers + decode("1=foo", DecodeOptions(decoder = decoder)) shouldBe mapOf("1" to "foo") } it( @@ -733,11 +736,11 @@ class DecodeSpec : it("can parse with custom encoding") { val expected = mapOf("県" to "大阪府") - val decode: (str: String?, charset: Charset?) -> String? = { str, _ -> + val customDecoder = Decoder { str, _, _ -> str?.replace("%8c%a7", "県")?.replace("%91%e5%8d%e3%95%7b", "大阪府") } - decode("%8c%a7=%91%e5%8d%e3%95%7b", DecodeOptions(decoder = decode)) shouldBe + decode("%8c%a7=%91%e5%8d%e3%95%7b", DecodeOptions(decoder = customDecoder)) shouldBe expected } @@ -824,7 +827,7 @@ class DecodeSpec : it( "handles a custom decoder returning `null`, in the `iso-8859-1` charset, when `interpretNumericEntities`" ) { - val decoder: (str: String?, charset: Charset?) -> String? = { str, charset -> + val decoder = Decoder { str, charset, _ -> if (!str.isNullOrEmpty()) Utils.decode(str, charset) else null } @@ -1068,4 +1071,109 @@ class DecodeSpec : } } } + + describe("encoded dot behavior in keys (%2E / %2e)") { + it( + "allowDots=false, decodeDotInKeys=false: encoded dots decode to literal '.'; no dot-splitting" + ) { + decode( + "a%2Eb=c", + DecodeOptions(allowDots = false, decodeDotInKeys = false), + ) shouldBe mapOf("a.b" to "c") + decode( + "a%2eb=c", + DecodeOptions(allowDots = false, decodeDotInKeys = false), + ) shouldBe mapOf("a.b" to "c") + } + + it( + "allowDots=true, decodeDotInKeys=false: encoded dots are preserved inside segments; plain dots split" + ) { + // Plain dot splits + decode("a.b=c", DecodeOptions(allowDots = true, decodeDotInKeys = false)) shouldBe + mapOf("a" to mapOf("b" to "c")) + // Encoded dot stays encoded inside segment (no extra split) + decode( + "name%252Eobj.first=John", + DecodeOptions(allowDots = true, decodeDotInKeys = false), + ) shouldBe mapOf("name%2Eobj" to mapOf("first" to "John")) + // Lowercase variant inside first segment + decode( + "a%2eb.c=d", + DecodeOptions(allowDots = true, decodeDotInKeys = false), + ) shouldBe mapOf("a%2eb" to mapOf("c" to "d")) + } + + it( + "allowDots=true, decodeDotInKeys=true: encoded dots become literal '.' inside a segment (no extra split)" + ) { + decode( + "name%252Eobj.first=John", + DecodeOptions(allowDots = true, decodeDotInKeys = true), + ) shouldBe mapOf("name.obj" to mapOf("first" to "John")) + // Double-encoded single segment becomes a literal dot after post-split mapping + decode( + "a%252Eb=c", + DecodeOptions(allowDots = true, decodeDotInKeys = true), + ) shouldBe mapOf("a.b" to "c") + // Lowercase mapping as well + decode("a[%2e]=x", DecodeOptions(allowDots = true, decodeDotInKeys = true)) shouldBe + mapOf("a" to mapOf("." to "x")) + } + + it("bracket segment: %2E mapped based on decodeDotInKeys; case-insensitive") { + // When disabled, keep %2E literal (no conversion) + decode( + "a[%2E]=x", + DecodeOptions(allowDots = false, decodeDotInKeys = false), + ) shouldBe mapOf("a" to mapOf("%2E" to "x")) + decode( + "a[%2e]=x", + DecodeOptions(allowDots = true, decodeDotInKeys = false), + ) shouldBe mapOf("a" to mapOf("%2e" to "x")) + // When enabled, convert to '.' regardless of case + decode("a[%2E]=x", DecodeOptions(allowDots = true, decodeDotInKeys = true)) shouldBe + mapOf("a" to mapOf("." to "x")) + shouldThrow { + decode("a[%2e]=x", DecodeOptions(allowDots = false, decodeDotInKeys = true)) + } + .message shouldBe "decodeDotInKeys requires allowDots to be true" + } + + it("bare-key (no '='): behavior matches key decoding path") { + // allowDots=false → %2E decodes to '.'; no splitting because allowDots=false + decode( + "a%2Eb", + DecodeOptions( + allowDots = false, + decodeDotInKeys = false, + strictNullHandling = true, + ), + ) shouldBe mapOf("a.b" to null) + // allowDots=true & decodeDotInKeys=false → keep %2E inside key segment + decode("a%2Eb", DecodeOptions(allowDots = true, decodeDotInKeys = false)) shouldBe + mapOf("a%2Eb" to "") + } + + it("depth=0 with allowDots=true: do not split key") { + decode("a.b=c", DecodeOptions(allowDots = true, depth = 0)) shouldBe + mapOf("a.b" to "c") + } + + it("top-level dot→bracket conversion guardrails: leading/trailing/double dots") { + // Leading dot: ".a" should yield { "a": ... } when allowDots=true + decode(".a=x", DecodeOptions(allowDots = true, decodeDotInKeys = false)) shouldBe + mapOf("a" to "x") + + // Trailing dot: "a." should NOT create an empty bracket segment; remains literal + decode("a.=x", DecodeOptions(allowDots = true, decodeDotInKeys = false)) shouldBe + mapOf("a." to "x") + + // Double dots: only the second dot (before a token) causes a split; the empty + // middle + // segment is preserved as a literal dot in the parent key (no `[]` is created) + decode("a..b=x", DecodeOptions(allowDots = true, decodeDotInKeys = false)) shouldBe + mapOf("a." to mapOf("b" to "x")) + } + } }) diff --git a/qs-kotlin/src/test/kotlin/io/github/techouse/qskotlin/unit/ExampleSpec.kt b/qs-kotlin/src/test/kotlin/io/github/techouse/qskotlin/unit/ExampleSpec.kt index 0eaf527..b95d837 100644 --- a/qs-kotlin/src/test/kotlin/io/github/techouse/qskotlin/unit/ExampleSpec.kt +++ b/qs-kotlin/src/test/kotlin/io/github/techouse/qskotlin/unit/ExampleSpec.kt @@ -483,13 +483,14 @@ class ExampleSpec : decode( "%61=%68%65%6c%6c%6f", DecodeOptions( - decoder = { str, _ -> - when (str) { - "%61" -> "a" - "%68%65%6c%6c%6f" -> "hello" - else -> str + decoder = + Decoder { str, _, _ -> + when (str) { + "%61" -> "a" + "%68%65%6c%6c%6f" -> "hello" + else -> str + } } - } ), ) shouldBe mapOf("a" to "hello") } diff --git a/qs-kotlin/src/test/kotlin/io/github/techouse/qskotlin/unit/QsParserSpec.kt b/qs-kotlin/src/test/kotlin/io/github/techouse/qskotlin/unit/QsParserSpec.kt index 9500a03..ba52694 100644 --- a/qs-kotlin/src/test/kotlin/io/github/techouse/qskotlin/unit/QsParserSpec.kt +++ b/qs-kotlin/src/test/kotlin/io/github/techouse/qskotlin/unit/QsParserSpec.kt @@ -2,6 +2,7 @@ package io.github.techouse.qskotlin.unit import io.github.techouse.qskotlin.decode import io.github.techouse.qskotlin.encode +import io.github.techouse.qskotlin.enums.DecodeKind import io.github.techouse.qskotlin.enums.ListFormat import io.github.techouse.qskotlin.internal.Utils import io.github.techouse.qskotlin.models.* @@ -513,11 +514,15 @@ class QsParserSpec : } it("should use number decoder") { - val numberDecoder: Decoder = { value, _ -> - try { - val intValue = value?.toInt() - "[$intValue]" - } catch (_: NumberFormatException) { + val numberDecoder = Decoder { value, _, kind -> + if (kind == DecodeKind.VALUE) { + try { + value?.toInt()?.let { "[$it]" } ?: value + } catch (_: NumberFormatException) { + value + } + } else { + // Leave keys untouched value } } @@ -587,7 +592,7 @@ class QsParserSpec : } it("should parse with custom encoding") { - val customDecoder: Decoder = { content, _ -> + val customDecoder = Decoder { content: String?, _, _ -> try { java.net.URLDecoder.decode(content ?: "", "Shift_JIS") } catch (_: Exception) { @@ -668,9 +673,10 @@ class QsParserSpec : } it("should allow for decoding keys and values") { - val keyValueDecoder: Decoder = { content, _ -> - // Note: Kotlin implementation doesn't distinguish between key and value - // decoding + val keyValueDecoder = Decoder { content: String?, _, _ -> + // This decoder lowercases both keys and values. With DecodeKind available, + // you could branch on kind == DecodeKind.KEY or VALUE if different behaviors + // are desired. content?.lowercase() } val options = DecodeOptions(decoder = keyValueDecoder) diff --git a/qs-kotlin/src/test/kotlin/io/github/techouse/qskotlin/unit/models/DecodeOptionsSpec.kt b/qs-kotlin/src/test/kotlin/io/github/techouse/qskotlin/unit/models/DecodeOptionsSpec.kt index 1267967..40a6d48 100644 --- a/qs-kotlin/src/test/kotlin/io/github/techouse/qskotlin/unit/models/DecodeOptionsSpec.kt +++ b/qs-kotlin/src/test/kotlin/io/github/techouse/qskotlin/unit/models/DecodeOptionsSpec.kt @@ -1,10 +1,12 @@ package io.github.techouse.qskotlin.unit.models +import io.github.techouse.qskotlin.enums.DecodeKind import io.github.techouse.qskotlin.enums.Duplicates import io.github.techouse.qskotlin.models.DecodeOptions import io.github.techouse.qskotlin.models.RegexDelimiter import io.kotest.core.spec.style.DescribeSpec import io.kotest.matchers.shouldBe +import java.nio.charset.Charset import java.nio.charset.StandardCharsets class DecodeOptionsSpec : @@ -101,4 +103,55 @@ class DecodeOptionsSpec : newOptions.strictNullHandling shouldBe false } } + + fun callDefaultDecode( + opts: DecodeOptions, + s: String?, + cs: Charset, + kind: DecodeKind, + ): Any? { + val m = + opts.javaClass.getDeclaredMethod( + "defaultDecode", + String::class.java, + Charset::class.java, + DecodeKind::class.java, + ) + m.isAccessible = true + return m.invoke(opts, s, cs, kind) + } + + val charsets = listOf(StandardCharsets.UTF_8, StandardCharsets.ISO_8859_1) + + describe( + "DecodeOptions.defaultDecode: KEY protects encoded dots prior to percent-decoding" + ) { + it( + "KEY preserves %2E / %2e inside brackets when allowDots=true (UTF-8 and ISO-8859-1)" + ) { + for (cs in charsets) { + val opts = DecodeOptions(allowDots = true) + callDefaultDecode(opts, "a[%2E]", cs, DecodeKind.KEY) shouldBe "a[%2E]" + callDefaultDecode(opts, "a[%2e]", cs, DecodeKind.KEY) shouldBe "a[%2e]" + } + } + + it( + "KEY preserves %2E outside brackets when allowDots=true, regardless of decodeDotInKeys (UTF-8 / ISO)" + ) { + for (cs in charsets) { + val opts1 = DecodeOptions(allowDots = true, decodeDotInKeys = false) + val opts2 = DecodeOptions(allowDots = true, decodeDotInKeys = true) + callDefaultDecode(opts1, "a%2Eb", cs, DecodeKind.KEY) shouldBe "a%2Eb" + callDefaultDecode(opts2, "a%2Eb", cs, DecodeKind.KEY) shouldBe "a%2Eb" + } + } + + it("non-KEY decodes %2E to '.' (control)") { + for (cs in charsets) { + val opts = DecodeOptions(allowDots = true) + callDefaultDecode(opts, "a%2Eb", cs, DecodeKind.VALUE) shouldBe "a.b" + } + } + } })