Skip to content
Closed
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
1c5c71c
chore: update `qs` dependency version to ^6.14.2
techouse Feb 16, 2026
e5b09d5
:bug: enhance comma-split behavior to enforce list limits and handle …
techouse Feb 16, 2026
fcf3c20
:white_check_mark: enhance comma splitting behavior to handle overflo…
techouse Feb 16, 2026
133152b
:zap: enhance list limit handling to throw errors for overflow condit…
techouse Feb 17, 2026
a46a0c4
:white_check_mark: enhance comma splitting tests for negative list li…
techouse Feb 17, 2026
788cfe2
:zap: cap comma growth at zero for negative list limits in decoding
techouse Feb 17, 2026
caac169
:zap: enhance comma-split overflow handling to enforce list limits an…
techouse Feb 17, 2026
5018c16
:white_check_mark: enhance comma decoding tests for strict limit hand…
techouse Feb 17, 2026
585b3d4
:zap: streamline error handling for list limit exceeded conditions
techouse Feb 17, 2026
f6b585e
:white_check_mark: add test for strict duplicate limit check using ma…
techouse Feb 17, 2026
f870325
:white_check_mark: format GHSA payload initialization for clarity in …
techouse Feb 17, 2026
13e73ea
:recycle: simplify overflow handling by introducing overflowCount uti…
techouse Feb 17, 2026
74e7975
:white_check_mark: add tests for overflowCount utility in merge and s…
techouse Feb 17, 2026
3db5c81
:recycle: enforce empty-bracket handling in _parseQueryStringValues a…
techouse Feb 17, 2026
cb40ce9
:white_check_mark: add test for negative list limit in strict mode to…
techouse Feb 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 69 additions & 16 deletions lib/src/extensions/decode.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -50,18 +52,16 @@ extension _$Decode on QS {
if (val is String && val.isNotEmpty && options.comma && val.contains(',')) {
final List<String> 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 <String>[];
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 <String>[];
}
return splitVal;
}

// Guard incremental growth of an existing list as we parse additional items.
Expand All @@ -84,8 +84,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<String, dynamic> _parseQueryStringValues(
String str, [
DecodeOptions options = const DecodeOptions(),
Expand Down Expand Up @@ -189,9 +190,34 @@ extension _$Decode on QS {
val = [val];
}

// Enforce comma-split overflow behavior to match Node `qs`:
// - strict mode throws
// - non-strict mode preserves all elements by converting to overflow map
if (options.comma &&
options.listLimit >= 0 &&
val is Iterable &&
val.length > options.listLimit) {
if (options.throwOnLimitExceeded) {
throw RangeError(
'List limit exceeded. Only ${options.listLimit} '
'element${options.listLimit == 1 ? '' : 's'} allowed in a list.',
);
}
val = Utils.combine([], val, listLimit: options.listLimit);
}

// 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;
Expand All @@ -201,6 +227,33 @@ 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) {
if (Utils.isOverflow(value)) {
int maxIndex = -1;
for (final dynamic key in value.keys) {
final int? parsed = int.tryParse(key.toString());
if (parsed != null && parsed >= 0 && parsed > maxIndex) {
maxIndex = parsed;
}
}
return maxIndex + 1;
}
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`.
Expand Down
2 changes: 1 addition & 1 deletion test/comparison/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@
"author": "Klemen Tusar",
"license": "BSD-3-Clause",
"dependencies": {
"qs": "^6.14.1"
"qs": "^6.14.2"
}
}
149 changes: 144 additions & 5 deletions test/unit/decode_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<RangeError>().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(
Expand Down Expand Up @@ -2913,30 +2946,136 @@ 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<dynamic> 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<RangeError>()),
);
});

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<RangeError>()),
);
});

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: 1,
listLimit: 3,
throwOnLimitExceeded: true,
),
),
throwsA(isA<RangeError>()),
);
});

test('GHSA payload throws when limit exceeded in strict mode', () {
final payload = 'a=${','.padLeft(25, ',')}';

expect(
() => QS.decode(
payload,
const DecodeOptions(
comma: true,
listLimit: 5,
throwOnLimitExceeded: true,
),
),
throwsA(isA<RangeError>()),
);
});

test('GHSA payload converts to overflow map without throw', () {
final payload = 'a=${','.padLeft(25, ',')}';
final result = QS.decode(
payload,
const DecodeOptions(
comma: true,
listLimit: 5,
),
);

final aValue = result['a'];
expect(aValue, isA<Map<String, dynamic>>());
expect((aValue as Map<String, dynamic>).length, 26);
expect(Utils.isOverflow(aValue), isTrue);
});

test('strict depth throws when additional bracket groups remain', () {
expect(
() => QS.decode(
Expand Down