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)); } diff --git a/QsNet.Tests/DecodeTests.cs b/QsNet.Tests/DecodeTests.cs index e334f34..13f452e 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; @@ -272,7 +273,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 +2278,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 +2287,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 +2296,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 +2305,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 +2406,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 +2484,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 +2531,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 +2540,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] @@ -4127,10 +4128,50 @@ public void Decode_NonGeneric_Hashtable_Is_Normalised() decoded.Should().Equal(new Dictionary { ["x"] = 1, ["2"] = "y" }); } + [Fact] + public void Decode_CommaSplit_NoTruncationWhenSumExceedsLimit_AndThrowOff() + { + var opts = new DecodeOptions + { + Comma = true, + ListLimit = 3, + ThrowOnLimitExceeded = false, + ParseLists = true, + Duplicates = Duplicates.Combine + }; + + var result = Qs.Decode("a=1,2&a=3,4,5", opts); + + 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_YieldsNestedList() + { + var opts = new DecodeOptions { Comma = true }; + + // 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 }; @@ -4157,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 }; @@ -4181,7 +4222,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] @@ -4205,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 }; @@ -4349,8 +4390,9 @@ 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*"); + act.Should().Throw() + .WithMessage("*DecodeDotInKeys*AllowDots*") + .WithMessage("*DecodeDotInKeys=true*AllowDots=true*"); } [Fact] @@ -4646,7 +4688,7 @@ public void StrictDepthOverflow_RaisesForWellFormed() { var act = () => InternalDecoder.SplitKeyIntoSegments("a[b][c][d]", false, 1, true); - act.Should().Throw(); + act.Should().Throw(); } [Fact] @@ -4681,4 +4723,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 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] 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 8503dc7..3aba058 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,62 @@ 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_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() + { + 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() { @@ -1567,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 diff --git a/QsNet/Internal/Decoder.cs b/QsNet/Internal/Decoder.cs index e3f71ef..6d29bbe 100644 --- a/QsNet/Internal/Decoder.cs +++ b/QsNet/Internal/Decoder.cs @@ -1,6 +1,7 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Text; using QsNet.Enums; @@ -17,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 @@ -48,15 +49,15 @@ 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) - throw new IndexOutOfRangeException( + 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." ); 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." ); @@ -70,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 @@ -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)) @@ -220,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 @@ -234,27 +239,29 @@ bool valuesParsed chain[^1] == "[]" #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, 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); if (leaf is IDictionary id and not Dictionary) @@ -292,16 +299,22 @@ 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 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; @@ -317,7 +330,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 @@ -340,9 +352,10 @@ 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() == decodedRoot; + isPureNumeric && root != decodedRoot && idx.ToString(CultureInfo.InvariantCulture) == decodedRoot; if (!options.ParseLists || options.ListLimit < 0) { @@ -421,8 +434,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; @@ -486,7 +504,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, @@ -569,7 +587,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 +616,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 + "]"); diff --git a/QsNet/Internal/Encoder.cs b/QsNet/Internal/Encoder.cs index 9132a67..dd08133 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,8 +370,13 @@ 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) encodedKey = keyStr.Replace(".", "%2E"); +#else + if (allowDots && encodeDotInKeys && keyStr.Contains('.', StringComparison.Ordinal)) + encodedKey = keyStr.Replace(".", "%2E", StringComparison.Ordinal); +#endif var keyPrefix = obj is IEnumerable and not string and not IDictionary diff --git a/QsNet/Internal/Utils.cs b/QsNet/Internal/Utils.cs index db5144b..4ba9913 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,10 @@ 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 +408,10 @@ 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"; } @@ -422,10 +424,20 @@ 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) @@ -463,9 +475,19 @@ 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)]); @@ -473,7 +495,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(); @@ -787,9 +809,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; @@ -801,18 +827,49 @@ 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') { - code = code * 10 + (str[j] - '0'); + 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]))) + 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: @@ -1119,7 +1176,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 +1217,6 @@ private static object NormalizeForTarget(IDictionary map) dstList.Add(item); break; } - } break; } diff --git a/QsNet/Models/DecodeOptions.cs b/QsNet/Models/DecodeOptions.cs index 9643bae..a7aaf3d 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); @@ -197,9 +203,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 +241,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); } diff --git a/QsNet/Qs.cs b/QsNet/Qs.cs index a12e6c7..7e65fb8 100644 --- a/QsNet/Qs.cs +++ b/QsNet/Qs.cs @@ -1,6 +1,7 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Text; using QsNet.Enums; @@ -25,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(); @@ -121,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(); @@ -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(joined.Length > 0 ? "&" : ""); else if (opts.Charset.WebName.Equals("utf-8", StringComparison.OrdinalIgnoreCase)) - sb.Append($"{Sentinel.Charset.GetEncoded()}&"); + sb.Append(Sentinel.Charset.GetEncoded()).Append(joined.Length > 0 ? "&" : ""); } 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; } } diff --git a/QsNet/QsNet.csproj b/QsNet/QsNet.csproj index c498297..b3a1255 100644 --- a/QsNet/QsNet.csproj +++ b/QsNet/QsNet.csproj @@ -7,17 +7,49 @@ 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 + true + + See CHANGELOG: https://github.com/techouse/qs-net/releases + QsNet - Query-string encoding/decoding for .NET + + + enable + + + + + + + + +