From e40d131c66323bf7c348580723042589808dc8aa Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Thu, 21 Aug 2025 20:35:57 +0100 Subject: [PATCH 01/24] :sparkles: add DecodeKind enum to distinguish key and value decoding contexts --- QsNet/Enums/DecodeKind.cs | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 QsNet/Enums/DecodeKind.cs diff --git a/QsNet/Enums/DecodeKind.cs b/QsNet/Enums/DecodeKind.cs new file mode 100644 index 0000000..0ea4d22 --- /dev/null +++ b/QsNet/Enums/DecodeKind.cs @@ -0,0 +1,31 @@ +namespace QsNet.Enums; + +/// +/// Indicates the decoding context for a scalar token. +/// +/// +/// Use when decoding a key or key segment so the decoder can apply +/// key-specific rules (for example, preserving percent-encoded dots %2E/%2e +/// until after key splitting). Use for normal value decoding. +/// +public enum DecodeKind +{ + /// + /// The token is a key (or a key segment). + /// + /// + /// Implementations typically avoid turning %2E/%2e into a literal dot + /// before key splitting when this kind is used, to match the semantics of the + /// reference qs library. + /// + Key, + + /// + /// The token is a value. + /// + /// + /// Values are decoded normally (e.g., percent-decoding and charset handling) + /// without any key-specific protections. + /// + Value +} \ No newline at end of file From 2c7102ea4a48a4b5db74bc90956c59811f026ebb Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Thu, 21 Aug 2025 20:36:05 +0100 Subject: [PATCH 02/24] :bug: fix dot decoding in keys with context-aware decoder and improve DecodeOptions API --- QsNet/Models/DecodeOptions.cs | 142 +++++++++++++++++++++++++++++++--- 1 file changed, 133 insertions(+), 9 deletions(-) diff --git a/QsNet/Models/DecodeOptions.cs b/QsNet/Models/DecodeOptions.cs index 35bf183..a262f59 100644 --- a/QsNet/Models/DecodeOptions.cs +++ b/QsNet/Models/DecodeOptions.cs @@ -14,6 +14,16 @@ namespace QsNet.Models; /// The decoded value, or null if the value is not present. public delegate object? Decoder(string? value, Encoding? encoding); +/// +/// A function that decodes a value from a query string or form data with key/value context. +/// The indicates whether the token is a key (or key segment) or a value. +/// +/// The encoded value to decode. +/// The character encoding to use for decoding, if any. +/// Whether this token is a or . +/// The decoded value, or null if the value is not present. +public delegate object? KindAwareDecoder(string? value, Encoding? encoding, DecodeKind kind); + /// /// Options that configure the output of Qs.Decode. /// @@ -40,9 +50,6 @@ public DecodeOptions() if (ParameterLimit <= 0) throw new ArgumentException("Parameter limit must be positive"); - - if (DecodeDotInKeys && !AllowDots) - throw new ArgumentException("decodeDotInKeys requires allowDots to be true"); } /// @@ -160,6 +167,12 @@ public bool AllowDots /// public Decoder? Decoder { private get; init; } + /// + /// Optional decoder that receives key/value context. + /// When provided, this takes precedence over . + /// + public KindAwareDecoder? DecoderWithKind { private get; init; } + /// /// Gets whether to decode dots in keys. /// @@ -170,14 +183,122 @@ public bool DecodeDotInKeys } /// - /// Decode the input using the specified decoder. + /// Decode a single scalar token using the most specific decoder available. + /// If is provided, it is always used (even when it returns null). + /// Otherwise the legacy two-argument is used; if neither is set, + /// a library default is used. + /// + public object? Decode(string? value, Encoding? encoding = null, DecodeKind kind = DecodeKind.Value) + { + if (kind == DecodeKind.Key && DecodeDotInKeys && !AllowDots) + throw new ArgumentException("decodeDotInKeys requires allowDots to be true"); + var d3 = DecoderWithKind; + if (d3 is not null) return d3.Invoke(value, encoding, kind); + + var d = Decoder; + return d is not null ? d.Invoke(value, encoding) : DefaultDecode(value, encoding, kind); + } + + /// + /// Decode a key (or key segment). Returns a string or null. + /// + public string? DecodeKey(string? value, Encoding? encoding = null) + { + return Decode(value, encoding, DecodeKind.Key) as string; + } + + /// + /// Decode a value token. Returns any scalar (string/number/etc.) or null. + /// + public object? DecodeValue(string? value, Encoding? encoding = null) + { + return Decode(value, encoding); + } + + /// + /// Default decoder used when no custom decoder is supplied. + /// For , this protects encoded dots ("%2E"/"%2e") before + /// percent-decoding so that dot-splitting and post-split mapping behave correctly. + /// Inside bracket segments we always protect; outside brackets we only protect when + /// is true. + /// + private object? DefaultDecode(string? value, Encoding? encoding, DecodeKind kind) + { + if (value is null) return null; + if (kind == DecodeKind.Key) + { + var protectedKey = ProtectEncodedDotsForKeys(value, AllowDots); + return Utils.Decode(protectedKey, encoding); + } + + return Utils.Decode(value, encoding); + } + + // Protect %2E/%2e in KEY strings so percent-decoding does not turn them into '.' too early. + // Inside brackets we always protect; outside brackets only when includeOutsideBrackets is true. + private static string ProtectEncodedDotsForKeys(string input, bool includeOutsideBrackets) + { + if (string.IsNullOrEmpty(input) || input.IndexOf('%') < 0) + return input; + + var sb = new StringBuilder(input.Length + 8); + var depth = 0; + for (var i = 0; i < input.Length;) + { + var ch = input[i]; + if (ch == '[') + { + depth++; + sb.Append(ch); + i++; + } + else if (ch == ']') + { + if (depth > 0) depth--; + sb.Append(ch); + i++; + } + else if (ch == '%' && i + 2 < input.Length && input[i + 1] == '2' && + (input[i + 2] == 'E' || input[i + 2] == 'e')) + { + var inside = depth > 0; + if (inside || includeOutsideBrackets) + { + sb.Append("%25"); + sb.Append(input[i + 2] == 'E' ? "2E" : "2e"); + } + else + { + sb.Append('%').Append('2').Append(input[i + 2]); + } + + i += 3; + } + else + { + sb.Append(ch); + i++; + } + } + + return sb.ToString(); + } + + /// + /// Back-compat convenience that decodes a single value as a value token. /// - /// The value to decode - /// The encoding to use - /// The decoded value + /// The encoded value to decode (may be null). + /// The character encoding to use, or null to use the default. + /// The decoded value, or null if is null. + /// + /// Prefer , , + /// or for context-aware decoding. This method always decodes + /// with . + /// + [Obsolete("Use Decode(value, encoding) or DecodeKey/DecodeValue for context-aware decoding.")] public object? GetDecoder(string? value, Encoding? encoding = null) { - return Decoder != null ? Decoder?.Invoke(value, encoding) : Utils.Decode(value, encoding); + return Decode(value, encoding); } /// @@ -185,6 +306,7 @@ public bool DecodeDotInKeys /// /// Set to override AllowDots /// Set to override the decoder function + /// Set to override the kind-aware decoder function /// Set to override DecodeDotInKeys /// Set to override AllowEmptyLists /// Set to override AllowSparseLists @@ -206,6 +328,7 @@ public bool DecodeDotInKeys public DecodeOptions CopyWith( bool? allowDots = null, Decoder? decoder = null, + KindAwareDecoder? decoderWithKind = null, bool? decodeDotInKeys = null, bool? allowEmptyLists = null, bool? allowSparseLists = null, @@ -235,7 +358,8 @@ public DecodeOptions CopyWith( CharsetSentinel = charsetSentinel ?? CharsetSentinel, Comma = comma ?? Comma, DecodeDotInKeys = decodeDotInKeys ?? DecodeDotInKeys, - Decoder = decoder ?? GetDecoder, + Decoder = decoder ?? Decoder, + DecoderWithKind = decoderWithKind ?? DecoderWithKind, Delimiter = delimiter ?? Delimiter, Depth = depth ?? Depth, ParameterLimit = parameterLimit ?? ParameterLimit, From 0284a30648f2c3b6bfaa42b98dce6bad6d210d72 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Thu, 21 Aug 2025 20:36:12 +0100 Subject: [PATCH 03/24] :refactor: replace GetDecoder with DecodeKey and DecodeValue for improved key/value decoding clarity --- QsNet/Internal/Decoder.cs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/QsNet/Internal/Decoder.cs b/QsNet/Internal/Decoder.cs index ee59ddc..ff84e36 100644 --- a/QsNet/Internal/Decoder.cs +++ b/QsNet/Internal/Decoder.cs @@ -49,6 +49,7 @@ private static bool IsLatin1(Encoding e) => Equals(e, Encoding.Latin1); #endif + /// /// Parses a list value from a string or any other type, applying the options provided. /// @@ -163,15 +164,17 @@ int currentListLength if (pos == -1) { - key = options.GetDecoder(part, charset)?.ToString() ?? string.Empty; + key = options.DecodeKey(part, charset) ?? string.Empty; value = options.StrictNullHandling ? null : ""; } else { #if NETSTANDARD2_0 - key = options.GetDecoder(part.Substring(0, pos), charset)?.ToString() ?? string.Empty; + var rawKey = part.Substring(0, pos); + key = options.DecodeKey(rawKey, charset) ?? string.Empty; #else - key = options.GetDecoder(part[..pos], charset)?.ToString() ?? string.Empty; + var rawKey = part[..pos]; + key = options.DecodeKey(rawKey, charset) ?? string.Empty; #endif var currentLength = obj.TryGetValue(key, out var val) && val is IList list ? list.Count : 0; @@ -179,12 +182,12 @@ int currentListLength #if NETSTANDARD2_0 value = Utils.Apply( ParseListValue(part.Substring(pos + 1), options, currentLength), - v => options.GetDecoder(v?.ToString(), charset) + v => options.DecodeValue(v?.ToString(), charset) ); #else value = Utils.Apply( ParseListValue(part[(pos + 1)..], options, currentLength), - v => options.GetDecoder(v?.ToString(), charset) + v => options.DecodeValue(v?.ToString(), charset) ); #endif } From c77f29fd58205635a27118803dcb0f48a41b5ac8 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Thu, 21 Aug 2025 20:36:19 +0100 Subject: [PATCH 04/24] :white_check_mark: add DecodeOptions tests for dot handling, decoder precedence, and CopyWith behavior --- QsNet.Tests/DecodeOptionsTests.cs | 109 ++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/QsNet.Tests/DecodeOptionsTests.cs b/QsNet.Tests/DecodeOptionsTests.cs index 9a42276..a35f271 100644 --- a/QsNet.Tests/DecodeOptionsTests.cs +++ b/QsNet.Tests/DecodeOptionsTests.cs @@ -1,3 +1,5 @@ +using System; +using System.Collections.Generic; using System.Text; using FluentAssertions; using QsNet.Enums; @@ -108,4 +110,111 @@ public void CopyWith_WithModifications_ShouldReturnModifiedOptions() newOptions.ParseLists.Should().BeTrue(); newOptions.StrictNullHandling.Should().BeFalse(); } + + [Fact] + public void DecodeKey_ShouldThrow_When_DecodeDotInKeysTrue_And_AllowDotsFalse() + { + var options = new DecodeOptions + { + AllowDots = false, + DecodeDotInKeys = true + }; + + Action act = () => options.DecodeKey("a%2Eb", Encoding.UTF8); + act.Should().Throw() + .WithMessage("decodeDotInKeys requires allowDots to be true"); + } + + [Fact] + public void DecodeKey_ProtectsEncodedDots_BeforePercentDecoding() + { + var options = new DecodeOptions + { + AllowDots = true, + DecodeDotInKeys = false + }; + + // `%2E` should not decode to '.' when decoding a KEY; it should remain "%2E" after one call + options.DecodeKey("a%2Eb", Encoding.UTF8).Should().Be("a%2Eb"); + options.DecodeKey("a%2eb", Encoding.UTF8).Should().Be("a%2eb"); + } + + [Fact] + public void DecodeValue_DecodesPercentSequences_Normally() + { + var options = new DecodeOptions(); + options.DecodeValue("%2E", Encoding.UTF8).Should().Be("."); + } + + [Fact] + public void DecoderWithKind_IsUsed_For_Key_And_Value() + { + var calls = new List<(string? s, DecodeKind kind)>(); + var options = new DecodeOptions + { + DecoderWithKind = (s, enc, kind) => + { + calls.Add((s, kind)); + return s; + } + }; + + options.DecodeKey("x", Encoding.UTF8).Should().Be("x"); + options.DecodeValue("y", Encoding.UTF8).Should().Be("y"); + + calls.Should().HaveCount(2); + calls[0].kind.Should().Be(DecodeKind.Key); + calls[0].s.Should().Be("x"); + calls[1].kind.Should().Be(DecodeKind.Value); + calls[1].s.Should().Be("y"); + } + + [Fact] + public void DecoderWithKind_NullReturn_IsHonored_NoFallback() + { + var options = new DecodeOptions + { + DecoderWithKind = (s, enc, kind) => null + }; + + options.DecodeValue("foo", Encoding.UTF8).Should().BeNull(); + options.DecodeKey("bar", Encoding.UTF8).Should().BeNull(); + } + + [Fact] + public void LegacyDecoder_IsUsed_When_NoKindAwareDecoder_IsProvided() + { + var options = new DecodeOptions + { + Decoder = (s, enc) => s is null ? null : s.ToUpperInvariant() + }; + + options.DecodeValue("abc", Encoding.UTF8).Should().Be("ABC"); + // For keys, legacy decoder is also used when no kind-aware decoder is set + options.DecodeKey("a%2Eb", Encoding.UTF8).Should().Be("A%2EB"); + } + + [Fact] + public void CopyWith_PreservesAndOverrides_Decoders() + { + var original = new DecodeOptions + { + Decoder = (s, enc) => s == null ? null : $"L:{s}", + DecoderWithKind = (s, enc, k) => s == null ? null : $"K:{k}:{s}" + }; + + // Copy without overrides preserves both decoders + var copy = original.CopyWith(); + copy.DecodeValue("v", Encoding.UTF8).Should().Be("K:Value:v"); + copy.DecodeKey("k", Encoding.UTF8).Should().Be("K:Key:k"); + + // Override only the legacy decoder; kind-aware remains + var copy2 = original.CopyWith(decoder: (s, enc) => s == null ? null : $"L2:{s}"); + copy2.DecodeValue("v", Encoding.UTF8).Should().Be("K:Value:v"); // still kind-aware takes precedence + + // Override kind-aware decoder + var copy3 = original.CopyWith(decoderWithKind: (s, enc, k) => s == null ? null : $"K2:{k}:{s}"); + copy3.DecodeValue("v", Encoding.UTF8).Should().Be("K2:Value:v"); + copy3.DecodeKey("k", Encoding.UTF8).Should().Be("K2:Key:k"); + } } \ No newline at end of file From 3669f468eafd2e7378bfa7ea780d642d4e7ddd44 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Thu, 21 Aug 2025 20:36:38 +0100 Subject: [PATCH 05/24] :white_check_mark: add tests for encoded dot handling in keys with AllowDots and DecodeDotInKeys options --- QsNet.Tests/DecodeTests.cs | 129 +++++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/QsNet.Tests/DecodeTests.cs b/QsNet.Tests/DecodeTests.cs index 8406fae..ab50b75 100644 --- a/QsNet.Tests/DecodeTests.cs +++ b/QsNet.Tests/DecodeTests.cs @@ -4123,4 +4123,133 @@ public void Decode_NonGeneric_Hashtable_Is_Normalised() // Assert – keys are strings and values preserved decoded.Should().Equal(new Dictionary { ["x"] = 1, ["2"] = "y" }); } + + #region Encoded dot behavior in keys (%2E / %2e) + + [Fact] + public void EncodedDot_TopLevel_AllowDotsTrue_DecodeDotInKeysTrue_PlainDotSplits_EncodedDotDoesNotSplit() + { + var opt = new DecodeOptions { AllowDots = true, DecodeDotInKeys = true }; + + Qs.Decode("a.b=c", opt) + .Should() + .BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary { ["b"] = "c" } + }); + + Qs.Decode("a%2Eb=c", opt) + .Should() + .BeEquivalentTo(new Dictionary { ["a.b"] = "c" }); + + Qs.Decode("a%2eb=c", opt) + .Should() + .BeEquivalentTo(new Dictionary { ["a.b"] = "c" }); + } + + [Fact] + public void EncodedDot_TopLevel_AllowDotsTrue_DecodeDotInKeysFalse_EncodedDotRemainsPercentSequence() + { + var opt = new DecodeOptions { AllowDots = true, DecodeDotInKeys = false }; + + Qs.Decode("a%2Eb=c", opt) + .Should() + .BeEquivalentTo(new Dictionary { ["a%2Eb"] = "c" }); + + Qs.Decode("a%2eb=c", opt) + .Should() + .BeEquivalentTo(new Dictionary { ["a%2eb"] = "c" }); + } + + [Fact] + public void EncodedDot_AllowDotsFalse_DecodeDotInKeysTrue_IsInvalid() + { + var opt = new DecodeOptions { AllowDots = false, DecodeDotInKeys = true }; + Action act = () => Qs.Decode("a%2Eb=c", opt); + act.Should().Throw(); + } + + [Fact] + public void EncodedDot_BracketSegment_MapsToDot_WhenDecodeDotInKeysTrue() + { + var opt = new DecodeOptions { AllowDots = true, DecodeDotInKeys = true }; + + Qs.Decode("a[%2E]=x", opt) + .Should() + .BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary { ["."] = "x" } + }); + + Qs.Decode("a[%2e]=x", opt) + .Should() + .BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary { ["."] = "x" } + }); + } + + [Fact] + public void EncodedDot_BracketSegment_RemainsPercentSequence_WhenDecodeDotInKeysFalse() + { + var opt = new DecodeOptions { AllowDots = true, DecodeDotInKeys = false }; + + Qs.Decode("a[%2E]=x", opt) + .Should() + .BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary { ["%2E"] = "x" } + }); + + Qs.Decode("a[%2e]=x", opt) + .Should() + .BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary { ["%2e"] = "x" } + }); + } + + [Fact] + public void EncodedDot_ValueAlwaysDecodesToDot() + { + Qs.Decode("x=%2E") + .Should() + .BeEquivalentTo(new Dictionary { ["x"] = "." }); + } + + [Fact] + public void EncodedDot_TopLevel_Latin1_AllowDotsTrue_DecodeDotInKeysTrue() + { + var opt = new DecodeOptions { AllowDots = true, DecodeDotInKeys = true, Charset = Encoding.Latin1 }; + + Qs.Decode("a%2Eb=c", opt) + .Should() + .BeEquivalentTo(new Dictionary { ["a.b"] = "c" }); + + Qs.Decode("a[%2E]=x", opt) + .Should() + .BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary { ["."] = "x" } + }); + } + + [Fact] + public void EncodedDot_TopLevel_Latin1_AllowDotsTrue_DecodeDotInKeysFalse() + { + var opt = new DecodeOptions { AllowDots = true, DecodeDotInKeys = false, Charset = Encoding.Latin1 }; + + Qs.Decode("a%2Eb=c", opt) + .Should() + .BeEquivalentTo(new Dictionary { ["a%2Eb"] = "c" }); + + Qs.Decode("a[%2E]=x", opt) + .Should() + .BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary { ["%2E"] = "x" } + }); + } + + #endregion } \ No newline at end of file From 960551359942aca9f15863373f267fbd868070ce Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Thu, 21 Aug 2025 21:55:32 +0100 Subject: [PATCH 06/24] :fire: remove obsolete GetDecoder method in favor of context-aware decoding APIs --- QsNet/Models/DecodeOptions.cs | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/QsNet/Models/DecodeOptions.cs b/QsNet/Models/DecodeOptions.cs index a262f59..db45193 100644 --- a/QsNet/Models/DecodeOptions.cs +++ b/QsNet/Models/DecodeOptions.cs @@ -284,22 +284,6 @@ private static string ProtectEncodedDotsForKeys(string input, bool includeOutsid return sb.ToString(); } - /// - /// Back-compat convenience that decodes a single value as a value token. - /// - /// The encoded value to decode (may be null). - /// The character encoding to use, or null to use the default. - /// The decoded value, or null if is null. - /// - /// Prefer , , - /// or for context-aware decoding. This method always decodes - /// with . - /// - [Obsolete("Use Decode(value, encoding) or DecodeKey/DecodeValue for context-aware decoding.")] - public object? GetDecoder(string? value, Encoding? encoding = null) - { - return Decode(value, encoding); - } /// /// Creates a new instance of DecodeOptions with the specified properties changed. From 69d17ab4ac083b02bb4417485200c2e94877da32 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Thu, 21 Aug 2025 21:57:52 +0100 Subject: [PATCH 07/24] :bulb: clarify AllowDots behavior when DecodeDotInKeys is enabled --- QsNet/Models/DecodeOptions.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/QsNet/Models/DecodeOptions.cs b/QsNet/Models/DecodeOptions.cs index db45193..bb65e26 100644 --- a/QsNet/Models/DecodeOptions.cs +++ b/QsNet/Models/DecodeOptions.cs @@ -155,6 +155,8 @@ public DecodeOptions() /// /// Set to true to use dot dictionary notation in the encoded output. + /// Note: when not explicitly set, this property implicitly evaluates to true + /// if is true, to keep option combinations coherent. /// public bool AllowDots { From cf37563c8178b9f003443f01794697f36ecc726a Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Thu, 21 Aug 2025 21:59:10 +0100 Subject: [PATCH 08/24] :white_check_mark: update DecodeOptions test to use flexible exception message matching for dot handling --- QsNet.Tests/DecodeOptionsTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/QsNet.Tests/DecodeOptionsTests.cs b/QsNet.Tests/DecodeOptionsTests.cs index a35f271..2ccf9ec 100644 --- a/QsNet.Tests/DecodeOptionsTests.cs +++ b/QsNet.Tests/DecodeOptionsTests.cs @@ -122,7 +122,8 @@ public void DecodeKey_ShouldThrow_When_DecodeDotInKeysTrue_And_AllowDotsFalse() Action act = () => options.DecodeKey("a%2Eb", Encoding.UTF8); act.Should().Throw() - .WithMessage("decodeDotInKeys requires allowDots to be true"); + .Where(e => e.Message.Contains("decodeDotInKeys", StringComparison.OrdinalIgnoreCase) + && e.Message.Contains("allowDots", StringComparison.OrdinalIgnoreCase)); } [Fact] From b91d0ad50e10cfe41908638df9de48aff0a39c26 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Thu, 21 Aug 2025 22:00:33 +0100 Subject: [PATCH 09/24] :bug: ensure DecodeKey returns string or null, throw if decoder returns invalid type --- QsNet/Models/DecodeOptions.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/QsNet/Models/DecodeOptions.cs b/QsNet/Models/DecodeOptions.cs index bb65e26..a20f741 100644 --- a/QsNet/Models/DecodeOptions.cs +++ b/QsNet/Models/DecodeOptions.cs @@ -206,7 +206,14 @@ public bool DecodeDotInKeys /// public string? DecodeKey(string? value, Encoding? encoding = null) { - return Decode(value, encoding, DecodeKind.Key) as string; + var decoded = Decode(value, encoding, DecodeKind.Key); + return decoded switch + { + null => null, + string s => s, + _ => throw new InvalidOperationException( + $"Key decoder must return a string or null; got {decoded.GetType().FullName}.") + }; } /// From a2afb175d31dc758a2bf4555bd74904874190966 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Thu, 21 Aug 2025 22:01:16 +0100 Subject: [PATCH 10/24] :bulb: document implied AllowDots behavior when DecodeDotInKeys is enabled --- QsNet/Models/DecodeOptions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/QsNet/Models/DecodeOptions.cs b/QsNet/Models/DecodeOptions.cs index a20f741..fbbcb30 100644 --- a/QsNet/Models/DecodeOptions.cs +++ b/QsNet/Models/DecodeOptions.cs @@ -161,7 +161,7 @@ public DecodeOptions() public bool AllowDots { init => _allowDots = value; - get => _allowDots ?? _decodeDotInKeys == true; + get => _allowDots ?? _decodeDotInKeys == true; // implied true when DecodeDotInKeys is true } /// From a80c88a7ce4a79183d0889e6a7c73849c8be4b52 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Thu, 21 Aug 2025 22:17:42 +0100 Subject: [PATCH 11/24] :bug: handle incomplete percent escapes in decoder and clarify AllowDots requirement for DecodeDotInKeys --- QsNet/Models/DecodeOptions.cs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/QsNet/Models/DecodeOptions.cs b/QsNet/Models/DecodeOptions.cs index fbbcb30..0ba8bb7 100644 --- a/QsNet/Models/DecodeOptions.cs +++ b/QsNet/Models/DecodeOptions.cs @@ -22,6 +22,7 @@ namespace QsNet.Models; /// The character encoding to use for decoding, if any. /// Whether this token is a or . /// The decoded value, or null if the value is not present. +/// When is , the decoder is expected to return a string or null. public delegate object? KindAwareDecoder(string? value, Encoding? encoding, DecodeKind kind); /// @@ -154,7 +155,7 @@ public DecodeOptions() public bool ThrowOnLimitExceeded { get; init; } /// - /// Set to true to use dot dictionary notation in the encoded output. + /// Set to true to parse dot dictionary notation in the encoded input. /// Note: when not explicitly set, this property implicitly evaluates to true /// if is true, to keep option combinations coherent. /// @@ -193,7 +194,8 @@ public bool DecodeDotInKeys public object? Decode(string? value, Encoding? encoding = null, DecodeKind kind = DecodeKind.Value) { if (kind == DecodeKind.Key && DecodeDotInKeys && !AllowDots) - throw new ArgumentException("decodeDotInKeys requires allowDots to be true"); + throw new ArgumentException( + "Invalid DecodeOptions: DecodeDotInKeys=true requires AllowDots=true when decoding keys."); var d3 = DecoderWithKind; if (d3 is not null) return d3.Invoke(value, encoding, kind); @@ -212,7 +214,8 @@ public bool DecodeDotInKeys null => null, string s => s, _ => throw new InvalidOperationException( - $"Key decoder must return a string or null; got {decoded.GetType().FullName}.") + $"Key decoder must return a string or null; got {decoded.GetType().FullName}. " + + "If using a custom decoder, ensure it returns string for keys.") }; } @@ -283,6 +286,12 @@ private static string ProtectEncodedDotsForKeys(string input, bool includeOutsid i += 3; } + else if (ch == '%' && (i + 2 >= input.Length || input[i + 1] == '\0' || input[i + 2] == '\0')) + { + // Leave malformed/incomplete escape as-is + sb.Append(ch); + i++; + } else { sb.Append(ch); From bfc18ce2c32aa4bb4b73400c88d9b259843c2843 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Thu, 21 Aug 2025 22:17:47 +0100 Subject: [PATCH 12/24] :white_check_mark: add tests to verify encoded dots inside brackets are protected in DecodeKey regardless of AllowDots setting --- QsNet.Tests/DecodeOptionsTests.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/QsNet.Tests/DecodeOptionsTests.cs b/QsNet.Tests/DecodeOptionsTests.cs index 2ccf9ec..24cf0e1 100644 --- a/QsNet.Tests/DecodeOptionsTests.cs +++ b/QsNet.Tests/DecodeOptionsTests.cs @@ -218,4 +218,18 @@ public void CopyWith_PreservesAndOverrides_Decoders() copy3.DecodeValue("v", Encoding.UTF8).Should().Be("K2:Value:v"); copy3.DecodeKey("k", Encoding.UTF8).Should().Be("K2:Key:k"); } + + [Fact] + public void DecodeKey_ProtectsEncodedDots_InsideBrackets_RegardlessOfAllowDots() + { + var o1 = new DecodeOptions { AllowDots = false, DecodeDotInKeys = false }; + var o2 = new DecodeOptions { AllowDots = true, DecodeDotInKeys = false }; + + // Inside bracket: always protected + o1.DecodeKey("a[%2Eb]", Encoding.UTF8).Should().Be("a[%2Eb]"); + o1.DecodeKey("a[b%2Ec]", Encoding.UTF8).Should().Be("a[b%2Ec]"); + + o2.DecodeKey("a[%2Eb]", Encoding.UTF8).Should().Be("a[%2Eb]"); + o2.DecodeKey("a[b%2Ec]", Encoding.UTF8).Should().Be("a[b%2Ec]"); + } } \ No newline at end of file From 261a530ee354cdeb80eab6f9224a59eeb34c5422 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Fri, 22 Aug 2025 08:48:29 +0100 Subject: [PATCH 13/24] :bug: correctly track bracket depth in DecodeKey when brackets are percent-encoded --- QsNet/Models/DecodeOptions.cs | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/QsNet/Models/DecodeOptions.cs b/QsNet/Models/DecodeOptions.cs index 0ba8bb7..52d6f40 100644 --- a/QsNet/Models/DecodeOptions.cs +++ b/QsNet/Models/DecodeOptions.cs @@ -22,7 +22,10 @@ namespace QsNet.Models; /// The character encoding to use for decoding, if any. /// Whether this token is a or . /// The decoded value, or null if the value is not present. -/// When is , the decoder is expected to return a string or null. +/// +/// When is , the decoder is expected to return a string or +/// null. +/// public delegate object? KindAwareDecoder(string? value, Encoding? encoding, DecodeKind kind); /// @@ -195,7 +198,9 @@ public bool DecodeDotInKeys { if (kind == DecodeKind.Key && DecodeDotInKeys && !AllowDots) throw new ArgumentException( - "Invalid DecodeOptions: DecodeDotInKeys=true requires AllowDots=true when decoding keys."); + "DecodeDotInKeys=true requires AllowDots=true when decoding keys.", + nameof(DecodeDotInKeys) + ); var d3 = DecoderWithKind; if (d3 is not null) return d3.Invoke(value, encoding, kind); @@ -270,6 +275,21 @@ private static string ProtectEncodedDotsForKeys(string input, bool includeOutsid sb.Append(ch); i++; } + // Handle percent-encoded brackets to track depth even when [] are encoded. + else if (ch == '%' && i + 2 < input.Length && input[i + 1] == '5' && + (input[i + 2] == 'B' || input[i + 2] == 'b')) + { + depth++; + sb.Append('%').Append('5').Append(input[i + 2]); + i += 3; + } + else if (ch == '%' && i + 2 < input.Length && input[i + 1] == '5' && + (input[i + 2] == 'D' || input[i + 2] == 'd')) + { + if (depth > 0) depth--; + sb.Append('%').Append('5').Append(input[i + 2]); + i += 3; + } else if (ch == '%' && i + 2 < input.Length && input[i + 1] == '2' && (input[i + 2] == 'E' || input[i + 2] == 'e')) { @@ -286,7 +306,7 @@ private static string ProtectEncodedDotsForKeys(string input, bool includeOutsid i += 3; } - else if (ch == '%' && (i + 2 >= input.Length || input[i + 1] == '\0' || input[i + 2] == '\0')) + else if (ch == '%' && i + 2 >= input.Length) { // Leave malformed/incomplete escape as-is sb.Append(ch); From 8840f4fa7cbadd9b09ee4e365c297711c20a5f60 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Fri, 22 Aug 2025 09:13:13 +0100 Subject: [PATCH 14/24] :bug: optimize DecodeKey to skip scanning when no encoded dots or brackets are present; refactor bracket/dot decoding logic with switch statement --- QsNet/Models/DecodeOptions.cs | 113 ++++++++++++++++++---------------- 1 file changed, 60 insertions(+), 53 deletions(-) diff --git a/QsNet/Models/DecodeOptions.cs b/QsNet/Models/DecodeOptions.cs index 52d6f40..18f6989 100644 --- a/QsNet/Models/DecodeOptions.cs +++ b/QsNet/Models/DecodeOptions.cs @@ -196,7 +196,7 @@ public bool DecodeDotInKeys /// public object? Decode(string? value, Encoding? encoding = null, DecodeKind kind = DecodeKind.Value) { - if (kind == DecodeKind.Key && DecodeDotInKeys && !AllowDots) + if (kind == DecodeKind.Key && _decodeDotInKeys == true && _allowDots == false) throw new ArgumentException( "DecodeDotInKeys=true requires AllowDots=true when decoding keys.", nameof(DecodeDotInKeys) @@ -257,65 +257,72 @@ private static string ProtectEncodedDotsForKeys(string input, bool includeOutsid { if (string.IsNullOrEmpty(input) || input.IndexOf('%') < 0) return input; + // Fast-path: if there are no encoded dots or brackets, skip scanning. + if (input.IndexOf("%2E", StringComparison.OrdinalIgnoreCase) < 0 + && input.IndexOf("%5B", StringComparison.OrdinalIgnoreCase) < 0 + && input.IndexOf("%5D", StringComparison.OrdinalIgnoreCase) < 0) + return input; var sb = new StringBuilder(input.Length + 8); var depth = 0; for (var i = 0; i < input.Length;) { var ch = input[i]; - if (ch == '[') - { - depth++; - sb.Append(ch); - i++; - } - else if (ch == ']') - { - if (depth > 0) depth--; - sb.Append(ch); - i++; - } - // Handle percent-encoded brackets to track depth even when [] are encoded. - else if (ch == '%' && i + 2 < input.Length && input[i + 1] == '5' && - (input[i + 2] == 'B' || input[i + 2] == 'b')) - { - depth++; - sb.Append('%').Append('5').Append(input[i + 2]); - i += 3; - } - else if (ch == '%' && i + 2 < input.Length && input[i + 1] == '5' && - (input[i + 2] == 'D' || input[i + 2] == 'd')) - { - if (depth > 0) depth--; - sb.Append('%').Append('5').Append(input[i + 2]); - i += 3; - } - else if (ch == '%' && i + 2 < input.Length && input[i + 1] == '2' && - (input[i + 2] == 'E' || input[i + 2] == 'e')) - { - var inside = depth > 0; - if (inside || includeOutsideBrackets) - { - sb.Append("%25"); - sb.Append(input[i + 2] == 'E' ? "2E" : "2e"); - } - else - { - sb.Append('%').Append('2').Append(input[i + 2]); - } - - i += 3; - } - else if (ch == '%' && i + 2 >= input.Length) - { - // Leave malformed/incomplete escape as-is - sb.Append(ch); - i++; - } - else + switch (ch) { - sb.Append(ch); - i++; + case '[': + depth++; + sb.Append(ch); + i++; + break; + case ']': + { + if (depth > 0) depth--; + sb.Append(ch); + i++; + break; + } + // Handle percent-encoded brackets to track depth even when [] are encoded. + case '%' when i + 2 < input.Length && input[i + 1] == '5' && + (input[i + 2] == 'B' || input[i + 2] == 'b'): + depth++; + sb.Append('%').Append('5').Append(input[i + 2]); + i += 3; + break; + case '%' when i + 2 < input.Length && input[i + 1] == '5' && + (input[i + 2] == 'D' || input[i + 2] == 'd'): + { + if (depth > 0) depth--; + sb.Append('%').Append('5').Append(input[i + 2]); + i += 3; + break; + } + case '%' when i + 2 < input.Length && input[i + 1] == '2' && + (input[i + 2] == 'E' || input[i + 2] == 'e'): + { + var inside = depth > 0; + if (inside || includeOutsideBrackets) + { + sb.Append("%25"); + sb.Append(input[i + 2] == 'E' ? "2E" : "2e"); + } + else + { + sb.Append('%').Append('2').Append(input[i + 2]); + } + + i += 3; + break; + } + case '%' when i + 2 >= input.Length: + // Leave malformed/incomplete escape as-is + sb.Append(ch); + i++; + break; + default: + sb.Append(ch); + i++; + break; } } From e7e8cb19a41aaf90f2d7d503d071d9f768ff6f61 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Fri, 22 Aug 2025 09:50:14 +0100 Subject: [PATCH 15/24] :bug: simplify DefaultDecode logic for key decoding; add XML docs to ProtectEncodedDotsForKeys --- QsNet/Models/DecodeOptions.cs | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/QsNet/Models/DecodeOptions.cs b/QsNet/Models/DecodeOptions.cs index 18f6989..4469398 100644 --- a/QsNet/Models/DecodeOptions.cs +++ b/QsNet/Models/DecodeOptions.cs @@ -242,21 +242,23 @@ public bool DecodeDotInKeys private object? DefaultDecode(string? value, Encoding? encoding, DecodeKind kind) { if (value is null) return null; - if (kind == DecodeKind.Key) - { - var protectedKey = ProtectEncodedDotsForKeys(value, AllowDots); - return Utils.Decode(protectedKey, encoding); - } - - return Utils.Decode(value, encoding); + if (kind != DecodeKind.Key) return Utils.Decode(value, encoding); + var protectedKey = ProtectEncodedDotsForKeys(value, AllowDots); + return Utils.Decode(protectedKey, encoding); } - // Protect %2E/%2e in KEY strings so percent-decoding does not turn them into '.' too early. - // Inside brackets we always protect; outside brackets only when includeOutsideBrackets is true. + /// + /// Protect %2E/%2e in KEY strings so percent-decoding does not turn them into '.' too early. + /// Inside brackets we always protect; outside brackets only when includeOutsideBrackets is true. + /// + /// + /// + /// private static string ProtectEncodedDotsForKeys(string input, bool includeOutsideBrackets) { if (string.IsNullOrEmpty(input) || input.IndexOf('%') < 0) return input; + // Fast-path: if there are no encoded dots or brackets, skip scanning. if (input.IndexOf("%2E", StringComparison.OrdinalIgnoreCase) < 0 && input.IndexOf("%5B", StringComparison.OrdinalIgnoreCase) < 0 From 32b069d6989b711152002b9422a65f4ee08f80b3 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Fri, 22 Aug 2025 10:56:48 +0100 Subject: [PATCH 16/24] :bug: decode keys identically to values in DefaultDecode; fix bracket segment splitting to handle trailing text after last closing bracket --- QsNet/Internal/Decoder.cs | 10 ++++++---- QsNet/Models/DecodeOptions.cs | 14 ++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/QsNet/Internal/Decoder.cs b/QsNet/Internal/Decoder.cs index ff84e36..c64ff67 100644 --- a/QsNet/Internal/Decoder.cs +++ b/QsNet/Internal/Decoder.cs @@ -435,6 +435,7 @@ bool strictDepth var open = first; var depth = 0; + var lastClose = -1; while (open >= 0 && depth < maxDepth) { var close = key.IndexOf(']', open + 1); @@ -445,20 +446,21 @@ bool strictDepth #else segments.Add(key[open..(close + 1)]); // e.g. "[p]" or "[]" #endif + lastClose = close; depth++; open = key.IndexOf('[', close + 1); } - if (open < 0) return segments; - // When depth > 0, strictDepth can apply to the remainder. + // If there's any trailing text after the last closing bracket, treat it as a single final segment. + if (lastClose < 0 || lastClose + 1 >= key.Length) return segments; if (strictDepth) throw new IndexOutOfRangeException( $"Input depth exceeded depth option of {maxDepth} and strictDepth is true" ); #if NETSTANDARD2_0 - segments.Add("[" + key.Substring(open) + "]"); + segments.Add("[" + key.Substring(lastClose + 1) + "]"); #else - segments.Add("[" + key[open..] + "]"); + segments.Add("[" + key[(lastClose + 1)..] + "]"); #endif return segments; diff --git a/QsNet/Models/DecodeOptions.cs b/QsNet/Models/DecodeOptions.cs index 4469398..c910902 100644 --- a/QsNet/Models/DecodeOptions.cs +++ b/QsNet/Models/DecodeOptions.cs @@ -234,17 +234,15 @@ public bool DecodeDotInKeys /// /// Default decoder used when no custom decoder is supplied. - /// For , this protects encoded dots ("%2E"/"%2e") before - /// percent-decoding so that dot-splitting and post-split mapping behave correctly. - /// Inside bracket segments we always protect; outside brackets we only protect when - /// is true. + /// Keys are decoded identically to values so percent-encoded octets (including + /// "%2E"/"%2e") become their literal characters (e.g., ".") before any + /// dot-to-bracket conversion and segment splitting. /// private object? DefaultDecode(string? value, Encoding? encoding, DecodeKind kind) { - if (value is null) return null; - if (kind != DecodeKind.Key) return Utils.Decode(value, encoding); - var protectedKey = ProtectEncodedDotsForKeys(value, AllowDots); - return Utils.Decode(protectedKey, encoding); + return value is null ? null : + // Decode keys exactly like values so %2E -> '.' prior to dot-to-bracket + splitting. + Utils.Decode(value, encoding); } /// From e64ac826416788b7fc548450170995520d9c1213 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Fri, 22 Aug 2025 11:05:49 +0100 Subject: [PATCH 17/24] :bug: correctly handle nested brackets when splitting key segments in DecodeKey --- QsNet/Internal/Decoder.cs | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/QsNet/Internal/Decoder.cs b/QsNet/Internal/Decoder.cs index c64ff67..d197cdf 100644 --- a/QsNet/Internal/Decoder.cs +++ b/QsNet/Internal/Decoder.cs @@ -438,13 +438,34 @@ bool strictDepth var lastClose = -1; while (open >= 0 && depth < maxDepth) { - var close = key.IndexOf(']', open + 1); + var level = 1; + var i = open + 1; + var close = -1; + while (i < key.Length) + { + var ch = key[i]; + if (ch == '[') + { + level++; + } + else if (ch == ']') + { + level--; + if (level == 0) + { + close = i; + break; + } + } + i++; + } + if (close < 0) - break; + break; // unterminated group; stop collecting #if NETSTANDARD2_0 - segments.Add(key.Substring(open, close + 1 - open)); // e.g. "[p]" or "[]" + segments.Add(key.Substring(open, close + 1 - open)); // balanced group, e.g. "[b[c]]" #else - segments.Add(key[open..(close + 1)]); // e.g. "[p]" or "[]" + segments.Add(key[open..(close + 1)]); // balanced group, e.g. "[b[c]]" #endif lastClose = close; depth++; From 1741cb5b10065f1df40e6b7ea450a6d7e83a6082 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Fri, 22 Aug 2025 12:09:04 +0100 Subject: [PATCH 18/24] :bug: convert top-level dots in keys to bracket segments; preserve dots inside brackets and handle degenerate cases in segment splitting --- QsNet/Internal/Decoder.cs | 78 ++++++++++++++++++++++++++++++++++----- 1 file changed, 69 insertions(+), 9 deletions(-) diff --git a/QsNet/Internal/Decoder.cs b/QsNet/Internal/Decoder.cs index d197cdf..3fb750d 100644 --- a/QsNet/Internal/Decoder.cs +++ b/QsNet/Internal/Decoder.cs @@ -397,6 +397,59 @@ bool valuesParsed return ParseObject(segments, value, options, valuesParsed); } + /// + /// Convert top-level dot segments into bracket groups, preserving dots inside brackets + /// and ignoring degenerate segments (leading/trailing/double dots). + /// Examples: + /// "user.email.name" -> "user[email][name]" + /// "a[b].c" -> "a[b][c]" (dot outside brackets) + /// "a[.].c" -> remains "a[.][c]" (dot inside brackets is preserved) + /// "user.email." -> "user[email]" (trailing dot ignored) + /// + private static string DotToBracketTopLevel(string key) + { + if (string.IsNullOrEmpty(key) || key.IndexOf('.') < 0) + return key; + + var sb = new StringBuilder(key.Length + 4); + var depth = 0; + + for (var i = 0; i < key.Length; i++) + { + var ch = key[i]; + switch (ch) + { + case '[': + depth++; + sb.Append(ch); + break; + case ']': + { + if (depth > 0) depth--; + sb.Append(ch); + break; + } + case '.' when depth == 0: + { + // Convert the immediate token after the dot into a bracketed segment. + // The token ends at the next '.' or '[' or end of string. + var j = i + 1; + while (j < key.Length && key[j] != '.' && key[j] != '[') j++; + var len = j - (i + 1); + if (len > 0) sb.Append('[').Append(key, i + 1, len).Append(']'); + // Degenerate cases (leading/double/trailing dot): do nothing. + i = j - 1; // continue from the delimiter we stopped at + break; + } + default: + sb.Append(ch); + break; + } + } + + return sb.ToString(); + } + /// /// Splits a key into segments based on brackets and dots, handling depth and strictness. /// @@ -414,9 +467,7 @@ bool strictDepth ) { // Apply dot→bracket *before* splitting, but when depth == 0, we do NOT split at all and do NOT throw. - var key = allowDots - ? DotToBracket.Replace(originalKey, match => $"[{match.Groups[1].Value}]") - : originalKey; + var key = allowDots ? DotToBracketTopLevel(originalKey) : originalKey; // Depth 0 semantics: use the original key as a single segment; never throw. if (maxDepth <= 0) @@ -457,11 +508,14 @@ bool strictDepth break; } } + i++; } if (close < 0) - break; // unterminated group; stop collecting + // Unterminated group: treat the entire key as a single literal segment (qs semantics). + // This ensures inputs like "[", "[[", or "[hello[" are preserved as-is and do not get dropped. + return [key]; #if NETSTANDARD2_0 segments.Add(key.Substring(open, close + 1 - open)); // balanced group, e.g. "[b[c]]" #else @@ -473,16 +527,22 @@ bool strictDepth } // If there's any trailing text after the last closing bracket, treat it as a single final segment. + // Ignore a lone trailing '.' (degenerate top-level dot). if (lastClose < 0 || lastClose + 1 >= key.Length) return segments; +#if NETSTANDARD2_0 + var remainder = key.Substring(lastClose + 1); +#else + var remainder = key[(lastClose + 1)..]; +#endif + if (remainder == ".") return segments; + if (strictDepth) throw new IndexOutOfRangeException( $"Input depth exceeded depth option of {maxDepth} and strictDepth is true" ); -#if NETSTANDARD2_0 - segments.Add("[" + key.Substring(lastClose + 1) + "]"); -#else - segments.Add("[" + key[(lastClose + 1)..] + "]"); -#endif + + // Wrap the remainder as one final bracket segment. + segments.Add("[" + remainder + "]"); return segments; } From 8d1f0cb3b6de66b42181eab8490808d3882918b1 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Fri, 22 Aug 2025 12:09:17 +0100 Subject: [PATCH 19/24] :white_check_mark: update tests to expect decoded dot-encoded keys as nested dictionaries; ensure bracketed dot-encoded segments decode to literal dots --- QsNet.Tests/DecodeTests.cs | 36 +++++++++++++++++++++++++++--------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/QsNet.Tests/DecodeTests.cs b/QsNet.Tests/DecodeTests.cs index ab50b75..c67709a 100644 --- a/QsNet.Tests/DecodeTests.cs +++ b/QsNet.Tests/DecodeTests.cs @@ -4140,11 +4140,17 @@ public void EncodedDot_TopLevel_AllowDotsTrue_DecodeDotInKeysTrue_PlainDotSplits Qs.Decode("a%2Eb=c", opt) .Should() - .BeEquivalentTo(new Dictionary { ["a.b"] = "c" }); + .BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary { ["b"] = "c" } + }); Qs.Decode("a%2eb=c", opt) .Should() - .BeEquivalentTo(new Dictionary { ["a.b"] = "c" }); + .BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary { ["b"] = "c" } + }); } [Fact] @@ -4154,11 +4160,17 @@ public void EncodedDot_TopLevel_AllowDotsTrue_DecodeDotInKeysFalse_EncodedDotRem Qs.Decode("a%2Eb=c", opt) .Should() - .BeEquivalentTo(new Dictionary { ["a%2Eb"] = "c" }); + .BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary { ["b"] = "c" } + }); Qs.Decode("a%2eb=c", opt) .Should() - .BeEquivalentTo(new Dictionary { ["a%2eb"] = "c" }); + .BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary { ["b"] = "c" } + }); } [Fact] @@ -4198,14 +4210,14 @@ public void EncodedDot_BracketSegment_RemainsPercentSequence_WhenDecodeDotInKeys .Should() .BeEquivalentTo(new Dictionary { - ["a"] = new Dictionary { ["%2E"] = "x" } + ["a"] = new Dictionary { ["."] = "x" } }); Qs.Decode("a[%2e]=x", opt) .Should() .BeEquivalentTo(new Dictionary { - ["a"] = new Dictionary { ["%2e"] = "x" } + ["a"] = new Dictionary { ["."] = "x" } }); } @@ -4224,7 +4236,10 @@ public void EncodedDot_TopLevel_Latin1_AllowDotsTrue_DecodeDotInKeysTrue() Qs.Decode("a%2Eb=c", opt) .Should() - .BeEquivalentTo(new Dictionary { ["a.b"] = "c" }); + .BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary { ["b"] = "c" } + }); Qs.Decode("a[%2E]=x", opt) .Should() @@ -4241,13 +4256,16 @@ public void EncodedDot_TopLevel_Latin1_AllowDotsTrue_DecodeDotInKeysFalse() Qs.Decode("a%2Eb=c", opt) .Should() - .BeEquivalentTo(new Dictionary { ["a%2Eb"] = "c" }); + .BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary { ["b"] = "c" } + }); Qs.Decode("a[%2E]=x", opt) .Should() .BeEquivalentTo(new Dictionary { - ["a"] = new Dictionary { ["%2E"] = "x" } + ["a"] = new Dictionary { ["."] = "x" } }); } From b999e5ddd867de8cb86a605e1ff205912cd5966f Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Fri, 22 Aug 2025 12:09:22 +0100 Subject: [PATCH 20/24] :white_check_mark: add tests for percent-decoding of dots in keys; verify handling of encoded brackets and dot splitting with AllowDots and DecodeDotInKeys options --- QsNet.Tests/DecodeOptionsTests.cs | 169 ++++++++++++++++++++++++++++-- 1 file changed, 159 insertions(+), 10 deletions(-) diff --git a/QsNet.Tests/DecodeOptionsTests.cs b/QsNet.Tests/DecodeOptionsTests.cs index 24cf0e1..b8479f3 100644 --- a/QsNet.Tests/DecodeOptionsTests.cs +++ b/QsNet.Tests/DecodeOptionsTests.cs @@ -127,7 +127,7 @@ public void DecodeKey_ShouldThrow_When_DecodeDotInKeysTrue_And_AllowDotsFalse() } [Fact] - public void DecodeKey_ProtectsEncodedDots_BeforePercentDecoding() + public void DecodeKey_DecodesPercentSequences_LikeValues() { var options = new DecodeOptions { @@ -135,9 +135,8 @@ public void DecodeKey_ProtectsEncodedDots_BeforePercentDecoding() DecodeDotInKeys = false }; - // `%2E` should not decode to '.' when decoding a KEY; it should remain "%2E" after one call - options.DecodeKey("a%2Eb", Encoding.UTF8).Should().Be("a%2Eb"); - options.DecodeKey("a%2eb", Encoding.UTF8).Should().Be("a%2eb"); + options.DecodeKey("a%2Eb", Encoding.UTF8).Should().Be("a.b"); + options.DecodeKey("a%2eb", Encoding.UTF8).Should().Be("a.b"); } [Fact] @@ -220,16 +219,166 @@ public void CopyWith_PreservesAndOverrides_Decoders() } [Fact] - public void DecodeKey_ProtectsEncodedDots_InsideBrackets_RegardlessOfAllowDots() + public void DecodeKey_PercentDecoding_InsideBrackets() { var o1 = new DecodeOptions { AllowDots = false, DecodeDotInKeys = false }; var o2 = new DecodeOptions { AllowDots = true, DecodeDotInKeys = false }; - // Inside bracket: always protected - o1.DecodeKey("a[%2Eb]", Encoding.UTF8).Should().Be("a[%2Eb]"); - o1.DecodeKey("a[b%2Ec]", Encoding.UTF8).Should().Be("a[b%2Ec]"); + // Inside brackets: percent-decoding still applies to keys + o1.DecodeKey("a[%2Eb]", Encoding.UTF8).Should().Be("a[.b]"); + o1.DecodeKey("a[b%2Ec]", Encoding.UTF8).Should().Be("a[b.c]"); - o2.DecodeKey("a[%2Eb]", Encoding.UTF8).Should().Be("a[%2Eb]"); - o2.DecodeKey("a[b%2Ec]", Encoding.UTF8).Should().Be("a[b%2Ec]"); + o2.DecodeKey("a[%2Eb]", Encoding.UTF8).Should().Be("a[.b]"); + o2.DecodeKey("a[b%2Ec]", Encoding.UTF8).Should().Be("a[b.c]"); + } + + [Fact] + public void MixedCase_EncodedBrackets_EncodedDot_AllowDotsTrue_DecodeDotInKeysTrue_Upper() + { + var opt = new DecodeOptions { AllowDots = true, DecodeDotInKeys = true }; + var result = Qs.Decode("a%5Bb%5D%5Bc%5D%2Ed=x", opt); + result.Should().BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary + { + ["b"] = new Dictionary + { + ["c"] = new Dictionary + { + ["d"] = "x" + } + } + } + }); + } + + [Fact] + public void MixedCase_EncodedBrackets_EncodedDot_AllowDotsTrue_DecodeDotInKeysTrue_Lower() + { + var opt = new DecodeOptions { AllowDots = true, DecodeDotInKeys = true }; + var result = Qs.Decode("a%5bb%5d%5bc%5d%2ed=x", opt); + result.Should().BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary + { + ["b"] = new Dictionary + { + ["c"] = new Dictionary + { + ["d"] = "x" + } + } + } + }); + } + + [Fact] + public void NestedBracketsInsideSegment_AllowDotsTrue_DecodeDotInKeysTrue() + { + var opt = new DecodeOptions { AllowDots = true, DecodeDotInKeys = true }; + var result = Qs.Decode("a[b%5Bc%5D].e=x", opt); + + result.Should().BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary + { + ["b[c]"] = new Dictionary + { + ["e"] = "x" + } + } + }); + } + + [Fact] + public void MixedCase_EncodedBrackets_EncodedDot_AllowDotsFalse_DecodeDotInKeysTrue_Throws() + { + var opt = new DecodeOptions { AllowDots = false, DecodeDotInKeys = true }; + Action act = () => Qs.Decode("a%5Bb%5D%5Bc%5D%2Ed=x", opt); + act.Should().Throw() + .WithMessage("*decodeDotInKeys*allowDots*"); + } + + [Fact] + public void TopLevel_EncodedDot_AllowDotsTrue_DecodeDotInKeysTrue_Splits() + { + var opt = new DecodeOptions { AllowDots = true, DecodeDotInKeys = true }; + Qs.Decode("a%2Eb=c", opt) + .Should().BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary + { + ["b"] = "c" + } + }); + } + + [Fact] + public void TopLevel_EncodedDot_AllowDotsTrue_DecodeDotInKeysFalse_AlsoSplits() + { + var opt = new DecodeOptions { AllowDots = true, DecodeDotInKeys = false }; + + Qs.Decode("a%2Eb=c", opt) + .Should().BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary { ["b"] = "c" } + }); + } + + [Fact] + public void TopLevel_EncodedDot_AllowDotsFalse_DecodeDotInKeysFalse_DoesNotSplit() + { + var opt = new DecodeOptions { AllowDots = false, DecodeDotInKeys = false }; + Qs.Decode("a%2Eb=c", opt) + .Should().BeEquivalentTo(new Dictionary + { + ["a.b"] = "c" + }); + } + + [Fact] + public void BracketThenEncodedDot_ToNextSegment_AllowDotsTrue() + { + var opt = new DecodeOptions { AllowDots = true, DecodeDotInKeys = true }; + // a[b]%2Ec = x => a[b].c = x => { a: { b: { c: x }}} + Qs.Decode("a[b]%2Ec=x", opt) + .Should().BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary + { + ["b"] = new Dictionary + { + ["c"] = "x" + } + } + }); + + // lowercase %2e + Qs.Decode("a[b]%2ec=x", opt) + .Should().BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary + { + ["b"] = new Dictionary + { + ["c"] = "x" + } + } + }); + } + + [Fact] + public void MixedCase_EncodedBrackets_TopLevelDotThenBracket_AllowDotsTrue() + { + var opt = new DecodeOptions { AllowDots = true, DecodeDotInKeys = true }; + // a%2E[b] = x => a.[b] then dot-split at top level → { a: { b: x }} + Qs.Decode("a%2E[b]=x", opt) + .Should().BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary + { + ["b"] = "x" + } + }); } } \ No newline at end of file From 80df68d427b63c9428685921f31a89906dd7e123 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Fri, 22 Aug 2025 12:14:19 +0100 Subject: [PATCH 21/24] :white_check_mark: move percent-decoding and dot-encoded key tests from DecodeOptionsTests to DecodeTests; remove unused using from UtilsTests --- QsNet.Tests/DecodeOptionsTests.cs | 164 ------------------------------ QsNet.Tests/DecodeTests.cs | 164 ++++++++++++++++++++++++++++++ QsNet.Tests/UtilsTests.cs | 1 - 3 files changed, 164 insertions(+), 165 deletions(-) diff --git a/QsNet.Tests/DecodeOptionsTests.cs b/QsNet.Tests/DecodeOptionsTests.cs index b8479f3..5e3fe2b 100644 --- a/QsNet.Tests/DecodeOptionsTests.cs +++ b/QsNet.Tests/DecodeOptionsTests.cs @@ -217,168 +217,4 @@ public void CopyWith_PreservesAndOverrides_Decoders() copy3.DecodeValue("v", Encoding.UTF8).Should().Be("K2:Value:v"); copy3.DecodeKey("k", Encoding.UTF8).Should().Be("K2:Key:k"); } - - [Fact] - public void DecodeKey_PercentDecoding_InsideBrackets() - { - var o1 = new DecodeOptions { AllowDots = false, DecodeDotInKeys = false }; - var o2 = new DecodeOptions { AllowDots = true, DecodeDotInKeys = false }; - - // Inside brackets: percent-decoding still applies to keys - o1.DecodeKey("a[%2Eb]", Encoding.UTF8).Should().Be("a[.b]"); - o1.DecodeKey("a[b%2Ec]", Encoding.UTF8).Should().Be("a[b.c]"); - - o2.DecodeKey("a[%2Eb]", Encoding.UTF8).Should().Be("a[.b]"); - o2.DecodeKey("a[b%2Ec]", Encoding.UTF8).Should().Be("a[b.c]"); - } - - [Fact] - public void MixedCase_EncodedBrackets_EncodedDot_AllowDotsTrue_DecodeDotInKeysTrue_Upper() - { - var opt = new DecodeOptions { AllowDots = true, DecodeDotInKeys = true }; - var result = Qs.Decode("a%5Bb%5D%5Bc%5D%2Ed=x", opt); - result.Should().BeEquivalentTo(new Dictionary - { - ["a"] = new Dictionary - { - ["b"] = new Dictionary - { - ["c"] = new Dictionary - { - ["d"] = "x" - } - } - } - }); - } - - [Fact] - public void MixedCase_EncodedBrackets_EncodedDot_AllowDotsTrue_DecodeDotInKeysTrue_Lower() - { - var opt = new DecodeOptions { AllowDots = true, DecodeDotInKeys = true }; - var result = Qs.Decode("a%5bb%5d%5bc%5d%2ed=x", opt); - result.Should().BeEquivalentTo(new Dictionary - { - ["a"] = new Dictionary - { - ["b"] = new Dictionary - { - ["c"] = new Dictionary - { - ["d"] = "x" - } - } - } - }); - } - - [Fact] - public void NestedBracketsInsideSegment_AllowDotsTrue_DecodeDotInKeysTrue() - { - var opt = new DecodeOptions { AllowDots = true, DecodeDotInKeys = true }; - var result = Qs.Decode("a[b%5Bc%5D].e=x", opt); - - result.Should().BeEquivalentTo(new Dictionary - { - ["a"] = new Dictionary - { - ["b[c]"] = new Dictionary - { - ["e"] = "x" - } - } - }); - } - - [Fact] - public void MixedCase_EncodedBrackets_EncodedDot_AllowDotsFalse_DecodeDotInKeysTrue_Throws() - { - var opt = new DecodeOptions { AllowDots = false, DecodeDotInKeys = true }; - Action act = () => Qs.Decode("a%5Bb%5D%5Bc%5D%2Ed=x", opt); - act.Should().Throw() - .WithMessage("*decodeDotInKeys*allowDots*"); - } - - [Fact] - public void TopLevel_EncodedDot_AllowDotsTrue_DecodeDotInKeysTrue_Splits() - { - var opt = new DecodeOptions { AllowDots = true, DecodeDotInKeys = true }; - Qs.Decode("a%2Eb=c", opt) - .Should().BeEquivalentTo(new Dictionary - { - ["a"] = new Dictionary - { - ["b"] = "c" - } - }); - } - - [Fact] - public void TopLevel_EncodedDot_AllowDotsTrue_DecodeDotInKeysFalse_AlsoSplits() - { - var opt = new DecodeOptions { AllowDots = true, DecodeDotInKeys = false }; - - Qs.Decode("a%2Eb=c", opt) - .Should().BeEquivalentTo(new Dictionary - { - ["a"] = new Dictionary { ["b"] = "c" } - }); - } - - [Fact] - public void TopLevel_EncodedDot_AllowDotsFalse_DecodeDotInKeysFalse_DoesNotSplit() - { - var opt = new DecodeOptions { AllowDots = false, DecodeDotInKeys = false }; - Qs.Decode("a%2Eb=c", opt) - .Should().BeEquivalentTo(new Dictionary - { - ["a.b"] = "c" - }); - } - - [Fact] - public void BracketThenEncodedDot_ToNextSegment_AllowDotsTrue() - { - var opt = new DecodeOptions { AllowDots = true, DecodeDotInKeys = true }; - // a[b]%2Ec = x => a[b].c = x => { a: { b: { c: x }}} - Qs.Decode("a[b]%2Ec=x", opt) - .Should().BeEquivalentTo(new Dictionary - { - ["a"] = new Dictionary - { - ["b"] = new Dictionary - { - ["c"] = "x" - } - } - }); - - // lowercase %2e - Qs.Decode("a[b]%2ec=x", opt) - .Should().BeEquivalentTo(new Dictionary - { - ["a"] = new Dictionary - { - ["b"] = new Dictionary - { - ["c"] = "x" - } - } - }); - } - - [Fact] - public void MixedCase_EncodedBrackets_TopLevelDotThenBracket_AllowDotsTrue() - { - var opt = new DecodeOptions { AllowDots = true, DecodeDotInKeys = true }; - // a%2E[b] = x => a.[b] then dot-split at top level → { a: { b: x }} - Qs.Decode("a%2E[b]=x", opt) - .Should().BeEquivalentTo(new Dictionary - { - ["a"] = new Dictionary - { - ["b"] = "x" - } - }); - } } \ No newline at end of file diff --git a/QsNet.Tests/DecodeTests.cs b/QsNet.Tests/DecodeTests.cs index c67709a..d6252a7 100644 --- a/QsNet.Tests/DecodeTests.cs +++ b/QsNet.Tests/DecodeTests.cs @@ -4269,5 +4269,169 @@ public void EncodedDot_TopLevel_Latin1_AllowDotsTrue_DecodeDotInKeysFalse() }); } + [Fact] + public void DecodeKey_PercentDecoding_InsideBrackets() + { + var o1 = new DecodeOptions { AllowDots = false, DecodeDotInKeys = false }; + var o2 = new DecodeOptions { AllowDots = true, DecodeDotInKeys = false }; + + // Inside brackets: percent-decoding still applies to keys + o1.DecodeKey("a[%2Eb]", Encoding.UTF8).Should().Be("a[.b]"); + o1.DecodeKey("a[b%2Ec]", Encoding.UTF8).Should().Be("a[b.c]"); + + o2.DecodeKey("a[%2Eb]", Encoding.UTF8).Should().Be("a[.b]"); + o2.DecodeKey("a[b%2Ec]", Encoding.UTF8).Should().Be("a[b.c]"); + } + + [Fact] + public void MixedCase_EncodedBrackets_EncodedDot_AllowDotsTrue_DecodeDotInKeysTrue_Upper() + { + var opt = new DecodeOptions { AllowDots = true, DecodeDotInKeys = true }; + var result = Qs.Decode("a%5Bb%5D%5Bc%5D%2Ed=x", opt); + result.Should().BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary + { + ["b"] = new Dictionary + { + ["c"] = new Dictionary + { + ["d"] = "x" + } + } + } + }); + } + + [Fact] + public void MixedCase_EncodedBrackets_EncodedDot_AllowDotsTrue_DecodeDotInKeysTrue_Lower() + { + var opt = new DecodeOptions { AllowDots = true, DecodeDotInKeys = true }; + var result = Qs.Decode("a%5bb%5d%5bc%5d%2ed=x", opt); + result.Should().BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary + { + ["b"] = new Dictionary + { + ["c"] = new Dictionary + { + ["d"] = "x" + } + } + } + }); + } + + [Fact] + public void NestedBracketsInsideSegment_AllowDotsTrue_DecodeDotInKeysTrue() + { + var opt = new DecodeOptions { AllowDots = true, DecodeDotInKeys = true }; + var result = Qs.Decode("a[b%5Bc%5D].e=x", opt); + + result.Should().BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary + { + ["b[c]"] = new Dictionary + { + ["e"] = "x" + } + } + }); + } + + [Fact] + public void MixedCase_EncodedBrackets_EncodedDot_AllowDotsFalse_DecodeDotInKeysTrue_Throws() + { + var opt = new DecodeOptions { AllowDots = false, DecodeDotInKeys = true }; + Action act = () => Qs.Decode("a%5Bb%5D%5Bc%5D%2Ed=x", opt); + act.Should().Throw() + .WithMessage("*decodeDotInKeys*allowDots*"); + } + + [Fact] + public void TopLevel_EncodedDot_AllowDotsTrue_DecodeDotInKeysTrue_Splits() + { + var opt = new DecodeOptions { AllowDots = true, DecodeDotInKeys = true }; + Qs.Decode("a%2Eb=c", opt) + .Should().BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary + { + ["b"] = "c" + } + }); + } + + [Fact] + public void TopLevel_EncodedDot_AllowDotsTrue_DecodeDotInKeysFalse_AlsoSplits() + { + var opt = new DecodeOptions { AllowDots = true, DecodeDotInKeys = false }; + + Qs.Decode("a%2Eb=c", opt) + .Should().BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary { ["b"] = "c" } + }); + } + + [Fact] + public void TopLevel_EncodedDot_AllowDotsFalse_DecodeDotInKeysFalse_DoesNotSplit() + { + var opt = new DecodeOptions { AllowDots = false, DecodeDotInKeys = false }; + Qs.Decode("a%2Eb=c", opt) + .Should().BeEquivalentTo(new Dictionary + { + ["a.b"] = "c" + }); + } + + [Fact] + public void BracketThenEncodedDot_ToNextSegment_AllowDotsTrue() + { + var opt = new DecodeOptions { AllowDots = true, DecodeDotInKeys = true }; + // a[b]%2Ec = x => a[b].c = x => { a: { b: { c: x }}} + Qs.Decode("a[b]%2Ec=x", opt) + .Should().BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary + { + ["b"] = new Dictionary + { + ["c"] = "x" + } + } + }); + + // lowercase %2e + Qs.Decode("a[b]%2ec=x", opt) + .Should().BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary + { + ["b"] = new Dictionary + { + ["c"] = "x" + } + } + }); + } + + [Fact] + public void MixedCase_EncodedBrackets_TopLevelDotThenBracket_AllowDotsTrue() + { + var opt = new DecodeOptions { AllowDots = true, DecodeDotInKeys = true }; + // a%2E[b] = x => a.[b] then dot-split at top level → { a: { b: x }} + Qs.Decode("a%2E[b]=x", opt) + .Should().BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary + { + ["b"] = "x" + } + }); + } + #endregion } \ No newline at end of file diff --git a/QsNet.Tests/UtilsTests.cs b/QsNet.Tests/UtilsTests.cs index b29d84d..0fc92d5 100644 --- a/QsNet.Tests/UtilsTests.cs +++ b/QsNet.Tests/UtilsTests.cs @@ -3,7 +3,6 @@ using System; using System.Collections; using System.Collections.Generic; -using System.Collections.Specialized; using System.Linq; using System.Reflection; using System.Text; From 0d90d6c707e463fe82e1da1c46aba4b1139c3397 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Fri, 22 Aug 2025 12:16:14 +0100 Subject: [PATCH 22/24] :white_check_mark: add tests for handling percent-encoded dots in keys with AllowDots and DecodeDotInKeys options; verify splitting, bracket mapping, trailing dots, and decoder callback behavior --- QsNet.Tests/DecodeTests.cs | 101 +++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/QsNet.Tests/DecodeTests.cs b/QsNet.Tests/DecodeTests.cs index d6252a7..8710400 100644 --- a/QsNet.Tests/DecodeTests.cs +++ b/QsNet.Tests/DecodeTests.cs @@ -4433,5 +4433,106 @@ public void MixedCase_EncodedBrackets_TopLevelDotThenBracket_AllowDotsTrue() }); } + [Fact] + public void TopLevel_EncodedDot_Lowercase_AllowDotsTrue_Splits() + { + var opt = new DecodeOptions { AllowDots = true, DecodeDotInKeys = false }; + Qs.Decode("a%2eb=c", opt) + .Should().BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary { ["b"] = "c" } + }); + } + + [Fact] + public void DotBeforeIndex_AllowDotsTrue_IndexRemainsIndex() + { + var opt = new DecodeOptions { AllowDots = true }; + Qs.Decode("foo[0].baz[0]=15&foo[0].bar=2", opt) + .Should().BeEquivalentTo( + new Dictionary + { + ["foo"] = new List + { + new Dictionary + { + ["baz"] = new List { "15" }, + ["bar"] = "2" + } + } + }); + } + + [Fact] + public void TrailingDot_AllowDotsTrue_Ignored() + { + var opt = new DecodeOptions { AllowDots = true }; + Qs.Decode("user.email.=x", opt) + .Should().BeEquivalentTo(new Dictionary + { + ["user"] = new Dictionary { ["email"] = "x" } + }); + } + + [Fact] + public void BracketSegment_EncodedDot_MappedToDot() + { + var opt = new DecodeOptions { AllowDots = true, DecodeDotInKeys = true }; + Qs.Decode("a[%2E]=x", opt) + .Should().BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary { ["."] = "x" } + }); + + Qs.Decode("a[%2e]=x", opt) + .Should().BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary { ["."] = "x" } + }); + } + + [Fact] + public void TopLevel_EncodedDot_BeforeBracket_Lowercase() + { + var opt = new DecodeOptions { AllowDots = true, DecodeDotInKeys = true }; + Qs.Decode("a%2e[b]=x", opt) + .Should().BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary { ["b"] = "x" } + }); + } + + [Fact] + public void PlainDot_BeforeBracket_AllowDotsTrue() + { + var opt = new DecodeOptions { AllowDots = true, DecodeDotInKeys = true }; + Qs.Decode("a.[b]=x", opt) + .Should().BeEquivalentTo(new Dictionary + { + ["a"] = new Dictionary { ["b"] = "x" } + }); + } + + [Fact] + public void DecoderWithKind_ReceivesKey_ForTopLevelAndBracketedKeys() + { + var calls = new List<(string? s, DecodeKind kind)>(); + var opt = new DecodeOptions + { + DecoderWithKind = (s, enc, kind) => + { + calls.Add((s, kind)); + return s; + }, + AllowDots = true, + DecodeDotInKeys = true + }; + + Qs.Decode("a%2Eb=c&a[b]=d", opt); + + calls.Should().Contain(x => x.kind == DecodeKind.Key && (x.s == "a%2Eb" || x.s == "a[b]")); + calls.Should().Contain(x => x.kind == DecodeKind.Value && (x.s == "c" || x.s == "d")); + } + #endregion } \ No newline at end of file From db08a58ac1b0f095332e5d9407ba18a2af1643c6 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Fri, 22 Aug 2025 12:37:26 +0100 Subject: [PATCH 23/24] :recycle: make DefaultDecode static to ensure consistent decoding behavior for keys and values --- QsNet/Models/DecodeOptions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/QsNet/Models/DecodeOptions.cs b/QsNet/Models/DecodeOptions.cs index c910902..6852198 100644 --- a/QsNet/Models/DecodeOptions.cs +++ b/QsNet/Models/DecodeOptions.cs @@ -238,7 +238,7 @@ public bool DecodeDotInKeys /// "%2E"/"%2e") become their literal characters (e.g., ".") before any /// dot-to-bracket conversion and segment splitting. /// - private object? DefaultDecode(string? value, Encoding? encoding, DecodeKind kind) + private static object? DefaultDecode(string? value, Encoding? encoding, DecodeKind kind) { return value is null ? null : // Decode keys exactly like values so %2E -> '.' prior to dot-to-bracket + splitting. From 8adf5cb02a12f7e0d427b1e954241832d75eb865 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Fri, 22 Aug 2025 12:56:33 +0100 Subject: [PATCH 24/24] :recycle: simplify DefaultDecode by removing unused DecodeKind parameter; update invocation to match new signature --- QsNet/Models/DecodeOptions.cs | 98 ++--------------------------------- 1 file changed, 5 insertions(+), 93 deletions(-) diff --git a/QsNet/Models/DecodeOptions.cs b/QsNet/Models/DecodeOptions.cs index 6852198..797f26d 100644 --- a/QsNet/Models/DecodeOptions.cs +++ b/QsNet/Models/DecodeOptions.cs @@ -205,7 +205,7 @@ public bool DecodeDotInKeys if (d3 is not null) return d3.Invoke(value, encoding, kind); var d = Decoder; - return d is not null ? d.Invoke(value, encoding) : DefaultDecode(value, encoding, kind); + return d is not null ? d.Invoke(value, encoding) : DefaultDecode(value, encoding); } /// @@ -233,100 +233,12 @@ public bool DecodeDotInKeys } /// - /// Default decoder used when no custom decoder is supplied. - /// Keys are decoded identically to values so percent-encoded octets (including - /// "%2E"/"%2e") become their literal characters (e.g., ".") before any - /// dot-to-bracket conversion and segment splitting. + /// Default decoder when no custom decoder is supplied. Keys are decoded identically + /// to values using with the provided encoding. /// - private static object? DefaultDecode(string? value, Encoding? encoding, DecodeKind kind) + private static object? DefaultDecode(string? value, Encoding? encoding) { - return value is null ? null : - // Decode keys exactly like values so %2E -> '.' prior to dot-to-bracket + splitting. - Utils.Decode(value, encoding); - } - - /// - /// Protect %2E/%2e in KEY strings so percent-decoding does not turn them into '.' too early. - /// Inside brackets we always protect; outside brackets only when includeOutsideBrackets is true. - /// - /// - /// - /// - private static string ProtectEncodedDotsForKeys(string input, bool includeOutsideBrackets) - { - if (string.IsNullOrEmpty(input) || input.IndexOf('%') < 0) - return input; - - // Fast-path: if there are no encoded dots or brackets, skip scanning. - if (input.IndexOf("%2E", StringComparison.OrdinalIgnoreCase) < 0 - && input.IndexOf("%5B", StringComparison.OrdinalIgnoreCase) < 0 - && input.IndexOf("%5D", StringComparison.OrdinalIgnoreCase) < 0) - return input; - - var sb = new StringBuilder(input.Length + 8); - var depth = 0; - for (var i = 0; i < input.Length;) - { - var ch = input[i]; - switch (ch) - { - case '[': - depth++; - sb.Append(ch); - i++; - break; - case ']': - { - if (depth > 0) depth--; - sb.Append(ch); - i++; - break; - } - // Handle percent-encoded brackets to track depth even when [] are encoded. - case '%' when i + 2 < input.Length && input[i + 1] == '5' && - (input[i + 2] == 'B' || input[i + 2] == 'b'): - depth++; - sb.Append('%').Append('5').Append(input[i + 2]); - i += 3; - break; - case '%' when i + 2 < input.Length && input[i + 1] == '5' && - (input[i + 2] == 'D' || input[i + 2] == 'd'): - { - if (depth > 0) depth--; - sb.Append('%').Append('5').Append(input[i + 2]); - i += 3; - break; - } - case '%' when i + 2 < input.Length && input[i + 1] == '2' && - (input[i + 2] == 'E' || input[i + 2] == 'e'): - { - var inside = depth > 0; - if (inside || includeOutsideBrackets) - { - sb.Append("%25"); - sb.Append(input[i + 2] == 'E' ? "2E" : "2e"); - } - else - { - sb.Append('%').Append('2').Append(input[i + 2]); - } - - i += 3; - break; - } - case '%' when i + 2 >= input.Length: - // Leave malformed/incomplete escape as-is - sb.Append(ch); - i++; - break; - default: - sb.Append(ch); - i++; - break; - } - } - - return sb.ToString(); + return value is null ? null : Utils.Decode(value, encoding); }