diff --git a/lib/src/extensions/decode.dart b/lib/src/extensions/decode.dart index 35d42f6..dd45ee6 100644 --- a/lib/src/extensions/decode.dart +++ b/lib/src/extensions/decode.dart @@ -27,10 +27,12 @@ part of '../qs.dart'; /// string → structure pipeline used by `QS.decode`. extension _$Decode on QS { /// Interprets a single scalar value as a list element when the `comma` - /// option is enabled and a comma is present, and enforces `listLimit`. + /// option is enabled and a comma is present. /// - /// If `throwOnLimitExceeded` is true, exceeding `listLimit` will throw a - /// `RangeError`; otherwise the caller can decide how to degrade. + /// For comma-splits with non-negative `listLimit`, this method returns the + /// full split result and lets `_parseQueryStringValues` decide whether to + /// throw or convert to an overflow map. For negative `listLimit`, legacy + /// Dart-port semantics are preserved here. /// /// The `currentListLength` is used to guard incremental growth when we are /// already building a list for a given key path. @@ -39,8 +41,8 @@ extension _$Decode on QS { /// elsewhere (e.g. `[2]` segments become string keys). For comma‑splits specifically: /// when `throwOnLimitExceeded` is `true` and `listLimit < 0`, any non‑empty split throws /// immediately; when `false`, growth is effectively capped at zero (the split produces - /// an empty list). Empty‑bracket pushes (`a[]=`) are handled during structure building - /// in `_parseObject`. + /// an empty list). Empty‑bracket pushes (`a[]=`) are enforced in + /// `_parseQueryStringValues`. static dynamic _parseListValue( dynamic val, DecodeOptions options, @@ -50,28 +52,23 @@ extension _$Decode on QS { if (val is String && val.isNotEmpty && options.comma && val.contains(',')) { final List splitVal = val.split(','); if (options.throwOnLimitExceeded && - (currentListLength + splitVal.length) > options.listLimit) { - final String msg = options.listLimit < 0 - ? 'List parsing is disabled (listLimit < 0).' - : 'List limit exceeded. Only ${options.listLimit} ' - 'element${options.listLimit == 1 ? '' : 's'} allowed in a list.'; - throw RangeError(msg); + options.listLimit < 0 && + splitVal.isNotEmpty) { + throw RangeError('List parsing is disabled (listLimit < 0).'); } - final int remaining = options.listLimit - currentListLength; - if (remaining <= 0) return const []; - return splitVal.length <= remaining - ? splitVal - : splitVal.sublist(0, remaining); + if (options.listLimit < 0) { + // For negative list limits, comma growth is always capped at zero + // (with currentListLength always >= 0 at call sites). + return const []; + } + return splitVal; } // Guard incremental growth of an existing list as we parse additional items. if (options.throwOnLimitExceeded && + options.listLimit >= 0 && currentListLength >= options.listLimit) { - final String msg = options.listLimit < 0 - ? 'List parsing is disabled (listLimit < 0).' - : 'List limit exceeded. Only ${options.listLimit} ' - 'element${options.listLimit == 1 ? '' : 's'} allowed in a list.'; - throw RangeError(msg); + throw RangeError(_listLimitExceededMessage(options.listLimit)); } return val; @@ -84,8 +81,9 @@ extension _$Decode on QS { /// - charset sentinel detection (`utf8=`) per `qs` /// - duplicate key policy (combine/first/last) /// - parameter and list limits with optional throwing behavior - /// - Comma‑split growth honors `throwOnLimitExceeded` (see `_parseListValue`); - /// empty‑bracket pushes (`[]=`) are created during structure building in `_parseObject`. + /// - Comma‑split overflow (`listLimit >= 0`) is enforced after decoding: + /// throw in strict mode, otherwise convert to an overflow map. + /// - Empty‑bracket pushes (`[]=`) are created during structure building in `_parseObject`. static Map _parseQueryStringValues( String str, [ DecodeOptions options = const DecodeOptions(), @@ -172,6 +170,18 @@ extension _$Decode on QS { ); } + // Enforce comma-split overflow behavior before any normalization that can + // collapse iterables into scalars (e.g. numeric entity interpretation). + if (options.comma && + options.listLimit >= 0 && + val is Iterable && + val.length > options.listLimit) { + if (options.throwOnLimitExceeded) { + throw RangeError(_listLimitExceededMessage(options.listLimit)); + } + val = Utils.combine([], val, listLimit: options.listLimit); + } + // Optional HTML numeric entity interpretation (legacy Latin-1 queries). if (val != null && !Utils.isEmpty(val) && @@ -179,19 +189,31 @@ extension _$Decode on QS { charset == latin1) { if (val is Iterable) { val = Utils.interpretNumericEntities(_joinIterableToCommaString(val)); - } else { + } else if (!(val is Map && Utils.isOverflow(val))) { val = Utils.interpretNumericEntities(val.toString()); } } // Quirk: a literal `[]=` suffix forces an array container (qs behavior). if (options.parseLists && part.contains('[]=')) { + if (options.throwOnLimitExceeded && options.listLimit < 0) { + throw RangeError('List parsing is disabled (listLimit < 0).'); + } val = [val]; } // Duplicate key policy: combine/first/last (default: combine). final bool existing = obj.containsKey(key); if (existing && options.duplicates == Duplicates.combine) { + if (options.throwOnLimitExceeded && options.listLimit >= 0) { + final int? existingCount = _listLikeCount(obj[key]); + final int? incomingCount = _listLikeCount(val); + if (existingCount != null && + incomingCount != null && + existingCount + incomingCount > options.listLimit) { + throw RangeError(_listLimitExceededMessage(options.listLimit)); + } + } obj[key] = Utils.combine(obj[key], val, listLimit: options.listLimit); } else if (!existing || options.duplicates == Duplicates.last) { obj[key] = val; @@ -201,6 +223,25 @@ extension _$Decode on QS { return obj; } + /// Standard error text used for list limit overflows. + static String _listLimitExceededMessage(int listLimit) => + 'List limit exceeded. Only $listLimit ' + 'element${listLimit == 1 ? '' : 's'} allowed in a list.'; + + /// Returns element count for values that participate in list growth checks. + /// + /// Maps that are not marked as overflow containers are treated as object values + /// and return `null` (not list-like). + static int? _listLikeCount(dynamic value) { + if (value is Iterable) return value.length; + if (value is Map) { + final int? overflowCount = Utils.overflowCount(value); + if (overflowCount != null) return overflowCount; + return null; + } + return 1; + } + /// Reduces a list of key segments (e.g. `["a", "[b]", "[0]"]`) and a /// leaf value into a nested structure. Operates right-to-left, constructing /// maps or lists based on segment content and `DecodeOptions`. diff --git a/lib/src/utils.dart b/lib/src/utils.dart index 1defe05..b419c8b 100644 --- a/lib/src/utils.dart +++ b/lib/src/utils.dart @@ -49,6 +49,13 @@ final class Utils { static bool isOverflow(dynamic obj) => obj is Map && _overflowIndex[obj] != null; + /// Returns tracked overflow length (`maxIndex + 1`) or `null` if not overflow. + @internal + static int? overflowCount(dynamic obj) { + if (!isOverflow(obj) || obj is! Map) return null; + return _getOverflowIndex(obj) + 1; + } + /// Returns the tracked max numeric index for an overflow map, or -1 if absent. static int _getOverflowIndex(Map obj) => _overflowIndex[obj] ?? -1; diff --git a/test/comparison/package.json b/test/comparison/package.json index 43215c2..0342b53 100644 --- a/test/comparison/package.json +++ b/test/comparison/package.json @@ -5,6 +5,6 @@ "author": "Klemen Tusar", "license": "BSD-3-Clause", "dependencies": { - "qs": "^6.14.1" + "qs": "^6.14.2" } } diff --git a/test/unit/decode_test.dart b/test/unit/decode_test.dart index 4b05881..1a5831d 100644 --- a/test/unit/decode_test.dart +++ b/test/unit/decode_test.dart @@ -223,6 +223,39 @@ void main() { ); }); + test('comma: true with negative listLimit returns empty list', () { + expect( + QS.decode( + 'a=1,2', + const DecodeOptions( + comma: true, + listLimit: -1, + ), + ), + equals({'a': []}), + ); + }); + + test('comma: true with negative listLimit throws in strict mode', () { + expect( + () => QS.decode( + 'a=1,2', + const DecodeOptions( + comma: true, + listLimit: -1, + throwOnLimitExceeded: true, + ), + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('List parsing is disabled'), + ), + ), + ); + }); + test('allows enabling dot notation', () { expect(QS.decode('a.b=c'), equals({'a.b': 'c'})); expect( @@ -2015,6 +2048,28 @@ void main() { ); }); + test( + 'negative list limit strict mode still decodes non-list values without growth', + () { + expect( + QS.decode( + 'a=b', + const DecodeOptions(listLimit: -1, throwOnLimitExceeded: true), + ), + equals({'a': 'b'}), + ); + + expect( + QS.decode( + 'a[0]=b', + const DecodeOptions(listLimit: -1, throwOnLimitExceeded: true), + ), + equals({ + 'a': {'0': 'b'} + }), + ); + }); + test('applies list limit to nested lists', () { expect( () => QS.decode( @@ -2913,30 +2968,204 @@ void main() { }); group('Targeted coverage additions', () { - test('comma splitting truncates to remaining list capacity', () { + test('comma splitting at limit stays as list', () { final result = QS.decode( 'a=1,2,3', - const DecodeOptions(comma: true, listLimit: 2), + const DecodeOptions(comma: true, listLimit: 3), ); final Iterable iterable = result['a'] as Iterable; - expect(iterable.toList(), equals(['1', '2'])); + expect(iterable.toList(), equals(['1', '2', '3'])); + }); + + test('comma splitting over limit converts to overflow map', () { + final result = QS.decode( + 'a=1,2,3,4', + const DecodeOptions(comma: true, listLimit: 3), + ); + + final aValue = result['a']; + expect(aValue, { + '0': '1', + '1': '2', + '2': '3', + '3': '4', + }); + expect(Utils.isOverflow(aValue), isTrue); + }); + + test('comma splitting across duplicate keys throws in strict mode', () { + expect( + () => QS.decode( + 'a=1,2&a=3,4', + const DecodeOptions( + comma: true, + listLimit: 3, + throwOnLimitExceeded: true, + ), + ), + throwsA(isA()), + ); + }); + + test('comma splitting across duplicate keys over limit converts to map', + () { + final result = QS.decode( + 'a=1,2&a=3,4', + const DecodeOptions( + comma: true, + listLimit: 3, + ), + ); + + final aValue = result['a']; + expect(aValue, { + '0': '1', + '1': '2', + '2': '3', + '3': '4', + }); + expect(Utils.isOverflow(aValue), isTrue); + }); + + test( + 'strict duplicate limit check counts existing overflow-map values from decoder', + () { + final overflow = Utils.combine([], ['1', '2', '3', '4'], listLimit: 3); + expect(Utils.isOverflow(overflow), isTrue); + + expect( + () => QS.decode( + 'a=first&a=second', + DecodeOptions( + listLimit: 3, + throwOnLimitExceeded: true, + decoder: (value, {charset, kind}) { + if (kind == DecodeKind.value && value == 'first') { + return overflow; + } + return value; + }, + ), + ), + throwsA(isA()), + ); + }); + + test( + 'strict duplicate limit check uses max overflow index for sparse overflow maps', + () { + final sparseOverflow = + Utils.markOverflow({'100': 'x'}, 100); + expect(Utils.isOverflow(sparseOverflow), isTrue); + + expect( + () => QS.decode( + 'a=first&a=second', + DecodeOptions( + listLimit: 3, + throwOnLimitExceeded: true, + decoder: (value, {charset, kind}) { + if (kind == DecodeKind.value && value == 'first') { + return sparseOverflow; + } + return value; + }, + ), + ), + throwsA(isA()), + ); }); test('comma splitting throws when limit exceeded in strict mode', () { expect( () => QS.decode( - 'a=1,2', + 'a=1,2,3,4', + const DecodeOptions( + comma: true, + listLimit: 3, + throwOnLimitExceeded: true, + ), + ), + throwsA(isA()), + ); + }); + + test( + 'comma strict limit still throws with latin1 numeric-entity interpretation', + () { + expect( + () => QS.decode( + 'a=1,2,3,4', const DecodeOptions( comma: true, - listLimit: 1, + listLimit: 3, throwOnLimitExceeded: true, + charset: latin1, + interpretNumericEntities: true, ), ), throwsA(isA()), ); }); + test( + 'comma overflow still converts to map with latin1 numeric-entity interpretation', + () { + final result = QS.decode( + 'a=1,2,3,4', + const DecodeOptions( + comma: true, + listLimit: 3, + charset: latin1, + interpretNumericEntities: true, + ), + ); + + final aValue = result['a']; + expect(aValue, { + '0': '1', + '1': '2', + '2': '3', + '3': '4', + }); + expect(Utils.isOverflow(aValue), isTrue); + }); + + test('GHSA payload throws when limit exceeded in strict mode', () { + final payload = + 'a=${','.padLeft(25, ',')}'; // 25 commas => 26 elements after split. + + expect( + () => QS.decode( + payload, + const DecodeOptions( + comma: true, + listLimit: 5, + throwOnLimitExceeded: true, + ), + ), + throwsA(isA()), + ); + }); + + test('GHSA payload converts to overflow map without throw', () { + final payload = + 'a=${','.padLeft(25, ',')}'; // 25 commas => 26 elements after split. + final result = QS.decode( + payload, + const DecodeOptions( + comma: true, + listLimit: 5, + ), + ); + + final aValue = result['a']; + expect(aValue, isA>()); + expect((aValue as Map).length, 26); + expect(Utils.isOverflow(aValue), isTrue); + }); + test('strict depth throws when additional bracket groups remain', () { expect( () => QS.decode( diff --git a/test/unit/utils_test.dart b/test/unit/utils_test.dart index 2896013..3789447 100644 --- a/test/unit/utils_test.dart +++ b/test/unit/utils_test.dart @@ -956,9 +956,11 @@ void main() { final overflow = Utils.combine(['a'], 'b', listLimit: 1) as Map; expect(Utils.isOverflow(overflow), isTrue); + expect(Utils.overflowCount(overflow), 2); final merged = Utils.merge(overflow, 'c') as Map; expect(merged, {'0': 'a', '1': 'b', '2': 'c'}); + expect(Utils.overflowCount(merged), 3); }); test('merges overflow object into primitive', () { @@ -999,6 +1001,15 @@ void main() { '1': 'b', }); expect(Utils.isOverflow(merged), isFalse); + expect(Utils.overflowCount(merged), isNull); + }); + + test('overflowCount follows tracked max index for sparse overflow maps', + () { + final sparse = Utils.markOverflow({'100': 'x'}, 100); + + expect(Utils.isOverflow(sparse), isTrue); + expect(Utils.overflowCount(sparse), 101); }); }); });