From 4228c1f99b9df14681907b5f48aad653d0deba18 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 26 Aug 2025 17:35:34 +0100 Subject: [PATCH 01/36] :art: ensure culture-invariant number formatting and parsing in Utils --- QsNet/Internal/Utils.cs | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/QsNet/Internal/Utils.cs b/QsNet/Internal/Utils.cs index db5144b..0232086 100644 --- a/QsNet/Internal/Utils.cs +++ b/QsNet/Internal/Utils.cs @@ -188,7 +188,7 @@ private static Regex MyRegex1() foreach (var item in srcIter) { if (item is not Undefined) - mutable[i.ToString()] = item; + mutable[i.ToString(CultureInfo.InvariantCulture)] = item; i++; } @@ -232,7 +232,7 @@ private static Regex MyRegex1() foreach (var v in tEnum) { if (v is not Undefined) - dict[i.ToString()] = v; + dict[i.ToString(CultureInfo.InvariantCulture)] = v; i++; } @@ -296,9 +296,9 @@ public static string Escape(string str, Format format = Format.Rfc3986) ) sb.Append(t); else if (c < 256) - sb.Append('%').Append(c.ToString("X2")); + sb.Append('%').Append(c.ToString("X2", CultureInfo.InvariantCulture)); else - sb.Append("%u").Append(c.ToString("X4")); + sb.Append("%u").Append(c.ToString("X4", CultureInfo.InvariantCulture)); } return sb.ToString(); @@ -327,7 +327,7 @@ public static string Unescape(string str) int.TryParse( str.Substring(i + 2, 4), NumberStyles.HexNumber, - null, + CultureInfo.InvariantCulture, out var code ) ) @@ -337,7 +337,7 @@ out var code int.TryParse( str.AsSpan(i + 2, 4), NumberStyles.HexNumber, - null, + CultureInfo.InvariantCulture, out var code ) ) @@ -351,9 +351,9 @@ out var code else if ( i + 3 <= str.Length #if NETSTANDARD2_0 - && int.TryParse(str.Substring(i + 1, 2), NumberStyles.HexNumber, null, out var b) + && int.TryParse(str.Substring(i + 1, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var b) #else - && int.TryParse(str.AsSpan(i + 1, 2), NumberStyles.HexNumber, null, out var b) + && int.TryParse(str.AsSpan(i + 1, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var b) #endif ) { @@ -407,9 +407,9 @@ public static string Encode(object? value, Encoding? encoding = null, Format? fo match => { #if NETSTANDARD2_0 - var code = int.Parse(match.Value.Substring(2), NumberStyles.HexNumber); + var code = int.Parse(match.Value.Substring(2), NumberStyles.HexNumber, CultureInfo.InvariantCulture); #else - var code = int.Parse(match.Value[2..], NumberStyles.HexNumber); + var code = int.Parse(match.Value[2..], NumberStyles.HexNumber, CultureInfo.InvariantCulture); #endif return $"%26%23{code}%3B"; } @@ -787,9 +787,13 @@ public static string InterpretNumericEntities(string str) { if (str.Length < 4) return str; - var first = str.IndexOf("&#", StringComparison.Ordinal); - if (first == -1) +#if NETSTANDARD2_0 + if (str.IndexOf("&#", StringComparison.Ordinal) == -1) + return str; +#else + if (!str.Contains("&#", StringComparison.Ordinal)) return str; +#endif var sb = new StringBuilder(str.Length); var i = 0; @@ -1119,7 +1123,6 @@ private static object NormalizeForTarget(IDictionary map) // List node ➜ List case IList srcList when dst is List dstList: foreach (var item in srcList) - { switch (item) { case IDictionary innerDict: @@ -1161,7 +1164,6 @@ private static object NormalizeForTarget(IDictionary map) dstList.Add(item); break; } - } break; } From f7d0f2e28bf48e9dd44c794cd35ad193605771f3 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 26 Aug 2025 17:35:42 +0100 Subject: [PATCH 02/36] :art: use StringBuilder.Append for sentinel encoding and ensure culture-invariant array key formatting --- QsNet/Qs.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/QsNet/Qs.cs b/QsNet/Qs.cs index a12e6c7..03ef84e 100644 --- a/QsNet/Qs.cs +++ b/QsNet/Qs.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using System.Text; +using System.Globalization; using QsNet.Enums; using QsNet.Internal; using QsNet.Models; @@ -256,9 +257,9 @@ public static string Encode(object? data, EncodeOptions? options = null) { // encodeURIComponent('✓') and encodeURIComponent('βœ“') if (opts.Charset.WebName.Equals("iso-8859-1", StringComparison.OrdinalIgnoreCase)) - sb.Append($"{Sentinel.Iso.GetEncoded()}&"); + sb.Append(Sentinel.Iso.GetEncoded()).Append('&'); else if (opts.Charset.WebName.Equals("utf-8", StringComparison.OrdinalIgnoreCase)) - sb.Append($"{Sentinel.Charset.GetEncoded()}&"); + sb.Append(Sentinel.Charset.GetEncoded()).Append('&'); } if (joined.Length > 0) @@ -272,7 +273,7 @@ public static string Encode(object? data, EncodeOptions? options = null) var dict = new Dictionary(initial); var i = 0; foreach (var v in en) - dict.Add(i++.ToString(), v); + dict.Add(i++.ToString(CultureInfo.InvariantCulture), v); return dict; } } From 3ff7455aa033b8f38ebb2e75436a95e19acf7555 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 26 Aug 2025 17:35:58 +0100 Subject: [PATCH 03/36] :white_check_mark: update EncodeTests to expect InvalidOperationException for circular references --- QsNet.Tests/EncodeTests.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/QsNet.Tests/EncodeTests.cs b/QsNet.Tests/EncodeTests.cs index 15e9b52..b08fd5f 100644 --- a/QsNet.Tests/EncodeTests.cs +++ b/QsNet.Tests/EncodeTests.cs @@ -1790,13 +1790,13 @@ public void Encode_DoesNotCrashWhenParsingCircularReferences() Action act1 = () => Qs.Encode(new Dictionary { { "foo[bar]", "baz" }, { "foo[baz]", a } }); - act1.Should().Throw(); + act1.Should().Throw(); var circular = new Dictionary { { "a", "value" } }; circular["a"] = circular; Action act2 = () => Qs.Encode(circular); - act2.Should().Throw(); + act2.Should().Throw(); var arr = new List { "a" }; Action act3 = () => @@ -3405,7 +3405,7 @@ public void Encode_ThrowsOnSelfReferentialMap() a["self"] = a; var act = () => Qs.Encode(new Dictionary { { "a", a } }); - act.Should().Throw(); + act.Should().Throw(); } [Fact] @@ -3415,7 +3415,7 @@ public void Encode_ThrowsOnSelfReferentialList() l.Add(l); var act = () => Qs.Encode(new Dictionary { { "l", l } }); - act.Should().Throw(); + act.Should().Throw(); } [Fact] From 0530198dc979ea118145ac04186fffbe9a878ede Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 26 Aug 2025 17:36:14 +0100 Subject: [PATCH 04/36] :wheelchair: throw InvalidOperationException for cyclic object values and fix dot encoding for NETSTANDARD2_0 compatibility --- QsNet/Internal/Encoder.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/QsNet/Internal/Encoder.cs b/QsNet/Internal/Encoder.cs index 9132a67..458f36e 100644 --- a/QsNet/Internal/Encoder.cs +++ b/QsNet/Internal/Encoder.cs @@ -86,7 +86,7 @@ public static object Encode( if (objKey is not null && tmpSc.TryGet(objKey, out var pos)) { if (pos == step) - throw new IndexOutOfRangeException("Cyclic object value"); + throw new InvalidOperationException("Cyclic object value"); found = true; } @@ -370,7 +370,11 @@ IConvertible when int.TryParse(key.ToString(), out var parsed) => parsed, var keyStr = key?.ToString() ?? ""; var encodedKey = keyStr; +#if NETSTANDARD2_0 if (allowDots && encodeDotInKeys && keyStr.IndexOf('.') >= 0) +#else + if (allowDots && encodeDotInKeys && keyStr.Contains('.')) +#endif encodedKey = keyStr.Replace(".", "%2E"); var keyPrefix = From 2fefa90fd4aafe132059ee0cd8ab51aa1b7088fd Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 26 Aug 2025 17:36:42 +0100 Subject: [PATCH 05/36] :white_check_mark: update DecodeTests to expect InvalidOperationException instead of IndexOutOfRangeException or ArgumentException --- QsNet.Tests/DecodeTests.cs | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/QsNet.Tests/DecodeTests.cs b/QsNet.Tests/DecodeTests.cs index e334f34..386d592 100644 --- a/QsNet.Tests/DecodeTests.cs +++ b/QsNet.Tests/DecodeTests.cs @@ -272,7 +272,7 @@ public void Should_Throw_When_Comma_List_Limit_Exceeded() action .Should() - .Throw() + .Throw() .WithMessage("List limit exceeded. Only 3 elements allowed in a list."); } @@ -2277,7 +2277,7 @@ public void Decode_StrictDepth_ThrowsExceptionForMultipleNestedObjectsWithStrict var options = new DecodeOptions { Depth = 1, StrictDepth = true }; Action act = () => Qs.Decode("a[b][c][d][e][f][g][h][i]=j", options); - act.Should().Throw(); + act.Should().Throw(); } [Fact] @@ -2286,7 +2286,7 @@ public void Decode_StrictDepth_ThrowsExceptionForMultipleNestedListsWithStrictDe var options = new DecodeOptions { Depth = 3, StrictDepth = true }; Action act = () => Qs.Decode("a[0][1][2][3][4]=b", options); - act.Should().Throw(); + act.Should().Throw(); } [Fact] @@ -2295,7 +2295,7 @@ public void Decode_StrictDepth_ThrowsExceptionForNestedMapsAndListsWithStrictDep var options = new DecodeOptions { Depth = 3, StrictDepth = true }; Action act = () => Qs.Decode("a[b][c][0][d][e]=f", options); - act.Should().Throw(); + act.Should().Throw(); } [Fact] @@ -2304,7 +2304,7 @@ public void Decode_StrictDepth_ThrowsExceptionForDifferentTypesOfValuesWithStric var options = new DecodeOptions { Depth = 3, StrictDepth = true }; Action act = () => Qs.Decode("a[b][c][d][e]=true&a[b][c][d][f]=42", options); - act.Should().Throw(); + act.Should().Throw(); } [Fact] @@ -2405,7 +2405,7 @@ public void Decode_ParameterLimit_ThrowsErrorWhenParameterLimitExceeded() var options = new DecodeOptions { ParameterLimit = 3, ThrowOnLimitExceeded = true }; Action act = () => Qs.Decode("a=1&b=2&c=3&d=4&e=5&f=6", options); - act.Should().Throw(); + act.Should().Throw(); } [Fact] @@ -2483,7 +2483,7 @@ public void Decode_ListLimit_ThrowsErrorWhenListLimitExceeded() var options = new DecodeOptions { ListLimit = 3, ThrowOnLimitExceeded = true }; Action act = () => Qs.Decode("a[]=1&a[]=2&a[]=3&a[]=4", options); - act.Should().Throw(); + act.Should().Throw(); } [Fact] @@ -2530,7 +2530,7 @@ public void Decode_ListLimit_HandlesNegativeListLimitCorrectly() var options = new DecodeOptions { ListLimit = -1, ThrowOnLimitExceeded = true }; Action act = () => Qs.Decode("a[]=1&a[]=2", options); - act.Should().Throw(); + act.Should().Throw(); } [Fact] @@ -2539,7 +2539,7 @@ public void Decode_ListLimit_AppliesListLimitToNestedLists() var options = new DecodeOptions { ListLimit = 3, ThrowOnLimitExceeded = true }; Action act = () => Qs.Decode("a[0][]=1&a[0][]=2&a[0][]=3&a[0][]=4", options); - act.Should().Throw(); + act.Should().Throw(); } [Fact] @@ -4181,7 +4181,7 @@ 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(); + act.Should().Throw(); } [Fact] @@ -4349,7 +4349,7 @@ public void MixedCase_EncodedBrackets_EncodedDot_AllowDotsFalse_DecodeDotInKeysT { var opt = new DecodeOptions { AllowDots = false, DecodeDotInKeys = true }; Action act = () => Qs.Decode("a%5Bb%5D%5Bc%5D%2Ed=x", opt); - act.Should().Throw() + act.Should().Throw() .WithMessage("*decodeDotInKeys*allowDots*"); } @@ -4646,7 +4646,7 @@ public void StrictDepthOverflow_RaisesForWellFormed() { var act = () => InternalDecoder.SplitKeyIntoSegments("a[b][c][d]", false, 1, true); - act.Should().Throw(); + act.Should().Throw(); } [Fact] From 151a1450f76c7ec3f116fe8a1fec4ed96b2bfd30 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 26 Aug 2025 17:36:51 +0100 Subject: [PATCH 06/36] :wheelchair: replace IndexOutOfRangeException with InvalidOperationException for limit and depth checks in Decoder; ensure culture-invariant numeric parsing and NETSTANDARD2_0 compatibility --- QsNet/Internal/Decoder.cs | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/QsNet/Internal/Decoder.cs b/QsNet/Internal/Decoder.cs index e3f71ef..f2a2717 100644 --- a/QsNet/Internal/Decoder.cs +++ b/QsNet/Internal/Decoder.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using System.Text; +using System.Globalization; using QsNet.Enums; using QsNet.Models; @@ -49,14 +50,14 @@ int currentListLength { var splitVal = str.Split(','); if (options.ThrowOnLimitExceeded && splitVal.Length > options.ListLimit) - throw new IndexOutOfRangeException( + throw new InvalidOperationException( $"List limit exceeded. Only {options.ListLimit} element{(options.ListLimit == 1 ? "" : "s")} allowed in a list." ); return splitVal.ToList(); } if (options.ThrowOnLimitExceeded && currentListLength >= options.ListLimit) - throw new IndexOutOfRangeException( + throw new InvalidOperationException( $"List limit exceeded. Only {options.ListLimit} element{(options.ListLimit == 1 ? "" : "s")} allowed in a list." ); @@ -104,7 +105,7 @@ int currentListLength for (var i = 0; i < count; i++) parts.Add(allParts[i]); if (options.ThrowOnLimitExceeded && allParts.Length > limit.Value) - throw new IndexOutOfRangeException( + throw new InvalidOperationException( $"Parameter limit exceeded. Only {limit} parameter{(limit == 1 ? "" : "s")} allowed." ); } @@ -187,7 +188,11 @@ int currentListLength value = Utils.InterpretNumericEntities(tmpStr); } + #if NETSTANDARD2_0 if (part.IndexOf("[]=", StringComparison.Ordinal) >= 0) + #else + if (part.Contains("[]=", StringComparison.Ordinal)) + #endif value = value is IEnumerable and not string ? new List { value } : value; if (obj.TryGetValue(key, out var existingVal)) @@ -292,7 +297,7 @@ bool valuesParsed { // Unwrap [ ... ] and (optionally) decode %2E -> . #if NETSTANDARD2_0 - var cleanRoot = root.StartsWith("[") && root.EndsWith("]") + var cleanRoot = root.StartsWith("[", StringComparison.Ordinal) && root.EndsWith("]", StringComparison.Ordinal) ? root.Substring(1, root.Length - 2) : root; #else @@ -342,7 +347,7 @@ bool valuesParsed var isPureNumeric = int.TryParse(decodedRoot, out var idx) && !string.IsNullOrEmpty(decodedRoot); var isBracketedNumeric = - isPureNumeric && root != decodedRoot && idx.ToString() == decodedRoot; + isPureNumeric && root != decodedRoot && idx.ToString(CultureInfo.InvariantCulture) == decodedRoot; if (!options.ParseLists || options.ListLimit < 0) { @@ -421,8 +426,13 @@ bool valuesParsed /// private static string DotToBracketTopLevel(string key) { + #if NETSTANDARD2_0 if (string.IsNullOrEmpty(key) || key.IndexOf('.') < 0) return key; + #else + if (string.IsNullOrEmpty(key) || !key.Contains('.')) + return key; + #endif var sb = new StringBuilder(key.Length + 4); var depth = 0; @@ -569,7 +579,7 @@ bool strictDepth { // Well-formed overflow remainder: still subject to strictDepth if (strictDepth && !brokeUnterminated) - throw new IndexOutOfRangeException( + throw new InvalidOperationException( $"Input depth exceeded depth option of {maxDepth} and strictDepth is true" ); @@ -598,7 +608,7 @@ bool strictDepth #endif if (trailing == ".") return segments; if (strictDepth) - throw new IndexOutOfRangeException( + throw new InvalidOperationException( $"Input depth exceeded depth option of {maxDepth} and strictDepth is true" ); segments.Add("[" + trailing + "]"); From 8fb686572f6de3a37f3880adb3e2aeeade6d8e6a Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 26 Aug 2025 17:36:57 +0100 Subject: [PATCH 07/36] :white_check_mark: update DecodeOptionsTests to expect InvalidOperationException for dot decoding option errors --- QsNet.Tests/DecodeOptionsTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/QsNet.Tests/DecodeOptionsTests.cs b/QsNet.Tests/DecodeOptionsTests.cs index 482f656..3d43c45 100644 --- a/QsNet.Tests/DecodeOptionsTests.cs +++ b/QsNet.Tests/DecodeOptionsTests.cs @@ -121,7 +121,7 @@ public void DecodeKey_ShouldThrow_When_DecodeDotInKeysTrue_And_AllowDotsFalse() }; Action act = () => options.DecodeKey("a%2Eb", Encoding.UTF8); - act.Should().Throw() + act.Should().Throw() .Where(e => e.Message.Contains("decodeDotInKeys", StringComparison.OrdinalIgnoreCase) && e.Message.Contains("allowDots", StringComparison.OrdinalIgnoreCase)); } From 05c870fffef49df5c307addf825e58851a927ad8 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 26 Aug 2025 17:37:02 +0100 Subject: [PATCH 08/36] :wheelchair: throw InvalidOperationException for invalid DecodeDotInKeys and AllowDots combination; update DefaultDecode to return string --- QsNet/Models/DecodeOptions.cs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/QsNet/Models/DecodeOptions.cs b/QsNet/Models/DecodeOptions.cs index 9643bae..8efe690 100644 --- a/QsNet/Models/DecodeOptions.cs +++ b/QsNet/Models/DecodeOptions.cs @@ -197,9 +197,8 @@ public bool DecodeDotInKeys public object? Decode(string? value, Encoding? encoding = null, DecodeKind kind = DecodeKind.Value) { if (kind == DecodeKind.Key && _decodeDotInKeys == true && _allowDots == false) - throw new ArgumentException( - "DecodeDotInKeys=true requires AllowDots=true when decoding keys.", - nameof(DecodeDotInKeys) + throw new InvalidOperationException( + "Invalid DecodeOptions: DecodeDotInKeys=true requires AllowDots=true when decoding keys." ); var d3 = DecoderWithKind; if (d3 is not null) return d3.Invoke(value, encoding, kind); @@ -236,7 +235,7 @@ public bool DecodeDotInKeys /// 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) + private static string? DefaultDecode(string? value, Encoding? encoding) { return value is null ? null : Utils.Decode(value, encoding); } From 49efd2ae6ca121407e502003159999ac6b475983 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 26 Aug 2025 17:37:10 +0100 Subject: [PATCH 09/36] :package: update project metadata; expand package tags, add release notes, enable analyzers and validation, configure symbol and source embedding, add SourceLink for GitHub --- QsNet/QsNet.csproj | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/QsNet/QsNet.csproj b/QsNet/QsNet.csproj index c498297..df99740 100644 --- a/QsNet/QsNet.csproj +++ b/QsNet/QsNet.csproj @@ -7,17 +7,44 @@ QsNet 1.0.7 Klemen Tusar - query-string; parser; encoder; qs + + querystring;query-string;query;parameters;url;uri;http; + parser;encoder;decoder;encoding;decoding; + form-urlencoded;percent-encoding;nested;arrays;brackets; + qs;aspnetcore + A query string encoding and decoding library for C#/.NET. Ported from qs for JavaScript. https://techouse.github.io/qs-net/ https://github.com/techouse/qs-net git BSD-3-Clause README.md + true + true + snupkg + true + + true + true + latest-recommended + + true + true + + See CHANGELOG: https://github.com/techouse/qs-net/releases + QsNet - Query-string encoding/decoding for .NET + + + + enable + + + + From 62222da54045c2b6c1b1f0c23b6d4123e6e0c4d4 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 26 Aug 2025 17:38:19 +0100 Subject: [PATCH 10/36] :art: update NETSTANDARD2_0 conditional checks to use consistent preprocessor formatting in Decoder --- QsNet/Internal/Decoder.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/QsNet/Internal/Decoder.cs b/QsNet/Internal/Decoder.cs index f2a2717..4e5947a 100644 --- a/QsNet/Internal/Decoder.cs +++ b/QsNet/Internal/Decoder.cs @@ -188,11 +188,11 @@ int currentListLength value = Utils.InterpretNumericEntities(tmpStr); } - #if NETSTANDARD2_0 +#if NETSTANDARD2_0 if (part.IndexOf("[]=", StringComparison.Ordinal) >= 0) - #else +#else if (part.Contains("[]=", StringComparison.Ordinal)) - #endif +#endif value = value is IEnumerable and not string ? new List { value } : value; if (obj.TryGetValue(key, out var existingVal)) @@ -426,13 +426,13 @@ bool valuesParsed /// private static string DotToBracketTopLevel(string key) { - #if NETSTANDARD2_0 +#if NETSTANDARD2_0 if (string.IsNullOrEmpty(key) || key.IndexOf('.') < 0) return key; - #else +#else if (string.IsNullOrEmpty(key) || !key.Contains('.')) return key; - #endif +#endif var sb = new StringBuilder(key.Length + 4); var depth = 0; From c60336a54dcf60c63e341948d62ca11070ffd26b Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 26 Aug 2025 17:57:20 +0100 Subject: [PATCH 11/36] :bulb: update exception documentation to reflect InvalidOperationException for limit and option violations in Decode and Encode methods --- QsNet/Qs.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/QsNet/Qs.cs b/QsNet/Qs.cs index 03ef84e..d4a02af 100644 --- a/QsNet/Qs.cs +++ b/QsNet/Qs.cs @@ -26,7 +26,7 @@ public static class Qs /// Optional decoder settings /// The decoded Dictionary /// If the input is not a string or Dictionary - /// If limits are exceeded and ThrowOnLimitExceeded is true + /// If limits are exceeded and ThrowOnLimitExceeded is true public static Dictionary Decode(object? input, DecodeOptions? options = null) { var opts = options ?? new DecodeOptions(); @@ -122,7 +122,7 @@ public static class Qs /// The data to encode /// Optional encoder settings /// The encoded query string - /// Thrown when index is out of bounds + /// Thrown when options/limits are violated during encoding public static string Encode(object? data, EncodeOptions? options = null) { var opts = options ?? new EncodeOptions(); From 62983da8c00d481af0b82ff22d23f77504b18626 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 26 Aug 2025 17:58:24 +0100 Subject: [PATCH 12/36] :bulb: update exception documentation to reflect InvalidOperationException for limit and depth violations in Decoder methods --- QsNet/Internal/Decoder.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/QsNet/Internal/Decoder.cs b/QsNet/Internal/Decoder.cs index 4e5947a..b24778e 100644 --- a/QsNet/Internal/Decoder.cs +++ b/QsNet/Internal/Decoder.cs @@ -71,7 +71,7 @@ int currentListLength /// The decoding options that affect how the string is parsed. /// A mutable dictionary containing the parsed key-value pairs. /// If the parameter limit is not a positive integer. - /// If the parameter limit is exceeded and ThrowOnLimitExceeded is true. + /// If the parameter limit is exceeded and ThrowOnLimitExceeded is true. internal static Dictionary ParseQueryStringValues( string str, DecodeOptions? options = null @@ -496,7 +496,7 @@ private static string DotToBracketTopLevel(string key) /// The maximum depth for splitting. /// Whether to enforce strict depth limits. /// A list of segments derived from the original key. - /// If the depth exceeds maxDepth and strictDepth is true. + /// If the depth exceeds maxDepth and strictDepth is true. internal static List SplitKeyIntoSegments( string originalKey, bool allowDots, From 7b28dff3baf6277997a1923204738580211cf5b4 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 26 Aug 2025 18:02:51 +0100 Subject: [PATCH 13/36] :safety_vest: fix dot encoding in keys to use ordinal string comparison for consistency across frameworks --- QsNet/Internal/Encoder.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/QsNet/Internal/Encoder.cs b/QsNet/Internal/Encoder.cs index 458f36e..dd08133 100644 --- a/QsNet/Internal/Encoder.cs +++ b/QsNet/Internal/Encoder.cs @@ -372,10 +372,11 @@ IConvertible when int.TryParse(key.ToString(), out var parsed) => parsed, var encodedKey = keyStr; #if NETSTANDARD2_0 if (allowDots && encodeDotInKeys && keyStr.IndexOf('.') >= 0) + encodedKey = keyStr.Replace(".", "%2E"); #else - if (allowDots && encodeDotInKeys && keyStr.Contains('.')) + if (allowDots && encodeDotInKeys && keyStr.Contains('.', StringComparison.Ordinal)) + encodedKey = keyStr.Replace(".", "%2E", StringComparison.Ordinal); #endif - encodedKey = keyStr.Replace(".", "%2E"); var keyPrefix = obj is IEnumerable and not string and not IDictionary From d29200fbf1845f8a8b1080a04ad3c60bf2a73b18 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 26 Aug 2025 18:04:46 +0100 Subject: [PATCH 14/36] :green_heart: enforce warnings as errors during CI builds for stricter linting --- QsNet/QsNet.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/QsNet/QsNet.csproj b/QsNet/QsNet.csproj index df99740..ce8e61e 100644 --- a/QsNet/QsNet.csproj +++ b/QsNet/QsNet.csproj @@ -30,6 +30,7 @@ true true + true See CHANGELOG: https://github.com/techouse/qs-net/releases QsNet - Query-string encoding/decoding for .NET From d2705bfbad84266229ec4a17d239733818e2f6b4 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 26 Aug 2025 18:18:04 +0100 Subject: [PATCH 15/36] :sparkles: improve entity decoding to support hexadecimal numeric character references and enhance preprocessor formatting --- QsNet/Internal/Utils.cs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/QsNet/Internal/Utils.cs b/QsNet/Internal/Utils.cs index 0232086..f1f852a 100644 --- a/QsNet/Internal/Utils.cs +++ b/QsNet/Internal/Utils.cs @@ -351,7 +351,8 @@ out var code else if ( i + 3 <= str.Length #if NETSTANDARD2_0 - && int.TryParse(str.Substring(i + 1, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var b) + && int.TryParse(str.Substring(i + 1, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, + out var b) #else && int.TryParse(str.AsSpan(i + 1, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var b) #endif @@ -407,7 +408,8 @@ public static string Encode(object? value, Encoding? encoding = null, Format? fo match => { #if NETSTANDARD2_0 - var code = int.Parse(match.Value.Substring(2), NumberStyles.HexNumber, CultureInfo.InvariantCulture); + var code = int.Parse(match.Value.Substring(2), NumberStyles.HexNumber, + CultureInfo.InvariantCulture); #else var code = int.Parse(match.Value[2..], NumberStyles.HexNumber, CultureInfo.InvariantCulture); #endif @@ -805,13 +807,17 @@ public static string InterpretNumericEntities(string str) if (ch == '&' && i + 2 < n && str[i + 1] == '#') { var j = i + 2; - if (j < n && char.IsDigit(str[j])) + if (j < n && (char.IsDigit(str[j]) || (str[j] is 'x' or 'X' && j + 1 < n))) { var code = 0; var startDigits = j; - while (j < n && char.IsDigit(str[j])) + var hex = false; + if (str[j] is 'x' or 'X') { hex = true; j++; startDigits = j; } + while (j < n && (hex ? Uri.IsHexDigit(str[j]) : char.IsDigit(str[j]))) { - code = code * 10 + (str[j] - '0'); + code = hex + ? (code << 4) + Convert.ToInt32(str[j].ToString(), 16) + : code * 10 + (str[j] - '0'); j++; } From 1656b01d5f9374f7bfe0f98ae58bf2245a7de0c9 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 26 Aug 2025 18:18:16 +0100 Subject: [PATCH 16/36] :white_check_mark: add comprehensive tests for hex numeric entity decoding in InterpretNumericEntities --- QsNet.Tests/UtilsTests.cs | 38 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/QsNet.Tests/UtilsTests.cs b/QsNet.Tests/UtilsTests.cs index 8503dc7..f04bb51 100644 --- a/QsNet.Tests/UtilsTests.cs +++ b/QsNet.Tests/UtilsTests.cs @@ -1285,8 +1285,8 @@ public void InterpretNumericEntities_MalformedOrUnsupportedPatternsRemainUnchang Utils.InterpretNumericEntities("&#;").Should().Be("&#;"); // Missing terminating semicolon Utils.InterpretNumericEntities(" ").Should().Be(" "); - // Hex form not supported by this decoder - Utils.InterpretNumericEntities("A").Should().Be("A"); + // Hex form is supported by this decoder + Utils.InterpretNumericEntities("A").Should().Be("A"); // Space inside Utils.InterpretNumericEntities("&# 12;").Should().Be("&# 12;"); // Negative / non-digit after '#' @@ -1302,6 +1302,40 @@ public void InterpretNumericEntities_OutOfRangeCodePointsRemainUnchanged() Utils.InterpretNumericEntities("�").Should().Be("�"); } + [Fact] + public void InterpretNumericEntities_DecodesSingleHexEntity() + { + Utils.InterpretNumericEntities("A").Should().Be("A"); // uppercase hex digits + Utils.InterpretNumericEntities("m").Should().Be("m"); // lowercase hex digits + } + + [Fact] + public void InterpretNumericEntities_DecodesMultipleHexEntities() + { + Utils.InterpretNumericEntities("Hi!").Should().Be("Hi!"); + } + + [Fact] + public void InterpretNumericEntities_DecodesHexSurrogatePair() + { + // U+1F4A9 (πŸ’©) as surrogate halves: 0xD83D, 0xDCA9 + Utils.InterpretNumericEntities("��").Should().Be("πŸ’©"); + } + + [Fact] + public void InterpretNumericEntities_MixedDecimalAndHexEntities() + { + Utils.InterpretNumericEntities("A = A and B").Should().Be("A = A and B"); + } + + [Fact] + public void InterpretNumericEntities_InvalidHexEntitiesRemainUnchanged() + { + Utils.InterpretNumericEntities("&#xZZ;").Should().Be("&#xZZ;"); // non-hex digits + Utils.InterpretNumericEntities("G;").Should().Be("G;"); // invalid hex digit + Utils.InterpretNumericEntities("A").Should().Be("A"); // missing semicolon + } + [Fact] public void Apply_OnScalarAndList() { From 65b057332cbaa11e31ce0500c1e111c90d235e23 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 26 Aug 2025 18:28:59 +0100 Subject: [PATCH 17/36] :safety_vest: fix encoding to append ampersand only when joined string is non-empty for charset sentinels --- QsNet/Qs.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/QsNet/Qs.cs b/QsNet/Qs.cs index d4a02af..e682354 100644 --- a/QsNet/Qs.cs +++ b/QsNet/Qs.cs @@ -257,9 +257,9 @@ public static string Encode(object? data, EncodeOptions? options = null) { // encodeURIComponent('✓') and encodeURIComponent('βœ“') if (opts.Charset.WebName.Equals("iso-8859-1", StringComparison.OrdinalIgnoreCase)) - sb.Append(Sentinel.Iso.GetEncoded()).Append('&'); + sb.Append(Sentinel.Iso.GetEncoded()).Append(joined.Length > 0 ? "&" : ""); else if (opts.Charset.WebName.Equals("utf-8", StringComparison.OrdinalIgnoreCase)) - sb.Append(Sentinel.Charset.GetEncoded()).Append('&'); + sb.Append(Sentinel.Charset.GetEncoded()).Append(joined.Length > 0 ? "&" : ""); } if (joined.Length > 0) From 89351ad87ef07667b96b81e04e0a665ab853da20 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 26 Aug 2025 18:31:24 +0100 Subject: [PATCH 18/36] :bulb: clarify decoder delegate remarks to specify return type requirements for key decoding --- QsNet/Models/DecodeOptions.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/QsNet/Models/DecodeOptions.cs b/QsNet/Models/DecodeOptions.cs index 8efe690..fc33566 100644 --- a/QsNet/Models/DecodeOptions.cs +++ b/QsNet/Models/DecodeOptions.cs @@ -12,6 +12,11 @@ namespace QsNet.Models; /// The encoded value to decode. /// The character encoding to use for decoding, if any. /// The decoded value, or null if the value is not present. +/// +/// When this delegate is used to decode keys (e.g., via ), +/// it must return either a or ; returning any other +/// type will cause at the call site. +/// public delegate object? Decoder(string? value, Encoding? encoding); /// @@ -23,8 +28,9 @@ namespace QsNet.Models; /// 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 must return a +/// or . Returning any other type will cause +/// at the call site (see ). /// public delegate object? KindAwareDecoder(string? value, Encoding? encoding, DecodeKind kind); From 58b430097be148a113c05d4964655381c93e5564 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 26 Aug 2025 18:34:55 +0100 Subject: [PATCH 19/36] :bulb: expand decoder comments to clarify synthetic bracket trimming logic in key segment parsing --- QsNet/Internal/Decoder.cs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/QsNet/Internal/Decoder.cs b/QsNet/Internal/Decoder.cs index b24778e..56fce3f 100644 --- a/QsNet/Internal/Decoder.cs +++ b/QsNet/Internal/Decoder.cs @@ -304,9 +304,14 @@ bool valuesParsed var cleanRoot = root.StartsWith('[') && root.EndsWith(']') ? root[1..^1] : root; #endif - // If we unwrapped a synthetic β€œdouble-bracket” remainder (e.g. "[[b[c]]"), - // the inner content becomes "[b[c]". That trailing ']' is artificial – drop it - // so the literal inner text is "[b[c" (matches qs/Kotlin parity and your tests). + // Why does `opens > closes` imply the trailing ']' is synthetic? + // SplitKeyIntoSegments() wraps any overflow/unterminated remainder exactly once: + // segments.Add("[" + remainder + "]"); + // Here we've already removed that outer wrapper (cleanRoot = root[1..^1]). + // If the remaining inner text has more '[' than ']' and *still* ends with ']', + // that last ']' cannot be balancing any '[' from the inner text β€” it's the + // closing bracket from the synthetic wrapper that leaked into this inner slice. + // Trimming it recovers the literal remainder (e.g., "[[b[c]]" β†’ cleanRoot "[b[c]" β†’ trim β†’ "[b[c"). if (root.Length >= 2 && root[0] == '[' && root[root.Length - 1] == ']') { var inner = cleanRoot; @@ -322,7 +327,6 @@ bool valuesParsed break; } - // More '[' than ']' inside AND it ends with ']' β†’ trim the final ']' if (opens > closes && inner.Length > 0 && inner[inner.Length - 1] == ']') { #if NETSTANDARD2_0 From 5ab597c6a0a50bea258d441170cfe1a2ac23abdd Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 26 Aug 2025 18:42:15 +0100 Subject: [PATCH 20/36] :recycle: update ParseObject to accept mutable List for chain parameter --- QsNet/Internal/Decoder.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/QsNet/Internal/Decoder.cs b/QsNet/Internal/Decoder.cs index 56fce3f..83c3af3 100644 --- a/QsNet/Internal/Decoder.cs +++ b/QsNet/Internal/Decoder.cs @@ -225,7 +225,7 @@ int currentListLength /// Indicates whether the values have already been parsed. /// The resulting object after parsing the chain. private static object? ParseObject( - IReadOnlyList chain, + List chain, object? value, DecodeOptions options, bool valuesParsed From 7b8b015f4aefac3383796dbc29e27a1e2232bf34 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 26 Aug 2025 18:50:34 +0100 Subject: [PATCH 21/36] :safety_vest: enforce list limit by accounting for current list length when splitting comma-separated values --- QsNet/Internal/Decoder.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/QsNet/Internal/Decoder.cs b/QsNet/Internal/Decoder.cs index 83c3af3..048ba85 100644 --- a/QsNet/Internal/Decoder.cs +++ b/QsNet/Internal/Decoder.cs @@ -49,7 +49,7 @@ int currentListLength if (value is string str && !string.IsNullOrEmpty(str) && options.Comma && str.Contains(',')) { var splitVal = str.Split(','); - if (options.ThrowOnLimitExceeded && splitVal.Length > options.ListLimit) + if (options.ThrowOnLimitExceeded && currentListLength + splitVal.Length > options.ListLimit) throw new InvalidOperationException( $"List limit exceeded. Only {options.ListLimit} element{(options.ListLimit == 1 ? "" : "s")} allowed in a list." ); From 30190fa1ec1e58df38796e2109654dafe4ec0178 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 26 Aug 2025 18:50:44 +0100 Subject: [PATCH 22/36] :white_check_mark: add tests for comma-separated value decoding with list limit enforcement --- QsNet.Tests/DecodeTests.cs | 43 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/QsNet.Tests/DecodeTests.cs b/QsNet.Tests/DecodeTests.cs index 386d592..69f803e 100644 --- a/QsNet.Tests/DecodeTests.cs +++ b/QsNet.Tests/DecodeTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Linq; using System.Text; using System.Text.RegularExpressions; using System.Web; @@ -4681,4 +4682,46 @@ public void LeadingDot_EncodedBracket_AllowDotsTrue_DecodeDotInKeysTrue() } #endregion + + #region Decode comma limit + + [Fact] + public void Decode_CommaSplit_AllowedWhenSumEqualsLimit() + { + var opts = new DecodeOptions + { + Comma = true, + ListLimit = 5, + ThrowOnLimitExceeded = true, + ParseLists = true, + Duplicates = Duplicates.Combine + }; + + // Existing N=2 from first part, incoming M=3; N+M = 5 == limit β†’ allowed + var result = Assert.IsType>(Qs.Decode("a=1,2&a=3,4,5", opts)); + result.Should().ContainKey("a"); + + var list = Assert.IsType>(result["a"]); + list.Should().HaveCount(5); + list.Select(x => x?.ToString()).Should().Equal("1", "2", "3", "4", "5"); + } + + [Fact] + public void Decode_CommaSplit_ThrowsWhenSumExceedsLimitAndThrowOn() + { + var opts = new DecodeOptions + { + Comma = true, + ListLimit = 5, + ThrowOnLimitExceeded = true, + ParseLists = true, + Duplicates = Duplicates.Combine + }; + + // Existing N=2, incoming M=4; N+M = 6 > limit and ThrowOnLimitExceeded = true β†’ throws + Action act = () => Qs.Decode("a=1,2&a=3,4,5,6", opts); + act.Should().Throw(); + } + + #endregion } \ No newline at end of file From 5cc572bba445aa7f437f7c1c05f66f411aba4608 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 26 Aug 2025 18:58:30 +0100 Subject: [PATCH 23/36] :white_check_mark: add tests for uppercase 'X' in hex entities, max valid code point, and empty hex digits in InterpretNumericEntities --- QsNet.Tests/UtilsTests.cs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/QsNet.Tests/UtilsTests.cs b/QsNet.Tests/UtilsTests.cs index f04bb51..64a9ec1 100644 --- a/QsNet.Tests/UtilsTests.cs +++ b/QsNet.Tests/UtilsTests.cs @@ -1308,6 +1308,28 @@ public void InterpretNumericEntities_DecodesSingleHexEntity() Utils.InterpretNumericEntities("A").Should().Be("A"); // uppercase hex digits Utils.InterpretNumericEntities("m").Should().Be("m"); // lowercase hex digits } + + [Fact] + public void InterpretNumericEntities_DecodesSingleHexEntity_UppercaseX() + { + Utils.InterpretNumericEntities("A").Should().Be("A"); + } + + [Fact] + public void InterpretNumericEntities_AcceptsMaxValidHexAndRejectsBeyond() + { + // U+10FFFF is valid + Utils.InterpretNumericEntities("􏿿").Should().Be(char.ConvertFromUtf32(0x10FFFF)); + // One above max should remain unchanged + Utils.InterpretNumericEntities("�").Should().Be("�"); + } + + [Fact] + public void InterpretNumericEntities_EmptyHexDigitsRemainUnchanged() + { + Utils.InterpretNumericEntities("&#x;").Should().Be("&#x;"); + Utils.InterpretNumericEntities("&#X;").Should().Be("&#X;"); + } [Fact] public void InterpretNumericEntities_DecodesMultipleHexEntities() From f4300b0ed4aedb8ffe89c2a8928747e2d6cb39d6 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 26 Aug 2025 19:11:15 +0100 Subject: [PATCH 24/36] :art: fix whitespace in InterpretNumericEntities hex entity tests --- QsNet.Tests/UtilsTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/QsNet.Tests/UtilsTests.cs b/QsNet.Tests/UtilsTests.cs index 64a9ec1..3552307 100644 --- a/QsNet.Tests/UtilsTests.cs +++ b/QsNet.Tests/UtilsTests.cs @@ -1308,7 +1308,7 @@ public void InterpretNumericEntities_DecodesSingleHexEntity() Utils.InterpretNumericEntities("A").Should().Be("A"); // uppercase hex digits Utils.InterpretNumericEntities("m").Should().Be("m"); // lowercase hex digits } - + [Fact] public void InterpretNumericEntities_DecodesSingleHexEntity_UppercaseX() { From d2f54941854dbcffd2942c6592cc67f194c46f06 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 26 Aug 2025 19:11:21 +0100 Subject: [PATCH 25/36] :zap: optimize numeric entity parsing to avoid per-digit string allocations and handle overflow --- QsNet/Internal/Utils.cs | 34 ++++++++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/QsNet/Internal/Utils.cs b/QsNet/Internal/Utils.cs index f1f852a..fb9dd37 100644 --- a/QsNet/Internal/Utils.cs +++ b/QsNet/Internal/Utils.cs @@ -809,20 +809,42 @@ public static string InterpretNumericEntities(string str) var j = i + 2; if (j < n && (char.IsDigit(str[j]) || (str[j] is 'x' or 'X' && j + 1 < n))) { - var code = 0; var startDigits = j; var hex = false; if (str[j] is 'x' or 'X') { hex = true; j++; startDigits = j; } + + // Advance j over the digit run without allocating per-digit strings while (j < n && (hex ? Uri.IsHexDigit(str[j]) : char.IsDigit(str[j]))) - { - code = hex - ? (code << 4) + Convert.ToInt32(str[j].ToString(), 16) - : code * 10 + (str[j] - '0'); j++; - } if (j < n && str[j] == ';' && j > startDigits) { + int code; +#if NETSTANDARD2_0 + var digits = str.Substring(startDigits, j - startDigits); + var ok = int.TryParse( + digits, + hex ? NumberStyles.HexNumber : NumberStyles.Integer, + CultureInfo.InvariantCulture, + out code + ); +#else + var digits = str.AsSpan(startDigits, j - startDigits); + var ok = int.TryParse( + digits, + hex ? NumberStyles.HexNumber : NumberStyles.Integer, + CultureInfo.InvariantCulture, + out code + ); +#endif + if (!ok) + { + // Overflow or invalid digits: leave input unchanged + sb.Append('&'); + i++; + continue; + } + switch (code) { case <= 0xFFFF: From d2c2beae3624414fb296f641ebf51211faf3126e Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 26 Aug 2025 19:12:47 +0100 Subject: [PATCH 26/36] :safety_vest: handle invalid surrogate pairs by percent-encoding single surrogates for lossless encoding --- QsNet/Internal/Utils.cs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/QsNet/Internal/Utils.cs b/QsNet/Internal/Utils.cs index fb9dd37..8e9c844 100644 --- a/QsNet/Internal/Utils.cs +++ b/QsNet/Internal/Utils.cs @@ -465,9 +465,18 @@ public static string Encode(object? value, Encoding? encoding = null, Format? fo continue; } - // 4 bytes (surrogate pair) - var nextC = i + 1 < segment.Length ? segment[i + 1] : 0; - var codePoint = 0x10000 + (((c & 0x3FF) << 10) | (nextC & 0x3FF)); + // 4 bytes (surrogate pair) – only if valid pair; otherwise treat as 3-byte fallback + if (i + 1 >= segment.Length || !char.IsSurrogatePair(segment[i], segment[i + 1])) + { + // Fallback: percent-encode the single surrogate code unit to remain lossless + buffer.Append(HexTable.Table[0xE0 | (c >> 12)]); + buffer.Append(HexTable.Table[0x80 | ((c >> 6) & 0x3F)]); + buffer.Append(HexTable.Table[0x80 | (c & 0x3F)]); + i++; + continue; + } + var nextC = segment[i + 1]; + var codePoint = char.ConvertToUtf32((char)c, nextC); buffer.Append(HexTable.Table[0xF0 | (codePoint >> 18)]); buffer.Append(HexTable.Table[0x80 | ((codePoint >> 12) & 0x3F)]); buffer.Append(HexTable.Table[0x80 | ((codePoint >> 6) & 0x3F)]); From 1d0d4141a7f70b91415ef5de1f81c71e6a9029ee Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 26 Aug 2025 19:16:46 +0100 Subject: [PATCH 27/36] :bug: ensure culture-invariant numeric parsing for list indexing and bracketed keys --- QsNet/Internal/Decoder.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/QsNet/Internal/Decoder.cs b/QsNet/Internal/Decoder.cs index 048ba85..9e029d8 100644 --- a/QsNet/Internal/Decoder.cs +++ b/QsNet/Internal/Decoder.cs @@ -253,7 +253,7 @@ bool valuesParsed } if ( - int.TryParse(parentKeyStr, out var parentKey) + int.TryParse(parentKeyStr, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parentKey) && value is IList list && parentKey < list.Count ) @@ -349,7 +349,8 @@ bool valuesParsed // Bracketed numeric like "[1]"? var isPureNumeric = - int.TryParse(decodedRoot, out var idx) && !string.IsNullOrEmpty(decodedRoot); + int.TryParse(decodedRoot, NumberStyles.Integer, CultureInfo.InvariantCulture, out var idx) && + !string.IsNullOrEmpty(decodedRoot); var isBracketedNumeric = isPureNumeric && root != decodedRoot && idx.ToString(CultureInfo.InvariantCulture) == decodedRoot; From 3ac1490e06b2aa75f3d61ec13c558b1665886650 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 26 Aug 2025 19:25:14 +0100 Subject: [PATCH 28/36] :zap: make Latin1Encoding static readonly to avoid repeated allocation --- QsNet/Internal/Decoder.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/QsNet/Internal/Decoder.cs b/QsNet/Internal/Decoder.cs index 9e029d8..b690d96 100644 --- a/QsNet/Internal/Decoder.cs +++ b/QsNet/Internal/Decoder.cs @@ -18,7 +18,7 @@ internal static class Decoder internal static partial class Decoder #endif { - private static Encoding Latin1Encoding => + private static readonly Encoding Latin1Encoding = #if NETSTANDARD2_0 Encoding.GetEncoding(28591); #else From a294b8ac6472fa48452dee80182baa35281c7921 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 26 Aug 2025 21:30:00 +0100 Subject: [PATCH 29/36] :safety_vest: prevent splitting surrogate pairs across segment boundaries during string chunking --- QsNet/Internal/Utils.cs | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/QsNet/Internal/Utils.cs b/QsNet/Internal/Utils.cs index 8e9c844..fd4f014 100644 --- a/QsNet/Internal/Utils.cs +++ b/QsNet/Internal/Utils.cs @@ -424,10 +424,22 @@ public static string Encode(object? value, Encoding? encoding = null, Format? fo while (j < nonNullStr.Length) { - var segment = - nonNullStr.Length >= SegmentLimit - ? nonNullStr.Substring(j, Math.Min(SegmentLimit, nonNullStr.Length - j)) - : nonNullStr; + // Take up to SegmentLimit characters, but never split a surrogate pair across the boundary. + var remaining = nonNullStr.Length - j; + var segmentLen = remaining >= SegmentLimit ? SegmentLimit : remaining; + + // If the last char of this segment is a high surrogate and the next char exists and is a low surrogate, + // shrink the segment by one so the pair is encoded together in the next iteration. + if ( + segmentLen < remaining && + char.IsHighSurrogate(nonNullStr[j + segmentLen - 1]) && + char.IsLowSurrogate(nonNullStr[j + segmentLen]) + ) + { + segmentLen--; // keep the high surrogate with its low surrogate in the next chunk + } + + var segment = nonNullStr.Substring(j, segmentLen); var i = 0; while (i < segment.Length) @@ -484,7 +496,7 @@ public static string Encode(object? value, Encoding? encoding = null, Format? fo i += 2; // Skip the next character as it's part of the surrogate pair } - j += SegmentLimit; + j += segment.Length; // advance by the actual processed count } return buffer.ToString(); From 0fbfa2ca001112279758ffbe83de11ce986b8ad4 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 26 Aug 2025 21:30:15 +0100 Subject: [PATCH 30/36] :white_check_mark: add tests to verify astral character encoding at segment boundaries --- QsNet.Tests/UtilsTests.cs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/QsNet.Tests/UtilsTests.cs b/QsNet.Tests/UtilsTests.cs index 3552307..8217a8c 100644 --- a/QsNet.Tests/UtilsTests.cs +++ b/QsNet.Tests/UtilsTests.cs @@ -1623,4 +1623,24 @@ public void ToStringKeyDeepNonRecursive_Converts_Nested_Lists_And_Dicts() outTopList[2].Should().Be(4); } + + [Fact] + public void EnsureAstralCharactersAtSegmentLimitMinus1OrSegmentLimitEncodeAs4ByteSequences() + { + const int SegmentLimit = 1024; + // Ensure astral characters at SegmentLimit-1/SegmentLimit encode as 4-byte sequences + var s = new string('a', SegmentLimit - 1) + "\U0001F600" + "b"; + var encoded = Utils.Encode(s, Encoding.UTF8, Format.Rfc3986); + Assert.Contains("%F0%9F%98%80", encoded); + } + + [Fact] + public void EnsureAstralCharactersAtSegmentLimitEncodeAs4ByteSequences() + { + const int SegmentLimit = 1024; + // Astral character starts exactly at the chunk boundary (index == SegmentLimit) + var s = new string('a', SegmentLimit) + "\U0001F600" + "b"; + var encoded = Utils.Encode(s, Encoding.UTF8, Format.Rfc3986); + Assert.Contains("%F0%9F%98%80", encoded); + } } \ No newline at end of file From 531cccd459937ee3cdcde4619f587941d04f1534 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 26 Aug 2025 21:40:14 +0100 Subject: [PATCH 31/36] :bug: fix parent segment parsing for list length calculation in decoder --- QsNet/Internal/Decoder.cs | 36 +++++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/QsNet/Internal/Decoder.cs b/QsNet/Internal/Decoder.cs index b690d96..bf647b7 100644 --- a/QsNet/Internal/Decoder.cs +++ b/QsNet/Internal/Decoder.cs @@ -240,24 +240,30 @@ bool valuesParsed #endif ) { - string parentKeyStr; + // Look only at the immediate parent segment, e.g. "[0]" in ["a", "[0]", "[]"] if (chain.Count > 1) { - var sbTmp = new StringBuilder(); - for (var t = 0; t < chain.Count - 1; t++) sbTmp.Append(chain[t]); - parentKeyStr = sbTmp.ToString(); - } - else - { - parentKeyStr = string.Empty; +#if NETSTANDARD2_0 + var parentSeg = chain[chain.Count - 2]; +#else + var parentSeg = chain[^2]; +#endif + if (parentSeg.Length >= 2 && parentSeg[0] == '[' && parentSeg[parentSeg.Length - 1] == ']') + { +#if NETSTANDARD2_0 + var idxStr = parentSeg.Substring(1, parentSeg.Length - 2); +#else + var idxStr = parentSeg[1..^1]; +#endif + if (int.TryParse(idxStr, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parentIndex) + && value is IList incomingList + && parentIndex >= 0 + && parentIndex < incomingList.Count) + { + currentListLength = (incomingList[parentIndex] as IList)?.Count ?? 0; + } + } } - - if ( - int.TryParse(parentKeyStr, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parentKey) - && value is IList list - && parentKey < list.Count - ) - currentListLength = (list[parentKey] as IList)?.Count ?? 0; } var leaf = valuesParsed ? value : ParseListValue(value, options, currentListLength); From ac45d63e99843c4c0ba06a6ce7e1001a5a4bb1ed Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 26 Aug 2025 21:45:35 +0100 Subject: [PATCH 32/36] :white_check_mark: add tests to clarify comma-split truncation and bracketed single occurrence decoding behavior --- QsNet.Tests/DecodeTests.cs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/QsNet.Tests/DecodeTests.cs b/QsNet.Tests/DecodeTests.cs index 69f803e..d7716f7 100644 --- a/QsNet.Tests/DecodeTests.cs +++ b/QsNet.Tests/DecodeTests.cs @@ -4724,4 +4724,23 @@ public void Decode_CommaSplit_ThrowsWhenSumExceedsLimitAndThrowOn() } #endregion + + [Fact] + public void Decode_CommaSplit_TruncatesWhenSumExceedsLimit_AndThrowOff() + { + var opts = new DecodeOptions + { Comma = true, ListLimit = 3, ThrowOnLimitExceeded = false, Duplicates = Duplicates.Combine }; + var result = Qs.Decode("a=1,2&a=3,4,5", opts); + // Define expected behavior explicitly: either first 3, or last 3, or exactly how ParseListValue truncates upstream. + // Assert accordingly once decided. + } + + [Fact] + public void Decode_BracketSingle_CommaSplit_DefinesSingleOccurrenceBehavior() + { + var opts = new DecodeOptions { Comma = true }; + var res = Qs.Decode("a=1,2,3", opts); // control + var res2 = Qs.Decode("a[]=1,2,3", opts); // bracketed + // Decide and assert: flat ["1","2","3"] or nested [["1","2","3"]] + } } \ No newline at end of file From 0facbeab4e5597a51e6c0d8e4b115d4f9773c0e0 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 26 Aug 2025 21:47:01 +0100 Subject: [PATCH 33/36] :art: clean up formatting, indentation, and redundant braces across codebase --- QsNet.Tests/DecodeTests.cs | 38 +++++++++++++++---------------- QsNet.Tests/Fixtures/DummyEnum.cs | 1 + QsNet.Tests/UtilsTests.cs | 2 +- QsNet/Internal/Decoder.cs | 11 ++++----- QsNet/Internal/Utils.cs | 10 +++++--- QsNet/Models/DecodeOptions.cs | 12 +++++----- QsNet/Qs.cs | 2 +- QsNet/QsNet.csproj | 2 +- 8 files changed, 40 insertions(+), 38 deletions(-) diff --git a/QsNet.Tests/DecodeTests.cs b/QsNet.Tests/DecodeTests.cs index d7716f7..f96d55e 100644 --- a/QsNet.Tests/DecodeTests.cs +++ b/QsNet.Tests/DecodeTests.cs @@ -4128,6 +4128,25 @@ public void Decode_NonGeneric_Hashtable_Is_Normalised() decoded.Should().Equal(new Dictionary { ["x"] = 1, ["2"] = "y" }); } + [Fact] + public void Decode_CommaSplit_TruncatesWhenSumExceedsLimit_AndThrowOff() + { + var opts = new DecodeOptions + { Comma = true, ListLimit = 3, ThrowOnLimitExceeded = false, Duplicates = Duplicates.Combine }; + var result = Qs.Decode("a=1,2&a=3,4,5", opts); + // Define expected behavior explicitly: either first 3, or last 3, or exactly how ParseListValue truncates upstream. + // Assert accordingly once decided. + } + + [Fact] + public void Decode_BracketSingle_CommaSplit_DefinesSingleOccurrenceBehavior() + { + var opts = new DecodeOptions { Comma = true }; + var res = Qs.Decode("a=1,2,3", opts); // control + var res2 = Qs.Decode("a[]=1,2,3", opts); // bracketed + // Decide and assert: flat ["1","2","3"] or nested [["1","2","3"]] + } + #region Encoded dot behavior in keys (%2E / %2e) [Fact] @@ -4724,23 +4743,4 @@ public void Decode_CommaSplit_ThrowsWhenSumExceedsLimitAndThrowOn() } #endregion - - [Fact] - public void Decode_CommaSplit_TruncatesWhenSumExceedsLimit_AndThrowOff() - { - var opts = new DecodeOptions - { Comma = true, ListLimit = 3, ThrowOnLimitExceeded = false, Duplicates = Duplicates.Combine }; - var result = Qs.Decode("a=1,2&a=3,4,5", opts); - // Define expected behavior explicitly: either first 3, or last 3, or exactly how ParseListValue truncates upstream. - // Assert accordingly once decided. - } - - [Fact] - public void Decode_BracketSingle_CommaSplit_DefinesSingleOccurrenceBehavior() - { - var opts = new DecodeOptions { Comma = true }; - var res = Qs.Decode("a=1,2,3", opts); // control - var res2 = Qs.Decode("a[]=1,2,3", opts); // bracketed - // Decide and assert: flat ["1","2","3"] or nested [["1","2","3"]] - } } \ No newline at end of file diff --git a/QsNet.Tests/Fixtures/DummyEnum.cs b/QsNet.Tests/Fixtures/DummyEnum.cs index 0541ca2..87589b9 100644 --- a/QsNet.Tests/Fixtures/DummyEnum.cs +++ b/QsNet.Tests/Fixtures/DummyEnum.cs @@ -5,6 +5,7 @@ internal enum DummyEnum // ReSharper disable InconsistentNaming LOREM, IPSUM, + DOLOR // ReSharper restore InconsistentNaming } \ No newline at end of file diff --git a/QsNet.Tests/UtilsTests.cs b/QsNet.Tests/UtilsTests.cs index 8217a8c..3aba058 100644 --- a/QsNet.Tests/UtilsTests.cs +++ b/QsNet.Tests/UtilsTests.cs @@ -1355,7 +1355,7 @@ public void InterpretNumericEntities_InvalidHexEntitiesRemainUnchanged() { Utils.InterpretNumericEntities("&#xZZ;").Should().Be("&#xZZ;"); // non-hex digits Utils.InterpretNumericEntities("G;").Should().Be("G;"); // invalid hex digit - Utils.InterpretNumericEntities("A").Should().Be("A"); // missing semicolon + Utils.InterpretNumericEntities("A").Should().Be("A"); // missing semicolon } [Fact] diff --git a/QsNet/Internal/Decoder.cs b/QsNet/Internal/Decoder.cs index bf647b7..6d29bbe 100644 --- a/QsNet/Internal/Decoder.cs +++ b/QsNet/Internal/Decoder.cs @@ -1,9 +1,9 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Text; -using System.Globalization; using QsNet.Enums; using QsNet.Models; @@ -71,7 +71,7 @@ int currentListLength /// The decoding options that affect how the string is parsed. /// A mutable dictionary containing the parsed key-value pairs. /// If the parameter limit is not a positive integer. - /// If the parameter limit is exceeded and ThrowOnLimitExceeded is true. + /// If the parameter limit is exceeded and ThrowOnLimitExceeded is true. internal static Dictionary ParseQueryStringValues( string str, DecodeOptions? options = null @@ -239,7 +239,6 @@ bool valuesParsed chain[^1] == "[]" #endif ) - { // Look only at the immediate parent segment, e.g. "[0]" in ["a", "[0]", "[]"] if (chain.Count > 1) { @@ -259,12 +258,9 @@ bool valuesParsed && value is IList incomingList && parentIndex >= 0 && parentIndex < incomingList.Count) - { currentListLength = (incomingList[parentIndex] as IList)?.Count ?? 0; - } } } - } var leaf = valuesParsed ? value : ParseListValue(value, options, currentListLength); @@ -303,7 +299,8 @@ bool valuesParsed { // Unwrap [ ... ] and (optionally) decode %2E -> . #if NETSTANDARD2_0 - var cleanRoot = root.StartsWith("[", StringComparison.Ordinal) && root.EndsWith("]", StringComparison.Ordinal) + var cleanRoot = root.StartsWith("[", StringComparison.Ordinal) && + root.EndsWith("]", StringComparison.Ordinal) ? root.Substring(1, root.Length - 2) : root; #else diff --git a/QsNet/Internal/Utils.cs b/QsNet/Internal/Utils.cs index fd4f014..4ba9913 100644 --- a/QsNet/Internal/Utils.cs +++ b/QsNet/Internal/Utils.cs @@ -435,9 +435,7 @@ public static string Encode(object? value, Encoding? encoding = null, Format? fo char.IsHighSurrogate(nonNullStr[j + segmentLen - 1]) && char.IsLowSurrogate(nonNullStr[j + segmentLen]) ) - { segmentLen--; // keep the high surrogate with its low surrogate in the next chunk - } var segment = nonNullStr.Substring(j, segmentLen); @@ -487,6 +485,7 @@ public static string Encode(object? value, Encoding? encoding = null, Format? fo i++; continue; } + var nextC = segment[i + 1]; var codePoint = char.ConvertToUtf32((char)c, nextC); buffer.Append(HexTable.Table[0xF0 | (codePoint >> 18)]); @@ -832,7 +831,12 @@ public static string InterpretNumericEntities(string str) { var startDigits = j; var hex = false; - if (str[j] is 'x' or 'X') { hex = true; j++; startDigits = j; } + if (str[j] is 'x' or 'X') + { + hex = true; + j++; + startDigits = j; + } // Advance j over the digit run without allocating per-digit strings while (j < n && (hex ? Uri.IsHexDigit(str[j]) : char.IsDigit(str[j]))) diff --git a/QsNet/Models/DecodeOptions.cs b/QsNet/Models/DecodeOptions.cs index fc33566..a7aaf3d 100644 --- a/QsNet/Models/DecodeOptions.cs +++ b/QsNet/Models/DecodeOptions.cs @@ -13,9 +13,9 @@ namespace QsNet.Models; /// The character encoding to use for decoding, if any. /// The decoded value, or null if the value is not present. /// -/// When this delegate is used to decode keys (e.g., via ), -/// it must return either a or ; returning any other -/// type will cause at the call site. +/// When this delegate is used to decode keys (e.g., via ), +/// it must return either a or ; returning any other +/// type will cause at the call site. /// public delegate object? Decoder(string? value, Encoding? encoding); @@ -28,9 +28,9 @@ namespace QsNet.Models; /// Whether this token is a or . /// The decoded value, or null if the value is not present. /// -/// When is , the decoder must return a -/// or . Returning any other type will cause -/// at the call site (see ). +/// When is , the decoder must return a +/// or . Returning any other type will cause +/// at the call site (see ). /// public delegate object? KindAwareDecoder(string? value, Encoding? encoding, DecodeKind kind); diff --git a/QsNet/Qs.cs b/QsNet/Qs.cs index e682354..7e65fb8 100644 --- a/QsNet/Qs.cs +++ b/QsNet/Qs.cs @@ -1,9 +1,9 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Text; -using System.Globalization; using QsNet.Enums; using QsNet.Internal; using QsNet.Models; diff --git a/QsNet/QsNet.csproj b/QsNet/QsNet.csproj index ce8e61e..0bb45eb 100644 --- a/QsNet/QsNet.csproj +++ b/QsNet/QsNet.csproj @@ -46,6 +46,6 @@ - + From acd1faa4c6719a675dbc04aaa9cb69ef11769495 Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 26 Aug 2025 22:10:19 +0100 Subject: [PATCH 34/36] :bulb: add commented template for enabling ImplicitUsings in future target frameworks --- QsNet/QsNet.csproj | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/QsNet/QsNet.csproj b/QsNet/QsNet.csproj index 0bb45eb..b3a1255 100644 --- a/QsNet/QsNet.csproj +++ b/QsNet/QsNet.csproj @@ -39,6 +39,10 @@ enable + + + + From ade83a2c8163e2752268e0cf61bae7dc4f8944ba Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 26 Aug 2025 22:25:30 +0100 Subject: [PATCH 35/36] :white_check_mark: update decode tests for comma-split truncation, bracketed single occurrence, and encoded dot splitting behavior --- QsNet.Tests/DecodeTests.cs | 43 ++++++++++++++++++++++++++++---------- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/QsNet.Tests/DecodeTests.cs b/QsNet.Tests/DecodeTests.cs index f96d55e..5c773ca 100644 --- a/QsNet.Tests/DecodeTests.cs +++ b/QsNet.Tests/DecodeTests.cs @@ -4129,28 +4129,49 @@ public void Decode_NonGeneric_Hashtable_Is_Normalised() } [Fact] - public void Decode_CommaSplit_TruncatesWhenSumExceedsLimit_AndThrowOff() + public void Decode_CommaSplit_NoTruncationWhenSumExceedsLimit_AndThrowOff() { var opts = new DecodeOptions - { Comma = true, ListLimit = 3, ThrowOnLimitExceeded = false, Duplicates = Duplicates.Combine }; + { + Comma = true, + ListLimit = 3, + ThrowOnLimitExceeded = false, + ParseLists = true, + Duplicates = Duplicates.Combine + }; + var result = Qs.Decode("a=1,2&a=3,4,5", opts); - // Define expected behavior explicitly: either first 3, or last 3, or exactly how ParseListValue truncates upstream. - // Assert accordingly once decided. + + var dict = Assert.IsType>(result); + var list = Assert.IsType>(dict["a"]); + // With ThrowOnLimitExceeded = false, no truncation occurs; full concatenation is allowed + list.Select(x => x?.ToString()).Should().Equal("1", "2", "3", "4", "5"); } [Fact] - public void Decode_BracketSingle_CommaSplit_DefinesSingleOccurrenceBehavior() + public void Decode_BracketSingle_CommaSplit_YieldsNestedList() { var opts = new DecodeOptions { Comma = true }; - var res = Qs.Decode("a=1,2,3", opts); // control - var res2 = Qs.Decode("a[]=1,2,3", opts); // bracketed - // Decide and assert: flat ["1","2","3"] or nested [["1","2","3"]] + + // Control: unbracketed key + var res = Qs.Decode("a=1,2,3", opts); + var dict1 = Assert.IsType>(res); + var list1 = Assert.IsType>(dict1["a"]); + list1.Select(x => x?.ToString()).Should().Equal("1", "2", "3"); + + // Bracketed single occurrence yields a nested list: [["1","2","3"]] + var res2 = Qs.Decode("a[]=1,2,3", opts); + var dict2 = Assert.IsType>(res2); + var outer = Assert.IsType>(dict2["a"]); + outer.Should().HaveCount(1); + var inner = Assert.IsType>(outer[0]); + inner.Select(x => x?.ToString()).Should().Equal("1", "2", "3"); } #region Encoded dot behavior in keys (%2E / %2e) [Fact] - public void EncodedDot_TopLevel_AllowDotsTrue_DecodeDotInKeysTrue_PlainDotSplits_EncodedDotDoesNotSplit() + public void EncodedDot_TopLevel_AllowDotsTrue_DecodeDotInKeysTrue_PlainAndEncodedDotSplit() { var opt = new DecodeOptions { AllowDots = true, DecodeDotInKeys = true }; @@ -4177,7 +4198,7 @@ public void EncodedDot_TopLevel_AllowDotsTrue_DecodeDotInKeysTrue_PlainDotSplits } [Fact] - public void EncodedDot_TopLevel_AllowDotsTrue_DecodeDotInKeysFalse_EncodedDotRemainsPercentSequence() + public void EncodedDot_TopLevel_AllowDotsTrue_DecodeDotInKeysFalse_EncodedDotAlsoSplits() { var opt = new DecodeOptions { AllowDots = true, DecodeDotInKeys = false }; @@ -4225,7 +4246,7 @@ public void EncodedDot_BracketSegment_MapsToDot_WhenDecodeDotInKeysTrue() } [Fact] - public void EncodedDot_BracketSegment_RemainsPercentSequence_WhenDecodeDotInKeysFalse() + public void EncodedDot_BracketSegment_DecodesToDot_WhenDecodeDotInKeysFalse() { var opt = new DecodeOptions { AllowDots = true, DecodeDotInKeys = false }; From 5c6804ac62fb4a127e1e2502857d523ff471a8fc Mon Sep 17 00:00:00 2001 From: Klemen Tusar Date: Tue, 26 Aug 2025 22:40:51 +0100 Subject: [PATCH 36/36] :white_check_mark: update decode tests to assert exception messages for DecodeDotInKeys and AllowDots options --- QsNet.Tests/DecodeTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/QsNet.Tests/DecodeTests.cs b/QsNet.Tests/DecodeTests.cs index 5c773ca..13f452e 100644 --- a/QsNet.Tests/DecodeTests.cs +++ b/QsNet.Tests/DecodeTests.cs @@ -4391,7 +4391,8 @@ public void MixedCase_EncodedBrackets_EncodedDot_AllowDotsFalse_DecodeDotInKeysT 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*"); + .WithMessage("*DecodeDotInKeys*AllowDots*") + .WithMessage("*DecodeDotInKeys=true*AllowDots=true*"); } [Fact]