diff --git a/QsNet.Comparison/js/package.json b/QsNet.Comparison/js/package.json index bc38bc5..43215c2 100644 --- a/QsNet.Comparison/js/package.json +++ b/QsNet.Comparison/js/package.json @@ -5,6 +5,6 @@ "author": "Klemen Tusar", "license": "BSD-3-Clause", "dependencies": { - "qs": "^6.14.0" + "qs": "^6.14.1" } } diff --git a/QsNet.Tests/DecodeTests.cs b/QsNet.Tests/DecodeTests.cs index 37a3b4a..16bbe91 100644 --- a/QsNet.Tests/DecodeTests.cs +++ b/QsNet.Tests/DecodeTests.cs @@ -752,7 +752,7 @@ public void Decode_ParsesMixOfSimpleAndExplicitLists() .BeEquivalentTo( new Dictionary { - ["a"] = new List { "b", "c" } + ["a"] = new Dictionary { ["0"] = "b", ["1"] = "c" } } ); @@ -779,7 +779,7 @@ public void Decode_ParsesMixOfSimpleAndExplicitLists() .BeEquivalentTo( new Dictionary { - ["a"] = new List { "b", "c" } + ["a"] = new Dictionary { ["0"] = "b", ["1"] = "c" } } ); @@ -1295,7 +1295,13 @@ public void Decode_AllowsEmptyStringsInLists() .BeEquivalentTo( new Dictionary { - ["a"] = new List { "b", null, "c", "" } + ["a"] = new Dictionary + { + ["0"] = "b", + ["1"] = null, + ["2"] = "c", + ["3"] = "" + } } ); @@ -1319,7 +1325,13 @@ public void Decode_AllowsEmptyStringsInLists() .BeEquivalentTo( new Dictionary { - ["a"] = new List { "b", "", "c", null } + ["a"] = new Dictionary + { + ["0"] = "b", + ["1"] = "", + ["2"] = "c", + ["3"] = null + } } ); @@ -1434,7 +1446,8 @@ public void Decode_ParsesBuffersCorrectly() [Fact] public void Decode_ParsesJqueryParamStrings() { - const string encoded = "filter%5B0%5D%5B%5D=int1&filter%5B0%5D%5B%5D=%3D&filter%5B0%5D%5B%5D=77&filter%5B%5D=and&filter%5B2%5D%5B%5D=int2&filter%5B2%5D%5B%5D=%3D&filter%5B2%5D%5B%5D=8"; + const string encoded = + "filter%5B0%5D%5B%5D=int1&filter%5B0%5D%5B%5D=%3D&filter%5B0%5D%5B%5D=77&filter%5B%5D=and&filter%5B2%5D%5B%5D=int2&filter%5B2%5D%5B%5D=%3D&filter%5B2%5D%5B%5D=8"; var expected = new Dictionary { ["filter"] = new List @@ -2560,7 +2573,83 @@ public void Decode_ListLimit_HandlesListLimitOfZeroCorrectly() .BeEquivalentTo( new Dictionary { - ["a"] = new List { "1", "2" } + ["a"] = new Dictionary { ["0"] = "1", ["1"] = "2" } + } + ); + } + + [Fact] + public void Decode_ListLimit_RespectsImplicitArrayLimit() + { + var values = Enumerable.Repeat("x", 105).ToArray(); + var attack = "a[]=" + string.Join("&a[]=", values); + var result = Qs.Decode(attack, new DecodeOptions { ListLimit = 100 }); + + result.Should().ContainKey("a"); + result["a"].Should().BeAssignableTo() + .Which.Count.Should().Be(105); + } + + [Fact] + public void Decode_ListLimit_BoundaryConditions() + { + Qs.Decode("a[]=1&a[]=2&a[]=3", new DecodeOptions { ListLimit = 3 }) + .Should() + .BeEquivalentTo( + new Dictionary + { + ["a"] = new List { "1", "2", "3" } + } + ); + + Qs.Decode("a[]=1&a[]=2&a[]=3&a[]=4", new DecodeOptions { ListLimit = 3 }) + .Should() + .BeEquivalentTo( + new Dictionary + { + ["a"] = new Dictionary + { + ["0"] = "1", + ["1"] = "2", + ["2"] = "3", + ["3"] = "4" + } + } + ); + + Qs.Decode("a[]=1&a[]=2", new DecodeOptions { ListLimit = 1 }) + .Should() + .BeEquivalentTo( + new Dictionary + { + ["a"] = new Dictionary { ["0"] = "1", ["1"] = "2" } + } + ); + } + + [Fact] + public void Decode_ListLimit_ConvertsDuplicateValuesWhenLimitExceeded() + { + Qs.Decode("a=b&a=c&a=d", new DecodeOptions { ListLimit = 20 }) + .Should() + .BeEquivalentTo( + new Dictionary + { + ["a"] = new List { "b", "c", "d" } + } + ); + + Qs.Decode("a=b&a=c&a=d", new DecodeOptions { ListLimit = 2 }) + .Should() + .BeEquivalentTo( + new Dictionary + { + ["a"] = new Dictionary + { + ["0"] = "b", + ["1"] = "c", + ["2"] = "d" + } } ); } @@ -2915,7 +3004,7 @@ public void ShouldParseAMixOfSimpleAndExplicitArrays() .BeEquivalentTo( new Dictionary { - ["a"] = new List { "b", "c" } + ["a"] = new Dictionary { ["0"] = "b", ["1"] = "c" } } ); @@ -2933,7 +3022,7 @@ public void ShouldParseAMixOfSimpleAndExplicitArrays() .BeEquivalentTo( new Dictionary { - ["a"] = new List { "b", "c" } + ["a"] = new Dictionary { ["0"] = "b", ["1"] = "c" } } ); } @@ -3423,7 +3512,13 @@ public void ShouldAllowForEmptyStringsInArrays() .BeEquivalentTo( new Dictionary { - ["a"] = new List { "b", null, "c", "" } + ["a"] = new Dictionary + { + ["0"] = "b", + ["1"] = null, + ["2"] = "c", + ["3"] = "" + } } ); @@ -3441,7 +3536,13 @@ public void ShouldAllowForEmptyStringsInArrays() .BeEquivalentTo( new Dictionary { - ["a"] = new List { "b", "", "c", null } + ["a"] = new Dictionary + { + ["0"] = "b", + ["1"] = "", + ["2"] = "c", + ["3"] = null + } } ); @@ -3450,7 +3551,12 @@ public void ShouldAllowForEmptyStringsInArrays() .BeEquivalentTo( new Dictionary { - ["a"] = new List { "", "b", "c" } + ["a"] = new Dictionary + { + ["0"] = "", + ["1"] = "b", + ["2"] = "c" + } } ); } @@ -4178,9 +4284,19 @@ public void Decode_CommaSplit_NoTruncationWhenSumExceedsLimit_AndThrowOff() 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"); + var list = Assert.IsType>(dict["a"]); + // With ThrowOnLimitExceeded = false, values are preserved but list limit still converts to a map. + list.Should() + .BeEquivalentTo( + new Dictionary + { + ["0"] = "1", + ["1"] = "2", + ["2"] = "3", + ["3"] = "4", + ["4"] = "5" + } + ); } [Fact] @@ -4821,4 +4937,4 @@ public void Decode_CommaSplit_ThrowsWhenSumExceedsLimitAndThrowOn() private static partial Regex MyRegex(); #endregion -} +} \ No newline at end of file diff --git a/QsNet.Tests/UtilsTests.cs b/QsNet.Tests/UtilsTests.cs index 2a5e94d..9beee35 100644 --- a/QsNet.Tests/UtilsTests.cs +++ b/QsNet.Tests/UtilsTests.cs @@ -1233,6 +1233,223 @@ public void Combine_ListAndScalarPreservesOrder() .BeEquivalentTo(new List { 1, 2, 3 }); } + [Fact] + public void Combine_WithListLimit_UnderAndOverLimit() + { + var under = Utils.CombineWithLimit( + new List { "a", "b" }, + "c", + new DecodeOptions { ListLimit = 10 } + ); + under.Should().BeOfType>(); + under.Should().BeEquivalentTo(new List { "a", "b", "c" }); + + var atLimit = Utils.CombineWithLimit( + new List { "a", "b" }, + "c", + new DecodeOptions { ListLimit = 3 } + ); + atLimit.Should().BeOfType>(); + atLimit.Should().BeEquivalentTo(new List { "a", "b", "c" }); + + var over = Utils.CombineWithLimit( + new List { "a", "b", "c" }, + "d", + new DecodeOptions { ListLimit = 3 } + ); + over.Should().BeOfType>(); + over.Should() + .BeEquivalentTo( + new Dictionary + { + ["0"] = "a", + ["1"] = "b", + ["2"] = "c", + ["3"] = "d" + } + ); + Utils.IsOverflow(over).Should().BeTrue(); + } + + [Fact] + public void Combine_WithListLimit_ZeroConvertsToMap() + { + var combined = Utils.CombineWithLimit( + new List(), + "a", + new DecodeOptions { ListLimit = 0 } + ); + combined.Should().BeOfType>(); + combined.Should().BeEquivalentTo(new Dictionary { ["0"] = "a" }); + } + + [Fact] + public void Combine_WithOverflowObject_AppendsAtNextIndex() + { + var overflow = Utils.CombineWithLimit( + new List { "a" }, + "b", + new DecodeOptions { ListLimit = 1 } + ); + Utils.IsOverflow(overflow).Should().BeTrue(); + + var combined = Utils.CombineWithLimit(overflow, "c", new DecodeOptions { ListLimit = 10 }); + combined.Should().BeSameAs(overflow); + combined.Should() + .BeEquivalentTo( + new Dictionary + { + ["0"] = "a", + ["1"] = "b", + ["2"] = "c" + } + ); + } + + [Fact] + public void Combine_WithPlainMap_DoesNotUseOverflowBehavior() + { + var plain = new Dictionary { ["0"] = "a", ["1"] = "b" }; + Utils.IsOverflow(plain).Should().BeFalse(); + + var combined = Utils.CombineWithLimit(plain, "c", new DecodeOptions { ListLimit = 10 }); + combined.Should().BeEquivalentTo(new List { plain, "c" }); + } + + [Fact] + public void Merge_WithOverflowObject_AppendsAtNextIndex() + { + var overflow = Utils.CombineWithLimit( + new List { "a" }, + "b", + new DecodeOptions { ListLimit = 1 } + ); + Utils.IsOverflow(overflow).Should().BeTrue(); + + var merged = Utils.Merge(overflow, "c"); + merged.Should() + .BeEquivalentTo( + new Dictionary + { + ["0"] = "a", + ["1"] = "b", + ["2"] = "c" + } + ); + Utils.IsOverflow(merged).Should().BeTrue(); + } + + [Fact] + public void Merge_WithPlainMap_UsesValueAsKey() + { + var obj = new Dictionary { ["0"] = "a", ["1"] = "b" }; + Utils.IsOverflow(obj).Should().BeFalse(); + + var merged = Utils.Merge(obj, "c"); + merged.Should() + .BeEquivalentTo( + new Dictionary + { + ["0"] = "a", + ["1"] = "b", + ["c"] = true + } + ); + } + + [Fact] + public void Merge_OverflowObjectIntoPrimitive_ShiftsIndices() + { + var overflow = Utils.CombineWithLimit( + new List { "b" }, + "c", + new DecodeOptions { ListLimit = 1 } + ); + Utils.IsOverflow(overflow).Should().BeTrue(); + + var merged = Utils.Merge("a", overflow); + merged.Should() + .BeEquivalentTo( + new Dictionary + { + ["0"] = "a", + ["1"] = "b", + ["2"] = "c" + } + ); + Utils.IsOverflow(merged).Should().BeTrue(); + } + + [Fact] + public void Merge_OverflowRespectsExistingNumericKeys() + { + var options = new DecodeOptions { ListLimit = 1 }; + var overflow = Utils.CombineWithLimit(new List { "a" }, "b", options); + Utils.IsOverflow(overflow).Should().BeTrue(); + + var target = new Dictionary { ["5"] = "x" }; + var merged = Utils.Merge(target, overflow); + Utils.IsOverflow(merged).Should().BeTrue(); + + var appended = Utils.CombineWithLimit(merged, "c", options); + appended.Should() + .BeEquivalentTo( + new Dictionary + { + ["0"] = "a", + ["1"] = "b", + ["5"] = "x", + ["6"] = "c" + } + ); + } + + [Fact] + public void Merge_OverflowTracksIntegerKeys() + { + var options = new DecodeOptions { ListLimit = 1 }; + var overflow = Utils.CombineWithLimit(new List { "a" }, "b", options); + + var target = new Dictionary { [5] = "x" }; + var merged = Utils.Merge(target, overflow); + Utils.IsOverflow(merged).Should().BeTrue(); + + var appended = Utils.CombineWithLimit(merged, "c", options); + appended.Should() + .BeEquivalentTo( + new Dictionary + { + ["0"] = "a", + ["1"] = "b", + [5] = "x", + ["6"] = "c" + } + ); + } + + [Fact] + public void Merge_OverflowIgnoresNonCanonicalStringIndices() + { + var options = new DecodeOptions { ListLimit = 1 }; + var overflow = Utils.CombineWithLimit(new List { "a" }, "b", options); + + var target = new Dictionary { ["010"] = "x" }; + var merged = Utils.Merge(target, overflow); + Utils.IsOverflow(merged).Should().BeTrue(); + + var appended = Utils.CombineWithLimit(merged, "c", options); + appended.Should() + .BeEquivalentTo( + new Dictionary + { + ["0"] = "a", + ["1"] = "b", + ["010"] = "x", + ["2"] = "c" + } + ); + } + [Fact] public void InterpretNumericEntities_ReturnsInputUnchangedWhenThereAreNoEntities() { diff --git a/QsNet/Internal/Decoder.cs b/QsNet/Internal/Decoder.cs index b6091dc..fc18985 100644 --- a/QsNet/Internal/Decoder.cs +++ b/QsNet/Internal/Decoder.cs @@ -253,7 +253,7 @@ int currentListLength switch (options.Duplicates) { case Duplicates.Combine: - obj[key] = Utils.Combine(existingVal, value); + obj[key] = Utils.CombineWithLimit(existingVal, value, options); break; case Duplicates.Last: obj[key] = value; @@ -352,13 +352,20 @@ bool valuesParsed } else { - if ( - options.AllowEmptyLists - && (leaf?.Equals("") == true || (options.StrictNullHandling && leaf == null)) - ) - obj = new List(); + if (Utils.IsOverflow(leaf)) + { + obj = leaf; + } else - obj = Utils.Combine(new List(), leaf); + { + if ( + options.AllowEmptyLists + && (leaf?.Equals("") == true || (options.StrictNullHandling && leaf == null)) + ) + obj = new List(); + else + obj = Utils.CombineWithLimit(new List(), leaf, options); + } } } else @@ -758,4 +765,4 @@ private static string JoinAsCommaSeparatedStrings(IEnumerable enumerable) return sb?.ToString() ?? string.Empty; } -} \ No newline at end of file +} diff --git a/QsNet/Internal/Utils.cs b/QsNet/Internal/Utils.cs index e52d4b6..1f5444f 100644 --- a/QsNet/Internal/Utils.cs +++ b/QsNet/Internal/Utils.cs @@ -27,6 +27,13 @@ internal static partial class Utils /// private const int SegmentLimit = 1024; + private sealed class OverflowState + { + public int MaxIndex { get; set; } + } + + private static readonly ConditionalWeakTable OverflowTable = new(); + /// /// A regex to match percent-encoded characters in the format %XX. /// @@ -179,6 +186,37 @@ private static Regex MyRegex1() case IDictionary targetMap: { var mutable = ToDictionary(targetMap); + var targetMapOverflow = IsOverflow(targetMap); + if (targetMapOverflow && !ReferenceEquals(mutable, targetMap)) + SetOverflowMaxIndex(mutable, GetOverflowMaxIndex(targetMap)); + + if (targetMapOverflow) + { + var targetMaxIndex = GetOverflowMaxIndex(mutable); + switch (source) + { + case IEnumerable srcIter: + { + var i = targetMaxIndex + 1; + foreach (var item in srcIter) + { + if (item is not Undefined) + mutable[i.ToString(CultureInfo.InvariantCulture)] = item; + i++; + } + + SetOverflowMaxIndex(mutable, i - 1); + return mutable; + } + case Undefined: + return mutable; + } + + var nextIndex = targetMaxIndex + 1; + mutable[nextIndex.ToString(CultureInfo.InvariantCulture)] = source; + SetOverflowMaxIndex(mutable, nextIndex); + return mutable; + } switch (source) { @@ -217,11 +255,19 @@ private static Regex MyRegex1() // Source IS a map var sourceMap = (IDictionary)source; // iterate the original map + var sourceOverflow = IsOverflow(sourceMap); Dictionary mergeTarget; + var initialMaxIndex = -1; + var targetOverflow = false; switch (target) { case IDictionary tmap: mergeTarget = ToDictionary(tmap); + targetOverflow = IsOverflow(tmap); + if (targetOverflow && !ReferenceEquals(mergeTarget, tmap)) + SetOverflowMaxIndex(mergeTarget, GetOverflowMaxIndex(tmap)); + if (targetOverflow) + initialMaxIndex = GetOverflowMaxIndex(mergeTarget); break; case IEnumerable tEnum: @@ -237,23 +283,59 @@ private static Regex MyRegex1() } mergeTarget = dict; + if (i > 0) + initialMaxIndex = i - 1; break; } default: { if (target is null or Undefined) - return NormalizeForTarget((IDictionary)source); + { + var normalized = NormalizeForTarget(sourceMap); + if (sourceOverflow && normalized is IDictionary normalizedMap) + SetOverflowMaxIndex(normalizedMap, GetOverflowMaxIndex(sourceMap)); + return normalized; + } + + if (sourceOverflow) + { + var result = new Dictionary(sourceMap.Count + 1) + { + ["0"] = target + }; + foreach (DictionaryEntry entry in sourceMap) + { + if (TryGetArrayIndex(entry.Key, out var idx)) + result[(idx + 1).ToString(CultureInfo.InvariantCulture)] = entry.Value; + else + result[entry.Key] = entry.Value; + } + + var sourceMaxIndex = GetOverflowMaxIndex(sourceMap); + SetOverflowMaxIndex(result, sourceMaxIndex >= 0 ? sourceMaxIndex + 1 : 0); + return result; + } var list = new List { target, - ToObjectKeyedDictionary((IDictionary)source) + ToObjectKeyedDictionary(sourceMap) }; return list; } } + var trackOverflow = targetOverflow || sourceOverflow; + var maxIndex = trackOverflow ? initialMaxIndex : -1; + if (trackOverflow) + { + if (!targetOverflow) + maxIndex = Math.Max(maxIndex, GetMaxIndexFromMap(mergeTarget)); + if (sourceOverflow) + maxIndex = Math.Max(maxIndex, GetOverflowMaxIndex(sourceMap)); + } + foreach (DictionaryEntry entry in sourceMap) { var key = entry.Key; @@ -262,8 +344,14 @@ private static Regex MyRegex1() mergeTarget[key] = mergeTarget.TryGetValue(key, out var existing) ? Merge(existing, value, options) : value; + + if (trackOverflow && TryGetArrayIndex(key, out var idx) && idx > maxIndex) + maxIndex = idx; } + if (trackOverflow) + SetOverflowMaxIndex(mergeTarget, maxIndex); + return mergeTarget; } @@ -722,6 +810,88 @@ void AddOne(object? x) } } + internal static bool IsOverflow(object? obj) + { + return obj is not null && OverflowTable.TryGetValue(obj, out _); + } + + private static int GetOverflowMaxIndex(object obj) + { + return OverflowTable.TryGetValue(obj, out var state) ? state.MaxIndex : -1; + } + + private static void SetOverflowMaxIndex(object obj, int maxIndex) + { + OverflowTable.GetOrCreateValue(obj).MaxIndex = maxIndex; + } + + private static object MarkOverflow(object obj, int maxIndex) + { + SetOverflowMaxIndex(obj, maxIndex); + return obj; + } + + private static bool TryGetArrayIndex(object key, out int index) + { + switch (key) + { + case int i when i >= 0: + index = i; + return true; + case string s + when int.TryParse( + s, + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out var parsed + ) + && parsed >= 0 + && parsed.ToString(CultureInfo.InvariantCulture) == s: + index = parsed; + return true; + default: + index = -1; + return false; + } + } + + private static int GetMaxIndexFromMap(IDictionary map) + { + var maxIndex = -1; + foreach (DictionaryEntry entry in map) + if (TryGetArrayIndex(entry.Key, out var idx) && idx > maxIndex) + maxIndex = idx; + return maxIndex; + } + + internal static object CombineWithLimit(object? a, object? b, DecodeOptions options) + { + if (options.ListLimit < 0) + return Combine(a, b); + + if (IsOverflow(a)) + { + var target = (IDictionary)a!; + var nextIndex = GetOverflowMaxIndex(target) + 1; + target[nextIndex.ToString(CultureInfo.InvariantCulture)] = b; + SetOverflowMaxIndex(target, nextIndex); + return target; + } + + var combined = Combine(a, b); + return combined.Count > options.ListLimit + ? MarkOverflow(ListToIndexMap(combined), combined.Count - 1) + : combined; + } + + private static Dictionary ListToIndexMap(List list) + { + var map = new Dictionary(list.Count); + for (var i = 0; i < list.Count; i++) + map[i.ToString(CultureInfo.InvariantCulture)] = list[i]; + return map; + } + /// /// Applies a function to a value or each element in an IEnumerable. /// @@ -1246,4 +1416,4 @@ public int GetHashCode(object obj) { return RuntimeHelpers.GetHashCode(obj); } -} \ No newline at end of file +}