Skip to content
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## 1.8.1-dev

* [FIX] match Node `qs` 6.15.3 cumulative list-limit enforcement across duplicate-key combinations and mixed list merges
* [FIX] reject oversized flat comma values before allocating their split lists when `throwOnLimitExceeded` is enabled
* [CHORE] add Node `qs` 6.15.3 regression coverage for unbalanced bracket keys, chunk-boundary surrogate pairs, and cyclic compaction

## 1.8.0

* [BREAKING] stop exporting the internal `Undefined` sentinel from the public API
Expand Down
56 changes: 38 additions & 18 deletions lib/src/extensions/decode.dart
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ extension _$Decode on QS {
///
/// The `currentListLength` is used to guard incremental growth when we are
/// already building a list for a given key path.
/// `isFlatListValue` distinguishes flat comma lists (`a=1,2`, `a[b]=1,2`)
/// from nested `[]=` comma groups, which count as one outer list element.
///
/// **Negative `listLimit` semantics:** a negative value disables numeric-index parsing
/// elsewhere (e.g. `[2]` segments become string keys). For comma‑splits specifically:
Expand All @@ -44,29 +46,34 @@ extension _$Decode on QS {
final DecodeOptions options,
final int currentListLength,
final bool isListGrowthPath,
final bool isFlatListValue,
) {
// Fast-path: split comma-separated scalars into a list when requested.
if (val is String && val.isNotEmpty && options.comma && val.contains(',')) {
if (isFlatListValue && options.throwOnLimitExceeded) {
int commaCount = 0;
int commaIndex = val.indexOf(',');
while (commaIndex >= 0) {
commaCount++;
if (commaCount >= options.listLimit) {
Utils.throwListLimitExceeded(options.listLimit);
}
commaIndex = val.indexOf(',', commaIndex + 1);
}
}
Comment thread
techouse marked this conversation as resolved.
return _splitCommaValue(val);
}

// Guard incremental growth of an existing list as we parse additional items.
if (options.throwOnLimitExceeded &&
isListGrowthPath &&
currentListLength >= options.listLimit) {
throw RangeError(_listLimitExceededMessage(options.listLimit));
Utils.throwListLimitExceeded(options.listLimit);
}

return val;
}

/// Helper to generate consistent error messages for list limit violations,
/// based on the configured `listLimit`.
static String _listLimitExceededMessage(final int listLimit) => listLimit < 0
? 'List parsing is disabled (listLimit < 0).'
: 'List limit exceeded. Only $listLimit '
'element${listLimit == 1 ? '' : 's'} allowed in a list.';

/// Applies comma-list limit handling after values have been decoded and
/// after `[]` suffix wrapping, matching Node `qs` ordering.
static dynamic _promoteCommaListIfNeeded(
Expand All @@ -77,9 +84,14 @@ extension _$Decode on QS {
return val;
}
if (options.throwOnLimitExceeded) {
throw RangeError(_listLimitExceededMessage(options.listLimit));
Utils.throwListLimitExceeded(options.listLimit);
}
return Utils.combine([], val, listLimit: options.listLimit);
return Utils.combine(
[],
val,
listLimit: options.listLimit,
throwOnLimitExceeded: options.throwOnLimitExceeded,
);
}

/// Splits a comma-separated value into parts, respecting an optional `maxParts` limit.
Expand Down Expand Up @@ -295,6 +307,7 @@ extension _$Decode on QS {
options,
currentListLength,
listGrowthFromKey,
!bracketSuffix,
),
(final dynamic v) =>
options.decodeValue(v as String?, charset: charset),
Expand Down Expand Up @@ -328,13 +341,14 @@ extension _$Decode on QS {
final bool existing = obj.containsKey(key);
switch ((existing, options.duplicates)) {
case (true, Duplicates.combine):
// Existing key + `combine` policy: merge old/new values.
obj[key] = Utils.combine(obj[key], val, listLimit: options.listLimit);
break;
case (true, _) when bracketSuffix:
// `qs` always combines duplicate bracket-notation keys, even when
// the duplicates option is `first` or `last`.
obj[key] = Utils.combine(obj[key], val, listLimit: options.listLimit);
// Combine by policy, and always combine bracket-notation duplicates.
obj[key] = Utils.combine(
obj[key],
val,
listLimit: options.listLimit,
throwOnLimitExceeded: options.throwOnLimitExceeded,
);
break;
case (false, _):
case (true, Duplicates.last):
Expand Down Expand Up @@ -411,6 +425,7 @@ extension _$Decode on QS {
options,
currentListLength,
isListGrowthPath,
false,
);

for (int i = chain.length - 1; i >= 0; --i) {
Expand All @@ -428,7 +443,12 @@ extension _$Decode on QS {
obj = options.allowEmptyLists &&
(leaf == '' || (options.strictNullHandling && leaf == null))
? List<dynamic>.empty(growable: true)
: Utils.combine([], leaf, listLimit: options.listLimit);
: Utils.combine(
[],
leaf,
listLimit: options.listLimit,
throwOnLimitExceeded: options.throwOnLimitExceeded,
);
}
} else {
obj = <String, dynamic>{};
Expand Down Expand Up @@ -465,7 +485,7 @@ extension _$Decode on QS {
);
obj[index] = leaf;
} else if (isValidListIndex && options.throwOnLimitExceeded) {
throw RangeError(_listLimitExceededMessage(options.listLimit));
Utils.throwListLimitExceeded(options.listLimit);
} else if (isValidListIndex) {
obj[index.toString()] = leaf;
Utils.markOverflow(obj, index);
Expand Down
105 changes: 70 additions & 35 deletions lib/src/utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,8 @@ final class Utils {
static int _getOverflowIndex(final Map obj) => _overflowIndex[obj] ?? -1;

/// Updates the tracked max numeric index for an overflow map.
static void _setOverflowIndex(final Map obj, final int maxIndex) {
_overflowIndex[obj] = maxIndex;
}
static void _setOverflowIndex(final Map obj, final int maxIndex) =>
_overflowIndex[obj] = maxIndex;

/// Returns the larger of the current max and the parsed numeric key (if any).
static int _updateOverflowMax(final int current, final String key) {
Expand All @@ -65,6 +64,27 @@ final class Utils {
return parsed > current ? parsed : current;
}

@internal
static Never throwListLimitExceeded(final int listLimit) => throw RangeError(
listLimit < 0
? 'List parsing is disabled (listLimit < 0).'
: 'List limit exceeded. Only $listLimit '
'element${listLimit == 1 ? '' : 's'} allowed in a list.',
);

static dynamic _enforceListLimit(
final List<dynamic> result,
final DecodeOptions? options,
) {
if (options == null || result.length <= options.listLimit) {
return result;
}
if (options.throwOnLimitExceeded) {
throwListLimitExceeded(options.listLimit);
}
return markOverflow(createIndexMap(result), result.length - 1);
}

/// Deeply merges `source` into `target` while preserving insertion order
/// and list semantics used by `qs`.
///
Expand Down Expand Up @@ -182,11 +202,12 @@ final class Utils {
}

stack.removeLast();
frame.onResult(
currentTarget is Set
? indexedTarget.values.toSet()
: indexedTarget.values.toList(),
);
frame.onResult(currentTarget is Set
? indexedTarget.values.toSet()
: _enforceListLimit(
indexedTarget.values.toList(),
frame.options,
));
continue;
}

Expand All @@ -203,21 +224,22 @@ final class Utils {
}

stack.removeLast();
frame.onResult(
currentTarget is Set
? (Set.of(currentTarget)
..addAll(currentSource.whereNotType<Undefined>()))
: (List.of(currentTarget)
..addAll(currentSource.whereNotType<Undefined>())),
);
frame.onResult(currentTarget is Set
? (Set.of(currentTarget)
..addAll(currentSource.whereNotType<Undefined>()))
: _enforceListLimit(
List.of(currentTarget)
..addAll(currentSource.whereNotType<Undefined>()),
frame.options,
));
continue;
}

if (currentTarget is List) {
final List<dynamic> merged = List<dynamic>.of(currentTarget)
..add(currentSource);
stack.removeLast();
frame.onResult(merged);
frame.onResult(_enforceListLimit(merged, frame.options));
continue;
}
if (currentTarget is Set) {
Expand Down Expand Up @@ -269,10 +291,12 @@ final class Utils {
continue;
} else {
if (currentTarget is! Iterable && currentSource is Iterable) {
final List<dynamic> merged = [
currentTarget,
...currentSource.whereNotType<Undefined>(),
];
stack.removeLast();
frame.onResult(
[currentTarget, ...currentSource.whereNotType<Undefined>()],
);
frame.onResult(_enforceListLimit(merged, frame.options));
continue;
}
stack.removeLast();
Expand Down Expand Up @@ -316,18 +340,17 @@ final class Utils {
}

stack.removeLast();
frame.onResult(
[
if (currentTarget is Iterable)
...currentTarget.whereNotType<Undefined>()
else if (currentTarget != null)
currentTarget,
if (currentSource is Iterable)
...(currentSource as Iterable).whereNotType<Undefined>()
else
currentSource,
],
);
final List<dynamic> combined = [
if (currentTarget is Iterable)
...currentTarget.whereNotType<Undefined>()
else if (currentTarget != null)
currentTarget,
if (currentSource is Iterable)
...(currentSource as Iterable).whereNotType<Undefined>()
else
currentSource,
];
frame.onResult(_enforceListLimit(combined, frame.options));
continue;
}

Expand Down Expand Up @@ -413,7 +436,10 @@ final class Utils {
}
final resultList = frame.targetIsSet
? frame.indexedTarget!.values.toSet()
: frame.indexedTarget!.values.toList();
: _enforceListLimit(
frame.indexedTarget!.values.toList(),
frame.options,
);
stack.removeLast();
frame.onResult(resultList);
continue;
Expand Down Expand Up @@ -868,10 +894,12 @@ final class Utils {
/// Concatenates two values, spreading iterables.
///
/// When [listLimit] is provided and exceeded, returns a map with string keys.
/// Any throwing behavior is enforced earlier during parsing, matching Node `qs`.
/// When [throwOnLimitExceeded] is true, throws instead of returning an
/// overflow map.
///
/// **Note:** If [a] is already an overflow object, this method mutates [a]
/// in place by appending entries from [b].
/// in place by appending entries from [b], unless strict limit enforcement
/// throws first.
///
/// Examples:
/// ```dart
Expand All @@ -882,8 +910,12 @@ final class Utils {
final dynamic a,
final dynamic b, {
final int? listLimit,
final bool throwOnLimitExceeded = false,
}) {
if (isOverflow(a)) {
if (throwOnLimitExceeded && listLimit != null) {
throwListLimitExceeded(listLimit);
}
int newIndex = _getOverflowIndex(a);
if (b is Iterable) {
for (final dynamic item in b) {
Expand All @@ -904,6 +936,9 @@ final class Utils {
];

if (listLimit != null && result.length > listLimit) {
if (throwOnLimitExceeded) {
throwListLimitExceeded(listLimit);
}
final Map<String, dynamic> overflow = createIndexMap(result);
return markOverflow(overflow, result.length - 1);
}
Expand Down Expand Up @@ -938,7 +973,7 @@ final class Utils {
};
}

static bool _isProtectedObjectKey(final String key) => const {
static bool _isProtectedObjectKey(final String key) => const <String>{
'__defineGetter__',
'__defineSetter__',
'__lookupGetter__',
Expand Down
Loading