diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 714dfd7..570a8f1 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -19,8 +19,10 @@ Purpose: Provide just enough project context so an AI assistant can make correct * Decode limits: default `depth=5`, `parameterLimit=1000`, `listLimit=20`; exceeding may coerce indices into object keys or (with strict flags) throw. * List vs Map merging mimics Node: duplicate keys accumulate to lists unless `duplicates` option changes strategy. * `Undefined` entries are placeholders stripped by `Utils.compact` post‑decode / during encode pruning; never serialize `Undefined` itself. +* `Utils.encode` uses `allowMalformed: true` when decoding `ByteBuffer` as UTF‑8 to match Node `Buffer.toString('utf8')`. * Charset sentinel: when `charsetSentinel=true`, `utf8=✓` token (encoded differently per charset) overrides provided `charset` and is omitted from output. * `allowDots` & `decodeDotInKeys`: invalid combination (`decodeDotInKeys: true` with `allowDots: false`) must throw (constructor asserts). Preserve that invariant. +* Merge semantics: list holes are treated as missing values when merging list‑of‑maps by index (no `[Undefined, map]` pair at an index); `parseLists=false` normalizes list results into string‑key maps. * Negative `listLimit` disables numeric indexing; with `throwOnLimitExceeded` certain pushes must throw `RangeError` (match existing patterns in decode logic—consult decode part file before altering behavior). * Encoding pipeline can inject custom encoder/decoder hooks; preserve argument order and named params (`charset`, `format`, `kind`). diff --git a/CHANGELOG.md b/CHANGELOG.md index 71ae7ce..1826694 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## 1.7.0-wip + +* [FEAT] add `DecodeOptions.throwOnLimitExceeded` for strict limit enforcement on parameter, list, and depth overflows +* [FIX] tolerate malformed UTF-8 when encoding `ByteBuffer` values to match Node `Buffer.toString('utf8')` +* [FIX] encode `ByteBuffer` values via charset even when `encode=false` (avoid `Instance of 'ByteBuffer'` output) +* [FIX] ensure invalid charset in `Utils.encode` consistently throws `ArgumentError` +* [FIX] improve merge semantics for lists/maps (hole replacement, undefined normalization under `parseLists=false`, and non-mutating list/set merges) +* [FIX] add runtime validation and DecodeOptions constructor asserts for invalid charsets and inconsistent dot options +* [CHORE] refactor encode/merge internals to stack-based frames (`EncodeFrame`, `MergeFrame`, `MergePhase`) for deep-nesting safety +* [CHORE] expand coverage for encode/decode/merge edge cases and add a shared `FakeEncoding` test helper + ## 1.6.1 * [FIX] prevent `DecodeOptions.listLimit` bypass in bracket notation to mitigate potential DoS via memory exhaustion diff --git a/README.md b/README.md index a6201ef..1824400 100644 --- a/README.md +++ b/README.md @@ -575,6 +575,10 @@ expect( ); ``` +Note: when a value is a `ByteBuffer`, it is still decoded using the selected +charset even when `encode` is `false`, so the emitted value reflects the buffer +contents. + Encoding can be disabled for keys by setting the [EncodeOptions.encodeValuesOnly] option to `true`: ```dart diff --git a/lib/src/enums/merge_phase.dart b/lib/src/enums/merge_phase.dart new file mode 100644 index 0000000..b55bc64 --- /dev/null +++ b/lib/src/enums/merge_phase.dart @@ -0,0 +1,17 @@ +import 'package:meta/meta.dart' show internal; + +/// Internal phases for the iterative merge walker. +/// +/// These drive the state machine used by [Utils.merge] to avoid recursion +/// while preserving `qs` merge semantics for maps and iterables. +@internal +enum MergePhase { + /// Initial dispatch and shape normalization. + start, + + /// Iterating over map entries during a merge step. + mapIter, + + /// Iterating over list/set indices during a merge step. + listIter, +} diff --git a/lib/src/extensions/encode.dart b/lib/src/extensions/encode.dart index 2b4d3bf..96530d2 100644 --- a/lib/src/extensions/encode.dart +++ b/lib/src/extensions/encode.dart @@ -16,14 +16,10 @@ part of '../qs.dart'; /// - *prefix*: current key path being built (e.g., `user[address]`), with optional `?` prefix. extension _$Encode on QS { - // Side-channel anchor used to thread cycle-detection state through recursion. - // We store nested WeakMaps under this key to walk back up the call stack. - static const Map _sentinel = {}; - - /// Core encoder (recursive). + /// Core encoder (iterative, stack-based). /// - /// Returns either a `String` (single key=value) or `List` fragments, which the - /// top-level caller ultimately joins with the chosen delimiter. + /// Returns a `List` of encoded fragments; the top-level caller joins + /// them with the chosen delimiter. /// /// Parameters (most mirror Node `qs`): /// - [object]: The current value to encode (map/iterable/scalar/byte buffer/date). @@ -76,164 +72,240 @@ extension _$Encode on QS { identical(generateArrayPrefix, ListFormat.comma.generator); formatter ??= format.formatter; - dynamic obj = object; - - WeakMap? tmpSc = sideChannel; - int step = 0; - bool findFlag = false; + List? result; + final List stack = [ + EncodeFrame( + object: object, + undefined: undefined, + sideChannel: sideChannel, + prefix: prefix, + generateArrayPrefix: generateArrayPrefix, + commaRoundTrip: commaRoundTrip, + commaCompactNulls: commaCompactNulls, + allowEmptyLists: allowEmptyLists, + strictNullHandling: strictNullHandling, + skipNulls: skipNulls, + encodeDotInKeys: encodeDotInKeys, + encoder: encoder, + serializeDate: serializeDate, + sort: sort, + filter: filter, + allowDots: allowDots, + format: format, + formatter: formatter, + encodeValuesOnly: encodeValuesOnly, + charset: charset, + onResult: (List value) => result = value, + ), + ]; + + while (stack.isNotEmpty) { + final EncodeFrame frame = stack.last; + + if (!frame.prepared) { + dynamic obj = frame.object; + final bool trackObject = + obj is Map || (obj is Iterable && obj is! String); + if (trackObject) { + if (frame.sideChannel.contains(obj)) { + throw RangeError('Cyclic object value'); + } + frame.sideChannel[obj] = true; + frame.tracked = true; + frame.trackedObject = obj as Object; + } - // Walk the nested WeakMap chain to see if the current object already appeared - // in the traversal path. If so, either throw (direct cycle) or stop descending. - while ((tmpSc = tmpSc?.get(_sentinel)) != null && !findFlag) { - // Where object last appeared in the ref tree - final int? pos = tmpSc?.get(object) as int?; - step += 1; - if (pos != null) { - if (pos == step) { - throw RangeError('Cyclic object value'); - } else { - findFlag = true; // Break while + // Apply filter hook first. For dates, serialize them before any list/comma handling. + if (frame.filter is Function) { + obj = frame.filter.call(frame.prefix, obj); + } else if (obj is DateTime) { + obj = switch (frame.serializeDate) { + null => obj.toIso8601String(), + _ => frame.serializeDate!(obj), + }; + } else if (identical( + frame.generateArrayPrefix, ListFormat.comma.generator) && + obj is Iterable) { + obj = Utils.apply( + obj, + (value) => value is DateTime + ? (frame.serializeDate?.call(value) ?? value.toIso8601String()) + : value, + ); } - } - if (tmpSc?.get(_sentinel) == null) { - step = 0; - } - } - // Apply filter hook first. For dates, serialize them before any list/comma handling. - if (filter is Function) { - obj = filter.call(prefix, obj); - } else if (obj is DateTime) { - obj = switch (serializeDate) { - null => obj.toIso8601String(), - _ => serializeDate(obj), - }; - } else if (identical(generateArrayPrefix, ListFormat.comma.generator) && - obj is Iterable) { - obj = Utils.apply( - obj, - (value) => value is DateTime - ? (serializeDate?.call(value) ?? value.toIso8601String()) - : value, - ); - } + // Present-but-null handling: + // - If the value is *present* and null and strictNullHandling is on, emit only the key. + // - Otherwise, treat null as an empty string. + if (!frame.undefined && obj == null) { + if (frame.strictNullHandling) { + final String keyOnly = + frame.encoder != null && !frame.encodeValuesOnly + ? frame.encoder!(frame.prefix) + : frame.prefix; + if (frame.tracked) { + frame.sideChannel.remove(frame.trackedObject ?? frame.object); + } + stack.removeLast(); + frame.onResult([keyOnly]); + continue; + } + obj = ''; + } - // Present-but-null handling: - // - If the value is *present* and null and strictNullHandling is on, emit only the key. - // - Otherwise, treat null as an empty string. - if (!undefined && obj == null) { - if (strictNullHandling) { - return encoder != null && !encodeValuesOnly ? encoder(prefix) : prefix; - } + // Fast path for primitives and byte buffers → return a single key=value fragment. + if (Utils.isNonNullishPrimitive(obj, frame.skipNulls) || + obj is ByteBuffer) { + late final String fragment; + if (frame.encoder != null) { + final String keyValue = frame.encodeValuesOnly + ? frame.prefix + : frame.encoder!(frame.prefix); + fragment = + '${frame.formatter(keyValue)}=${frame.formatter(frame.encoder!(obj))}'; + } else { + final String valueString = obj is ByteBuffer + ? (frame.charset == utf8 + ? utf8.decode( + obj.asUint8List(), + allowMalformed: true, + ) + : latin1.decode(obj.asUint8List())) + : obj.toString(); + fragment = + '${frame.formatter(frame.prefix)}=${frame.formatter(valueString)}'; + } + if (frame.tracked) { + frame.sideChannel.remove(frame.trackedObject ?? frame.object); + } + stack.removeLast(); + frame.onResult([fragment]); + continue; + } - obj = ''; - } + // Collect per-branch fragments; empty list signifies "emit nothing" for this path. + if (frame.undefined) { + if (frame.tracked) { + frame.sideChannel.remove(frame.trackedObject ?? frame.object); + } + stack.removeLast(); + frame.onResult(const []); + continue; + } - // Fast path for primitives and byte buffers → return a single key=value fragment. - if (Utils.isNonNullishPrimitive(obj, skipNulls) || obj is ByteBuffer) { - if (encoder != null) { - final String keyValue = encodeValuesOnly ? prefix : encoder(prefix); - return ['${formatter(keyValue)}=${formatter(encoder(obj))}']; - } - return ['${formatter(prefix)}=${formatter(obj.toString())}']; - } + // Cache list form once for non-Map, non-String iterables to avoid repeated enumeration + List? seqList; + int? commaEffectiveLength; + final bool isSeq = obj is Iterable && obj is! String && obj is! Map; + if (isSeq) { + seqList = obj is List ? obj : obj.toList(growable: false); + } - // Collect per-branch fragments; empty list signifies "emit nothing" for this path. - final List values = []; + late final List objKeys; + // Determine the set of keys/indices to traverse at this depth: + // - For `.comma` lists we join values in-place. + // - If `filter` is Iterable, it constrains the key set. + // - Otherwise derive keys from Map/Iterable, and optionally sort them. + if (identical(frame.generateArrayPrefix, ListFormat.comma.generator) && + obj is Iterable) { + final Iterable iterableObj = obj; + final List commaItems = iterableObj is List + ? iterableObj + : iterableObj.toList(growable: false); + + final List filteredItems = frame.commaCompactNulls + ? commaItems.where((dynamic item) => item != null).toList() + : commaItems; + + commaEffectiveLength = filteredItems.length; + + final Iterable joinIterable = frame.encodeValuesOnly && + frame.encoder != null + ? (Utils.apply(filteredItems, frame.encoder!) as Iterable) + : filteredItems; + + final List joinList = joinIterable is List + ? joinIterable + : joinIterable.toList(growable: false); + + if (joinList.isNotEmpty) { + final String objKeysValue = + joinList.map((e) => e != null ? e.toString() : '').join(','); + + objKeys = [ + { + 'value': objKeysValue.isNotEmpty ? objKeysValue : null, + }, + ]; + } else { + objKeys = [ + {'value': const Undefined()}, + ]; + } + } else if (frame.filter is Iterable) { + objKeys = List.of(frame.filter); + } else { + late final Iterable keys; + if (obj is Map) { + keys = obj.keys; + } else if (seqList != null) { + keys = + List.generate(seqList.length, (i) => i, growable: false); + } else { + keys = const []; + } + objKeys = frame.sort != null + ? (keys.toList()..sort(frame.sort)) + : keys.toList(); + } - if (undefined) { - return values; - } + // Key-path formatting: + // - Optionally encode literal dots. + // - Under `.comma` with single-element lists and round-trip enabled, append []. + final String encodedPrefix = frame.encodeDotInKeys + ? frame.prefix.replaceAll('.', '%2E') + : frame.prefix; + + final bool shouldAppendRoundTripMarker = (frame.commaRoundTrip == + true) && + seqList != null && + (identical(frame.generateArrayPrefix, ListFormat.comma.generator) && + commaEffectiveLength != null + ? commaEffectiveLength == 1 + : seqList.length == 1); + + final String adjustedPrefix = + shouldAppendRoundTripMarker ? '$encodedPrefix[]' : encodedPrefix; + + // Emit `key[]` when an empty list is allowed, to preserve shape on round-trip. + if (frame.allowEmptyLists && seqList != null && seqList.isEmpty) { + if (frame.tracked) { + frame.sideChannel.remove(frame.trackedObject ?? frame.object); + } + stack.removeLast(); + frame.onResult(['$adjustedPrefix[]']); + continue; + } - // Cache list form once for non-Map, non-String iterables to avoid repeated enumeration - List? seqList_; - int? commaEffectiveLength; - final bool isSeq_ = obj is Iterable && obj is! String && obj is! Map; - if (isSeq_) { - if (obj is List) { - seqList_ = obj; - } else { - seqList_ = obj.toList(growable: false); + frame.object = obj; + frame.prepared = true; + frame.objKeys = objKeys; + frame.seqList = seqList; + frame.commaEffectiveLength = commaEffectiveLength; + frame.adjustedPrefix = adjustedPrefix; + continue; } - } - - late final List objKeys; - // Determine the set of keys/indices to traverse at this depth: - // - For `.comma` lists we join values in-place. - // - If `filter` is Iterable, it constrains the key set. - // - Otherwise derive keys from Map/Iterable, and optionally sort them. - if (identical(generateArrayPrefix, ListFormat.comma.generator) && - obj is Iterable) { - final Iterable iterableObj = obj; - final List commaItems = iterableObj is List - ? List.from(iterableObj) - : iterableObj.toList(growable: false); - - final List filteredItems = commaCompactNulls - ? commaItems.where((dynamic item) => item != null).toList() - : commaItems; - - commaEffectiveLength = filteredItems.length; - - final Iterable joinIterable = encodeValuesOnly && encoder != null - ? (Utils.apply(filteredItems, encoder) as Iterable) - : filteredItems; - - final List joinList = joinIterable is List - ? List.from(joinIterable) - : joinIterable.toList(growable: false); - - if (joinList.isNotEmpty) { - final String objKeysValue = - joinList.map((e) => e != null ? e.toString() : '').join(','); - objKeys = [ - { - 'value': objKeysValue.isNotEmpty ? objKeysValue : null, - }, - ]; - } else { - objKeys = [ - {'value': const Undefined()}, - ]; - } - } else if (filter is Iterable) { - objKeys = List.of(filter); - } else { - late final Iterable keys; - if (obj is Map) { - keys = obj.keys; - } else if (seqList_ != null) { - keys = List.generate(seqList_.length, (i) => i, growable: false); - } else { - keys = const []; + if (frame.index >= frame.objKeys.length) { + if (frame.tracked) { + frame.sideChannel.remove(frame.trackedObject ?? frame.object); + } + stack.removeLast(); + frame.onResult(frame.values); + continue; } - objKeys = sort != null ? (keys.toList()..sort(sort)) : keys.toList(); - } - - // Key-path formatting: - // - Optionally encode literal dots. - // - Under `.comma` with single-element lists and round-trip enabled, append []. - final String encodedPrefix = - encodeDotInKeys ? prefix.replaceAll('.', '%2E') : prefix; - - final bool shouldAppendRoundTripMarker = (commaRoundTrip == true) && - seqList_ != null && - (identical(generateArrayPrefix, ListFormat.comma.generator) && - commaEffectiveLength != null - ? commaEffectiveLength == 1 - : seqList_.length == 1); - - final String adjustedPrefix = - shouldAppendRoundTripMarker ? '$encodedPrefix[]' : encodedPrefix; - - // Emit `key[]` when an empty list is allowed, to preserve shape on round-trip. - if (allowEmptyLists && seqList_ != null && seqList_.isEmpty) { - return '$adjustedPrefix[]'; - } - for (int i = 0; i < objKeys.length; i++) { - final key = objKeys[i]; + final key = frame.objKeys[frame.index++]; late final dynamic value; late final bool valueUndefined; @@ -245,13 +317,13 @@ extension _$Encode on QS { } else { // Resolve value for the current key/index. try { - if (obj is Map) { - value = obj[key]; - valueUndefined = !obj.containsKey(key); - } else if (seqList_ != null) { + if (frame.object is Map) { + value = frame.object[key]; + valueUndefined = !(frame.object as Map).containsKey(key); + } else if (frame.seqList != null) { final int? idx = key is int ? key : int.tryParse(key.toString()); - if (idx != null && idx >= 0 && idx < seqList_.length) { - value = seqList_[idx]; + if (idx != null && idx >= 0 && idx < frame.seqList!.length) { + value = frame.seqList![idx]; valueUndefined = false; } else { value = null; @@ -260,7 +332,7 @@ extension _$Encode on QS { } else { // Best-effort dynamic indexer for user-defined classes that expose `operator []`. // If it throws (no indexer / wrong type), we fall through to the catch and mark undefined. - value = obj[key]; + value = (frame.object as dynamic)[key]; valueUndefined = false; } } catch (_) { @@ -269,64 +341,58 @@ extension _$Encode on QS { } } - if (skipNulls && value == null) { + if (frame.skipNulls && value == null) { continue; } // Build the next key path segment using either bracket or dot notation. - final String encodedKey = allowDots && encodeDotInKeys + final String encodedKey = frame.allowDots && frame.encodeDotInKeys ? key.toString().replaceAll('.', '%2E') : key.toString(); final bool isCommaSentinel = key is Map && key.containsKey('value'); final String keyPrefix = (isCommaSentinel && - identical(generateArrayPrefix, ListFormat.comma.generator)) - ? adjustedPrefix - : (seqList_ != null - ? generateArrayPrefix(adjustedPrefix, encodedKey) - : '$adjustedPrefix${allowDots ? '.$encodedKey' : '[$encodedKey]'}'); - - // Thread cycle-detection state into recursive calls without keeping strong references. - sideChannel[object] = step; - final WeakMap valueSideChannel = WeakMap(); - valueSideChannel.add(key: _sentinel, value: sideChannel); - - final encoded = _encode( - value, - undefined: valueUndefined, - prefix: keyPrefix, - generateArrayPrefix: generateArrayPrefix, - commaRoundTrip: commaRoundTrip, - commaCompactNulls: commaCompactNulls, - allowEmptyLists: allowEmptyLists, - strictNullHandling: strictNullHandling, - skipNulls: skipNulls, - encodeDotInKeys: encodeDotInKeys, - encoder: identical(generateArrayPrefix, ListFormat.comma.generator) && - encodeValuesOnly && - seqList_ != null - ? null - : encoder, - serializeDate: serializeDate, - filter: filter, - sort: sort, - allowDots: allowDots, - format: format, - formatter: formatter, - encodeValuesOnly: encodeValuesOnly, - charset: charset, - sideChannel: valueSideChannel, + identical(frame.generateArrayPrefix, ListFormat.comma.generator)) + ? frame.adjustedPrefix! + : (frame.seqList != null + ? frame.generateArrayPrefix(frame.adjustedPrefix!, encodedKey) + : '${frame.adjustedPrefix!}${frame.allowDots ? '.$encodedKey' : '[$encodedKey]'}'); + + stack.add( + EncodeFrame( + object: value, + undefined: valueUndefined, + sideChannel: frame.sideChannel, + prefix: keyPrefix, + generateArrayPrefix: frame.generateArrayPrefix, + commaRoundTrip: frame.commaRoundTrip, + commaCompactNulls: frame.commaCompactNulls, + allowEmptyLists: frame.allowEmptyLists, + strictNullHandling: frame.strictNullHandling, + skipNulls: frame.skipNulls, + encodeDotInKeys: frame.encodeDotInKeys, + encoder: identical( + frame.generateArrayPrefix, ListFormat.comma.generator) && + frame.encodeValuesOnly && + frame.seqList != null + ? null + : frame.encoder, + serializeDate: frame.serializeDate, + sort: frame.sort, + filter: frame.filter, + allowDots: frame.allowDots, + format: frame.format, + formatter: frame.formatter, + encodeValuesOnly: frame.encodeValuesOnly, + charset: frame.charset, + onResult: (List encoded) { + frame.values.addAll(encoded); + }, + ), ); - - // Flatten nested results (each recursion returns a list of fragments or a single fragment). - if (encoded is Iterable) { - values.addAll(encoded); - } else { - values.add(encoded); - } } - return values; + return result ?? const []; } } diff --git a/lib/src/models/decode_options.dart b/lib/src/models/decode_options.dart index e9bb8b7..3183b15 100644 --- a/lib/src/models/decode_options.dart +++ b/lib/src/models/decode_options.dart @@ -17,7 +17,8 @@ import 'package:qs_dart/src/utils.dart'; /// - **Dot notation**: set [allowDots] to treat `a.b=c` like `{a: {b: "c"}}`. /// If you *explicitly* request dot decoding in keys via [decodeDotInKeys], /// [allowDots] is implied and will be treated as `true` unless you explicitly -/// set `allowDots: false` — which is an invalid combination and will throw at construction time. +/// set `allowDots: false` — which is an invalid combination and will throw +/// when validated/used. /// - **Charset handling**: [charset] selects UTF‑8 or Latin‑1 decoding. When /// [charsetSentinel] is `true`, a leading `utf8=✓` token (in either UTF‑8 or /// Latin‑1 form) can override [charset] as a compatibility escape hatch. @@ -48,6 +49,9 @@ typedef Decoder = dynamic Function( typedef LegacyDecoder = dynamic Function(String? value, {Encoding? charset}); /// Options that configure the output of [QS.decode]. +/// +/// Invariants are asserted in debug builds and validated at runtime via +/// [validate] (used by decode entry points). final class DecodeOptions with EquatableMixin { const DecodeOptions({ bool? allowDots, @@ -79,19 +83,20 @@ final class DecodeOptions with EquatableMixin { 'Invalid charset', ), assert( - !(decodeDotInKeys ?? false) || allowDots != false, + !(decodeDotInKeys ?? false) || + (allowDots ?? (decodeDotInKeys ?? false)), 'decodeDotInKeys requires allowDots to be true', ), assert( parameterLimit > 0, - 'Parameter limit must be positive', + 'Parameter limit must be a positive number.', ); /// When `true`, decode dot notation in keys: `a.b=c` → `{a: {b: "c"}}`. /// /// If you set [decodeDotInKeys] to `true` and do not pass [allowDots], this /// flag defaults to `true`. Passing `allowDots: false` while - /// `decodeDotInKeys` is `true` is invalid and will throw at construction. + /// `decodeDotInKeys` is `true` is invalid and will throw when validated/used. final bool allowDots; /// When `true`, allow empty list values to be produced from inputs like @@ -140,7 +145,7 @@ final class DecodeOptions with EquatableMixin { /// /// This explicitly opts into dot‑notation handling and **implies** [allowDots]. /// Passing `decodeDotInKeys: true` while forcing `allowDots: false` is an - /// invalid combination and will throw *at construction time*. + /// invalid combination and will throw when validated/used. /// /// Note: inside bracket segments (e.g., `a[%2E]`), percent‑decoding naturally /// yields `"."`. Whether a `.` causes additional splitting is a parser concern @@ -215,6 +220,8 @@ final class DecodeOptions with EquatableMixin { Encoding? charset, DecodeKind kind = DecodeKind.value, }) { + // Validate here to cover direct decodeKey/decodeValue usage; cached via Expando. + validate(); if (_decoder != null) { return _decoder!(value, charset: charset, kind: kind); } @@ -273,6 +280,7 @@ final class DecodeOptions with EquatableMixin { bool? parseLists, bool? strictNullHandling, bool? strictDepth, + bool? throwOnLimitExceeded, Decoder? decoder, LegacyDecoder? legacyDecoder, }) => @@ -294,10 +302,41 @@ final class DecodeOptions with EquatableMixin { parseLists: parseLists ?? this.parseLists, strictNullHandling: strictNullHandling ?? this.strictNullHandling, strictDepth: strictDepth ?? this.strictDepth, + throwOnLimitExceeded: throwOnLimitExceeded ?? this.throwOnLimitExceeded, decoder: decoder ?? _decoder, legacyDecoder: legacyDecoder ?? _legacyDecoder, ); + /// Validates option invariants (used by [QS.decode] and direct decoder calls). + void validate() { + if (_validated[this] == true) return; + + final Encoding currentCharset = charset; + if (currentCharset != utf8 && currentCharset != latin1) { + throw ArgumentError.value(currentCharset, 'charset', 'Invalid charset'); + } + + if (decodeDotInKeys && !allowDots) { + throw ArgumentError.value( + decodeDotInKeys, + 'decodeDotInKeys', + 'Invalid combination: decodeDotInKeys=$decodeDotInKeys requires ' + 'allowDots=true (currently allowDots=$allowDots).', + ); + } + + final num limit = parameterLimit; + if (limit.isNaN || limit <= 0) { + throw ArgumentError.value( + limit, + 'parameterLimit', + 'Parameter limit must be a positive number.', + ); + } + + _validated[this] = true; + } + @override String toString() => 'DecodeOptions(\n' ' allowDots: $allowDots,\n' @@ -315,6 +354,7 @@ final class DecodeOptions with EquatableMixin { ' parameterLimit: $parameterLimit,\n' ' parseLists: $parseLists,\n' ' strictDepth: $strictDepth,\n' + ' throwOnLimitExceeded: $throwOnLimitExceeded,\n' ' strictNullHandling: $strictNullHandling\n' ')'; @@ -340,4 +380,9 @@ final class DecodeOptions with EquatableMixin { _decoder, _legacyDecoder, ]; + + // Expando does not keep keys alive; cached flags vanish when the options + // instance is GC'd, so this avoids repeat validation without leaking. + static final Expando _validated = + Expando('qsDecodeOptionsValidated'); } diff --git a/lib/src/models/encode_frame.dart b/lib/src/models/encode_frame.dart new file mode 100644 index 0000000..30daf33 --- /dev/null +++ b/lib/src/models/encode_frame.dart @@ -0,0 +1,129 @@ +import 'dart:convert' show Encoding; + +import 'package:meta/meta.dart' show internal; +import 'package:qs_dart/src/enums/format.dart'; +import 'package:qs_dart/src/enums/list_format.dart'; +import 'package:qs_dart/src/models/encode_options.dart'; +import 'package:weak_map/weak_map.dart'; + +/// Internal encoder stack frame used by the iterative `_encode` traversal. +/// +/// Stores the current object, derived key paths, and accumulated child results +/// so the encoder can walk deep graphs without recursion while preserving +/// Node `qs` ordering and cycle detection behavior. +@internal +final class EncodeFrame { + EncodeFrame({ + required this.object, + required this.undefined, + required this.sideChannel, + required this.prefix, + required this.generateArrayPrefix, + required this.commaRoundTrip, + required this.commaCompactNulls, + required this.allowEmptyLists, + required this.strictNullHandling, + required this.skipNulls, + required this.encodeDotInKeys, + required this.encoder, + required this.serializeDate, + required this.sort, + required this.filter, + required this.allowDots, + required this.format, + required this.formatter, + required this.encodeValuesOnly, + required this.charset, + required this.onResult, + }); + + /// Current value being encoded at this stack level. + dynamic object; + + /// Whether the value is "missing" rather than explicitly present (qs semantics). + final bool undefined; + + /// Weak side-channel for cycle detection across the traversal path. + final WeakMap sideChannel; + + /// Fully-qualified key path prefix for this frame. + final String prefix; + + /// List key generator (indices/brackets/repeat/comma). + final ListFormatGenerator generateArrayPrefix; + + /// Emit a round-trip marker for comma lists with a single element. + final bool commaRoundTrip; + + /// Drop nulls before joining comma lists. + final bool commaCompactNulls; + + /// Whether empty lists should emit `key[]`. + final bool allowEmptyLists; + + /// Emit bare keys for explicit nulls (no `=`). + final bool strictNullHandling; + + /// Skip keys whose values are null. + final bool skipNulls; + + /// Encode literal dots in keys as `%2E`. + final bool encodeDotInKeys; + + /// Optional value encoder (and key encoder when `encodeValuesOnly` is false). + final Encoder? encoder; + + /// Optional serializer for DateTime values. + final DateSerializer? serializeDate; + + /// Optional key sorter for deterministic ordering. + final Sorter? sort; + + /// Filter hook or whitelist for keys at this level. + final dynamic filter; + + /// Whether to use dot notation between segments. + final bool allowDots; + + /// Output formatting mode. + final Format format; + + /// Formatter applied to already-encoded tokens. + final Formatter formatter; + + /// Encode values only (leave keys unencoded). + final bool encodeValuesOnly; + + /// Declared charset (used by encoder/formatter hooks). + final Encoding charset; + + /// Callback invoked with this frame's encoded fragments. + final void Function(List result) onResult; + + /// Whether this frame has been initialized (keys computed, prefix adjusted). + bool prepared = false; + + /// Whether this frame registered a cycle-tracking entry. + bool tracked = false; + + /// The object used for cycle tracking (after filter/date transforms). + Object? trackedObject; + + /// Keys/indices to iterate at this level. + List objKeys = const []; + + /// Current index into [objKeys]. + int index = 0; + + /// Cached list form for iterable values (to avoid re-iteration). + List? seqList; + + /// Effective comma list length after filtering nulls. + int? commaEffectiveLength; + + /// Prefix after dot-encoding and comma round-trip adjustment. + String? adjustedPrefix; + + /// Accumulated encoded fragments from child frames. + List values = []; +} diff --git a/lib/src/models/encode_options.dart b/lib/src/models/encode_options.dart index b4fab9e..ae8195f 100644 --- a/lib/src/models/encode_options.dart +++ b/lib/src/models/encode_options.dart @@ -1,4 +1,4 @@ -import 'dart:convert' show Encoding, utf8, latin1; +import 'dart:convert' show Encoding, latin1, utf8; import 'package:equatable/equatable.dart'; import 'package:qs_dart/src/enums/format.dart'; @@ -55,15 +55,7 @@ final class EncodeOptions with EquatableMixin { (indices == false ? ListFormat.repeat : null) ?? ListFormat.indices, _serializeDate = serializeDate, - _encoder = encoder, - assert( - charset == utf8 || charset == latin1, - 'Invalid charset', - ), - assert( - filter == null || filter is Function || filter is Iterable, - 'Invalid filter', - ); + _encoder = encoder; /// Set to `true` to add a question mark `?` prefix to the encoded output. final bool addQueryPrefix; @@ -163,6 +155,27 @@ final class EncodeOptions with EquatableMixin { ? _serializeDate!.call(date) : date.toIso8601String(); + /// Validates the encoding options, throwing [ArgumentError] on invalid values. + void validate() { + final Encoding charset = this.charset; + if (charset != utf8 && charset != latin1) { + throw ArgumentError.value( + charset, + 'charset', + 'Invalid charset; only utf8 and latin1 are supported', + ); + } + + final dynamic filter = this.filter; + if (filter != null && filter is! Function && filter is! Iterable) { + throw ArgumentError.value( + filter, + 'filter', + 'Invalid filter; expected Function or Iterable', + ); + } + } + /// Returns a new [EncodeOptions] instance with updated values. EncodeOptions copyWith({ bool? addQueryPrefix, diff --git a/lib/src/models/merge_frame.dart b/lib/src/models/merge_frame.dart new file mode 100644 index 0000000..350f41b --- /dev/null +++ b/lib/src/models/merge_frame.dart @@ -0,0 +1,35 @@ +import 'dart:collection' show SplayTreeMap; + +import 'package:meta/meta.dart' show internal; +import 'package:qs_dart/src/models/decode_options.dart'; +import 'package:qs_dart/src/enums/merge_phase.dart'; + +/// Stack frame for the iterative [Utils.merge] traversal. +/// +/// Captures the current target/source pair plus intermediate iterators and +/// buffers so the merge can walk deeply nested structures without recursion. +@internal +final class MergeFrame { + MergeFrame({ + required this.target, + required this.source, + required this.options, + required this.onResult, + }); + + dynamic target; + dynamic source; + final DecodeOptions? options; + final void Function(dynamic result) onResult; + + MergePhase phase = MergePhase.start; + + Map? mergeTarget; + Iterator>? mapIterator; + int? overflowMax; + + SplayTreeMap? indexedTarget; + List? sourceList; + int listIndex = 0; + bool targetIsSet = false; +} diff --git a/lib/src/qs.dart b/lib/src/qs.dart index 4f79f4e..d9a1a4d 100644 --- a/lib/src/qs.dart +++ b/lib/src/qs.dart @@ -7,6 +7,7 @@ import 'package:qs_dart/src/enums/list_format.dart'; import 'package:qs_dart/src/enums/sentinel.dart'; import 'package:qs_dart/src/extensions/extensions.dart'; import 'package:qs_dart/src/models/decode_options.dart'; +import 'package:qs_dart/src/models/encode_frame.dart'; import 'package:qs_dart/src/models/encode_options.dart'; import 'package:qs_dart/src/models/undefined.dart'; import 'package:qs_dart/src/utils.dart'; @@ -45,6 +46,7 @@ final class QS { static Map decode(dynamic input, [DecodeOptions? options]) { options ??= const DecodeOptions(); // Default to the library's safe, Node-`qs` compatible settings. + options.validate(); // Fail fast on unsupported input shapes to avoid ambiguous behavior. if (!(input is String? || input is Map?)) { @@ -102,6 +104,7 @@ final class QS { static String encode(Object? object, [EncodeOptions? options]) { options ??= const EncodeOptions(); // Use default encoding settings unless overridden by the caller. + options.validate(); // Normalize supported inputs into a mutable map we can traverse. Map obj = switch (object) { diff --git a/lib/src/utils.dart b/lib/src/utils.dart index 8917815..1defe05 100644 --- a/lib/src/utils.dart +++ b/lib/src/utils.dart @@ -6,8 +6,10 @@ import 'dart:typed_data' show ByteBuffer; import 'package:meta/meta.dart' show internal, visibleForTesting; import 'package:qs_dart/src/enums/format.dart'; +import 'package:qs_dart/src/enums/merge_phase.dart'; import 'package:qs_dart/src/extensions/extensions.dart'; import 'package:qs_dart/src/models/decode_options.dart'; +import 'package:qs_dart/src/models/merge_frame.dart'; import 'package:qs_dart/src/models/undefined.dart'; part 'constants/hex_table.dart'; @@ -92,180 +94,341 @@ final class Utils { dynamic source, [ DecodeOptions? options = const DecodeOptions(), ]) { - if (source == null) { - return target; - } + late dynamic result; + final List stack = [ + MergeFrame( + target: target, + source: source, + options: options, + onResult: (dynamic value) => result = value, + ), + ]; + + while (stack.isNotEmpty) { + final MergeFrame frame = stack.last; - if (source is! Map) { - if (target is Iterable) { - if (target.any((el) => el is Undefined)) { - // use a SplayTreeMap to keep the keys in order - final SplayTreeMap target_ = _toIndexedTreeMap(target); + if (frame.phase == MergePhase.start) { + final dynamic currentTarget = frame.target; + final dynamic currentSource = frame.source; + + if (currentSource == null) { + stack.removeLast(); + frame.onResult(currentTarget); + continue; + } - if (source is Iterable) { - for (final (int i, dynamic item) in source.indexed) { - if (item is! Undefined) { - target_[i] = item; + if (currentSource is! Map) { + if (currentTarget is Iterable) { + final bool sourceIsIterable = currentSource is Iterable; + bool hasHoles = false; + bool targetMaps = true; + bool targetHasMap = false; + for (final el in currentTarget) { + if (el is Undefined) { + hasHoles = true; + continue; + } + if (el is Map) { + targetHasMap = true; + continue; } + targetMaps = false; } - } else { - target_[target_.length] = source; - } - target = options?.parseLists == false && - target_.values.any((el) => el is Undefined) - ? SplayTreeMap.from({ - for (final MapEntry entry in target_.entries) - if (entry.value is! Undefined) entry.key: entry.value, - }) - : target is Set - ? target_.values.toSet() - : target_.values.toList(); - } else { - if (source is Iterable) { - // check if source is a list of maps and target is a list of maps - if (target.every((el) => el is Map || el is Undefined) && - source.every((el) => el is Map || el is Undefined)) { - // loop through the target list and merge the maps - // then loop through the source list and add any new maps - final SplayTreeMap target_ = - _toIndexedTreeMap(target); - for (final (int i, dynamic item) in source.indexed) { - target_.update( - i, - (value) => merge(value, item, options), - ifAbsent: () => item, - ); + bool sourceMaps = false; + bool sourceHasMap = false; + if (sourceIsIterable) { + sourceMaps = true; + for (final el in currentSource) { + if (el is Undefined) { + continue; + } + if (el is Map) { + sourceHasMap = true; + continue; + } + sourceMaps = false; } - if (target is Set) { - target = target_.values.toSet(); + } + + final bool canMergeMapLists = + sourceIsIterable && targetMaps && sourceMaps; + final bool hasAnyMap = targetHasMap || sourceHasMap; + + if (hasHoles && !(canMergeMapLists && hasAnyMap)) { + final SplayTreeMap target_ = + _toIndexedTreeMap(currentTarget); + + if (sourceIsIterable) { + for (final (int i, dynamic item) in currentSource.indexed) { + if (item is! Undefined) { + target_[i] = item; + } + } } else { - target = target_.values.toList(); + target_[target_.length] = currentSource; } - } else { - if (target is Set) { - target = Set.of(target) - ..addAll(source.whereNotType()); - } else { - target = List.of(target) - ..addAll(source.whereNotType()); + + if (frame.options?.parseLists == false && + target_.values.any((el) => el is Undefined)) { + final Map normalized = { + for (final MapEntry entry in target_.entries) + if (entry.value is! Undefined) + entry.key.toString(): entry.value, + }; + stack.removeLast(); + frame.onResult(normalized); + continue; } + + stack.removeLast(); + frame.onResult( + currentTarget is Set + ? target_.values.toSet() + : target_.values.toList(), + ); + continue; } - } else if (source != null) { - if (target is List) { - target.add(source); - } else if (target is Set) { - target.add(source); - } else { - target = [target, source]; + + if (sourceIsIterable) { + if (canMergeMapLists && hasAnyMap) { + frame.indexedTarget = _toIndexedTreeMap(currentTarget); + frame.sourceList = currentSource is List + ? currentSource + : currentSource.toList(growable: false); + frame.targetIsSet = currentTarget is Set; + frame.listIndex = 0; + frame.phase = MergePhase.listIter; + continue; + } + + stack.removeLast(); + frame.onResult( + currentTarget is Set + ? (Set.of(currentTarget) + ..addAll(currentSource.whereNotType())) + : (List.of(currentTarget) + ..addAll(currentSource.whereNotType())), + ); + continue; } + + if (currentTarget is List) { + final List merged = List.of(currentTarget) + ..add(currentSource); + stack.removeLast(); + frame.onResult(merged); + continue; + } + if (currentTarget is Set) { + final Set merged = Set.of(currentTarget) + ..add(currentSource); + stack.removeLast(); + frame.onResult(merged); + continue; + } + stack.removeLast(); + frame.onResult([currentTarget, currentSource]); + continue; + } else if (currentTarget is Map) { + if (currentSource is Iterable) { + final Map sourceMap = { + for (final (int i, dynamic item) in currentSource.indexed) + if (item is! Undefined) i.toString(): item, + }; + frame.source = sourceMap; + frame.phase = MergePhase.start; + continue; + } + if (isOverflow(currentTarget)) { + final int newIndex = _getOverflowIndex(currentTarget) + 1; + currentTarget[newIndex.toString()] = currentSource; + _setOverflowIndex(currentTarget, newIndex); + stack.removeLast(); + frame.onResult(currentTarget); + continue; + } + } else { + if (currentTarget is! Iterable && currentSource is Iterable) { + stack.removeLast(); + frame.onResult( + [currentTarget, ...currentSource.whereNotType()], + ); + continue; + } + stack.removeLast(); + frame.onResult([currentTarget, currentSource]); + continue; } + + stack.removeLast(); + frame.onResult(currentTarget); + continue; } - } else if (target is Map) { - if (source is Iterable) { - final Map sourceMap = { - for (final (int i, dynamic item) in source.indexed) - if (item is! Undefined) i.toString(): item - }; - return merge(target, sourceMap, options); - } - if (isOverflow(target)) { - final int newIndex = _getOverflowIndex(target) + 1; - target[newIndex.toString()] = source; - _setOverflowIndex(target, newIndex); - return target; - } - } else if (source != null) { - if (target is! Iterable && source is Iterable) { - return [target, ...source.whereNotType()]; - } - return [target, source]; - } - return target; - } + if (currentTarget == null || currentTarget is! Map) { + if (currentTarget is Iterable) { + final Map mergeTarget = { + for (final (int i, dynamic item) in currentTarget.indexed) + if (item is! Undefined) i.toString(): item, + }; + frame.mergeTarget = mergeTarget; + frame.mapIterator = currentSource.entries.iterator; + frame.overflowMax = null; + frame.phase = MergePhase.mapIter; + continue; + } - if (target == null || target is! Map) { - if (target is Iterable) { - final Map mergeTarget = { - for (final (int i, dynamic item) in target.indexed) - if (item is! Undefined) i.toString(): item, - }; - for (final MapEntry entry in source.entries) { - final String key = entry.key.toString(); - if (mergeTarget.containsKey(key)) { - mergeTarget[key] = merge( - mergeTarget[key], - entry.value, - options, - ); - } else { - mergeTarget[key] = entry.value; + if (isOverflow(currentSource)) { + final int sourceMax = _getOverflowIndex(currentSource); + final Map resultMap = { + if (currentTarget != null) '0': currentTarget, + }; + for (final MapEntry entry in currentSource.entries) { + final String key = entry.key.toString(); + final int? oldIndex = int.tryParse(key); + if (oldIndex == null) { + resultMap[key] = entry.value; + } else { + resultMap[(oldIndex + 1).toString()] = entry.value; + } + } + stack.removeLast(); + frame.onResult(markOverflow(resultMap, sourceMax + 1)); + continue; } + + stack.removeLast(); + frame.onResult( + [ + if (currentTarget is Iterable) + ...currentTarget.whereNotType() + else if (currentTarget != null) + currentTarget, + if (currentSource is Iterable) + ...(currentSource as Iterable).whereNotType() + else + currentSource, + ], + ); + continue; } - return mergeTarget; + + final bool targetOverflow = isOverflow(currentTarget); + final int? overflowMax = + targetOverflow ? _getOverflowIndex(currentTarget) : null; + + final Map mergeTarget = + currentTarget is Iterable && currentSource is! Iterable + ? { + for (final (int i, dynamic item) + in (currentTarget as Iterable).indexed) + if (item is! Undefined) i.toString(): item, + } + : { + for (final MapEntry entry in currentTarget.entries) + entry.key.toString(): entry.value, + }; + + frame.mergeTarget = mergeTarget; + frame.mapIterator = currentSource.entries.iterator; + frame.overflowMax = overflowMax; + frame.phase = MergePhase.mapIter; + continue; } - if (isOverflow(source)) { - final int sourceMax = _getOverflowIndex(source); - final Map result = { - if (target != null) '0': target, - }; - for (final MapEntry entry in source.entries) { + if (frame.phase == MergePhase.mapIter) { + if (frame.mapIterator!.moveNext()) { + final MapEntry entry = frame.mapIterator!.current; final String key = entry.key.toString(); - final int? oldIndex = int.tryParse(key); - if (oldIndex == null) { - result[key] = entry.value; - } else { - result[(oldIndex + 1).toString()] = entry.value; + + if (frame.overflowMax != null) { + frame.overflowMax = _updateOverflowMax(frame.overflowMax!, key); } + + if (frame.mergeTarget!.containsKey(key)) { + final dynamic childTarget = frame.mergeTarget![key]; + stack.add( + MergeFrame( + target: childTarget, + source: entry.value, + options: frame.options, + onResult: (dynamic value) { + frame.mergeTarget![key] = value; + }, + ), + ); + continue; + } + + frame.mergeTarget![key] = entry.value; + continue; } - return markOverflow(result, sourceMax + 1); - } - return [ - if (target is Iterable) - ...target.whereNotType() - else if (target != null) - target, - if (source is Iterable) - ...(source as Iterable).whereNotType() - else - source, - ]; - } + if (frame.overflowMax != null) { + markOverflow(frame.mergeTarget!, frame.overflowMax!); + } - final bool targetOverflow = isOverflow(target); - int? overflowMax = targetOverflow ? _getOverflowIndex(target) : null; + stack.removeLast(); + frame.onResult(frame.mergeTarget!); + continue; + } - Map mergeTarget = target is Iterable && source is! Iterable - ? { - for (final (int i, dynamic item) in (target as Iterable).indexed) - if (item is! Undefined) i.toString(): item - } - : { - for (final MapEntry entry in target.entries) - entry.key.toString(): entry.value + if (frame.phase != MergePhase.listIter) { + throw StateError('Invalid merge phase: ${frame.phase}'); + } + + if (frame.listIndex >= frame.sourceList!.length) { + if (frame.options?.parseLists == false && + frame.indexedTarget!.values.any((el) => el is Undefined)) { + final Map normalized = { + for (final MapEntry entry + in frame.indexedTarget!.entries) + if (entry.value is! Undefined) entry.key.toString(): entry.value, }; + stack.removeLast(); + frame.onResult(normalized); + continue; + } + final resultList = frame.targetIsSet + ? frame.indexedTarget!.values.toSet() + : frame.indexedTarget!.values.toList(); + stack.removeLast(); + frame.onResult(resultList); + continue; + } - for (final MapEntry entry in source.entries) { - if (overflowMax != null) { - overflowMax = _updateOverflowMax(overflowMax, entry.key.toString()); + final int idx = frame.listIndex++; + final dynamic item = frame.sourceList![idx]; + + if (frame.indexedTarget!.containsKey(idx)) { + final dynamic childTarget = frame.indexedTarget![idx]; + if (childTarget is Undefined) { + if (item is! Undefined) { + frame.indexedTarget![idx] = item; + } + continue; + } + if (item is Undefined) { + continue; + } + stack.add( + MergeFrame( + target: childTarget, + source: item, + options: frame.options, + onResult: (dynamic value) { + frame.indexedTarget![idx] = value; + }, + ), + ); + continue; } - mergeTarget.update( - entry.key.toString(), - (value) => merge( - value, - entry.value, - options, - ), - ifAbsent: () => entry.value, - ); - } - if (overflowMax != null) { - markOverflow(mergeTarget, overflowMax); + + frame.indexedTarget![idx] = item; } - return mergeTarget; + + return result; } /// Converts an iterable to a zero-indexed [SplayTreeMap]. @@ -422,6 +585,14 @@ final class Utils { Encoding charset = utf8, Format? format = Format.rfc3986, }) { + if (charset != utf8 && charset != latin1) { + throw ArgumentError.value( + charset, + 'charset', + 'Invalid charset; only utf8 and latin1 are supported', + ); + } + // these can not be encoded if (value is Iterable || value is Map || @@ -433,7 +604,11 @@ final class Utils { } final String? str = value is ByteBuffer - ? charset.decode(value.asUint8List()) + ? (charset == utf8 + // Match Node Buffer.toString('utf8'): replace malformed sequences. + // Strict decoding would throw and break Node qs parity for raw byte buffers. + ? utf8.decode(value.asUint8List(), allowMalformed: true) + : latin1.decode(value.asUint8List())) : value?.toString(); if (str?.isEmpty ?? true) { diff --git a/test/support/fake_encoding.dart b/test/support/fake_encoding.dart new file mode 100644 index 0000000..99fa75e --- /dev/null +++ b/test/support/fake_encoding.dart @@ -0,0 +1,15 @@ +import 'dart:convert' show Converter, Encoding, utf8; + +/// Shared fake encoding used by charset validation tests. +class FakeEncoding extends Encoding { + const FakeEncoding(); + + @override + String get name => 'fake'; + + @override + Converter, String> get decoder => utf8.decoder; + + @override + Converter> get encoder => utf8.encoder; +} diff --git a/test/unit/decode_test.dart b/test/unit/decode_test.dart index bf4e860..4b05881 100644 --- a/test/unit/decode_test.dart +++ b/test/unit/decode_test.dart @@ -21,7 +21,6 @@ void main() { ), throwsA(anyOf( isA(), - isA(), isA(), )), ); @@ -1539,10 +1538,13 @@ void main() { }); group('charset', () { - test('throws an AssertionError when given an unknown charset', () { + test('throws when given an unknown charset', () { expect( () => QS.decode('a=b', DecodeOptions(charset: ShiftJIS())), - throwsA(isA()), + throwsA(anyOf( + isA(), + isA(), + )), ); }); @@ -2348,7 +2350,6 @@ void main() { 'a%2Eb=c', DecodeOptions(allowDots: false, decodeDotInKeys: true)), throwsA(anyOf( isA(), - isA(), isA(), )), ); @@ -2504,7 +2505,6 @@ void main() { DecodeOptions(allowDots: false, decodeDotInKeys: true)), throwsA(anyOf( isA(), - isA(), isA(), )), ); @@ -2800,7 +2800,6 @@ void main() { 'a[%2e]=x', DecodeOptions(allowDots: false, decodeDotInKeys: true)), throwsA(anyOf( isA(), - isA(), isA(), )), ); @@ -2965,6 +2964,24 @@ void main() { })); }); + test('depth=0 keeps the original key intact (strictDepth ignored)', () { + final decoded = QS.decode( + 'a[b]=1', + const DecodeOptions(depth: 0, strictDepth: true), + ); + + expect(decoded, equals({'a[b]': '1'})); + }); + + test('depth=0 keeps the original key intact', () { + final decoded = QS.decode( + 'a[b]=1', + const DecodeOptions(depth: 0), + ); + + expect(decoded, equals({'a[b]': '1'})); + }); + test('parameterLimit < 1 coerces to zero and triggers argument error', () { expect( () => QS.decode( diff --git a/test/unit/encode_edge_cases_test.dart b/test/unit/encode_edge_cases_test.dart index d0e27ef..0785f05 100644 --- a/test/unit/encode_edge_cases_test.dart +++ b/test/unit/encode_edge_cases_test.dart @@ -35,6 +35,45 @@ class _Dyn extends MapBase { int get length => _store.length; } +class _WeirdMap extends MapBase { + final Map _store = {'real': 1}; + + @override + dynamic operator [](Object? key) { + if (key == 'ghost') return {'nested': 1}; + return _store[key]; + } + + @override + void operator []=(String key, dynamic value) => _store[key] = value; + + @override + void clear() => _store.clear(); + + @override + Iterable get keys => const ['ghost']; + + @override + dynamic remove(Object? key) => _store.remove(key); + + @override + bool containsKey(Object? key) => key != 'ghost' && _store.containsKey(key); + + @override + int get length => 1; +} + +class _Indexable { + _Indexable(this._store); + + final Map _store; + + dynamic operator [](Object? key) => _store[key]; + + @override + String toString() => 'indexable'; +} + void main() { group('encode edge cases', () { test('cycle detection: shared subobject visited twice without throwing', @@ -58,6 +97,36 @@ void main() { expect(encoded, 'nil'); }); + test('filter can null-out a map while strictNullHandling preserves the key', + () { + final encoded = QS.encode( + { + 'obj': {'k': 'v'} + }, + EncodeOptions( + strictNullHandling: true, + encode: false, + filter: (prefix, value) => prefix == 'obj' ? null : value, + ), + ); + + expect(encoded, 'obj'); + }); + + test('filter can collapse a map into a scalar', () { + final encoded = QS.encode( + { + 'obj': {'k': 'v'} + }, + EncodeOptions( + encode: false, + filter: (prefix, value) => prefix == 'obj' ? 'x' : value, + ), + ); + + expect(encoded, 'obj=x'); + }); + test('filter iterable branch on MapBase with throwing key access', () { final dyn = _Dyn(); // Encode the MapBase directly with a filter that forces lookups for both 'ok' @@ -67,6 +136,11 @@ void main() { expect(encoded, 'ok=42'); }); + test('undefined map entries still clear side-channel tracking', () { + final encoded = QS.encode({'root': _WeirdMap()}); + expect(encoded, isEmpty); + }); + test('comma list empty emits nothing but executes Undefined sentinel path', () { final encoded = QS.encode( @@ -136,6 +210,17 @@ void main() { expect(encoded, matches(pattern)); }); + test('custom objects encode via toString', () { + final encoded = QS.encode( + { + 'root': _Indexable({'root': 'ok'}), + }, + const EncodeOptions(encode: false), + ); + + expect(encoded, 'root=indexable'); + }); + test('cycle detection step reset path (multi-level shared object)', () { // Construct a deeper object graph where the same shared leaf appears in // branches of differing depth to exercise the while-loop step reset logic. @@ -161,6 +246,17 @@ void main() { expect(occurrences, 2); expect(encoded.contains('c=2'), isTrue); }); + + test('encodes deep nesting without stack overflow', () { + const depth = 2048; + Map obj = {'leaf': 'x'}; + for (var i = 0; i < depth; i++) { + obj = {'a': obj}; + } + + final encoded = QS.encode(obj); + expect(encoded, contains('leaf')); + }); }); } diff --git a/test/unit/encode_test.dart b/test/unit/encode_test.dart index 3b33178..e7c4d3d 100644 --- a/test/unit/encode_test.dart +++ b/test/unit/encode_test.dart @@ -108,6 +108,19 @@ void main() { expect(result, equals('obj[prop]=test')); }); + + test('filter iterable exercises dynamic indexer path', () { + final result = QS.encode( + {'u': const Undefined()}, + const EncodeOptions( + encode: false, + filter: ['u'], + ), + ); + + expect(result, isEmpty); + }); + test('encodes a query string map', () { expect(QS.encode({'a': 'b'}), equals('a=b')); expect(QS.encode({'a': 1}), equals('a=1')); @@ -3179,6 +3192,16 @@ void main() { expect(QS.encode({'a': utf8.encode('test').buffer}), equals('a=test')); }); + test('encodes a buffer value without encoding', () { + expect( + QS.encode( + {'a': latin1.encode('ä').buffer}, + const EncodeOptions(encode: false, charset: latin1), + ), + equals('a=ä'), + ); + }); + test('encodes a date value', () { final DateTime now = DateTime.now(); final String str = 'a=${Uri.encodeComponent(now.toIso8601String())}'; diff --git a/test/unit/models/decode_options_test.dart b/test/unit/models/decode_options_test.dart index 872954d..63c0463 100644 --- a/test/unit/models/decode_options_test.dart +++ b/test/unit/models/decode_options_test.dart @@ -1,11 +1,13 @@ // ignore_for_file: deprecated_member_use_from_same_package import 'dart:convert'; -import 'package:qs_dart/src/enums/decode_kind.dart'; import 'package:qs_dart/src/enums/duplicates.dart'; import 'package:qs_dart/src/models/decode_options.dart'; +import 'package:qs_dart/src/qs.dart'; import 'package:test/test.dart'; +import '../../support/fake_encoding.dart'; + void main() { group('DecodeOptions', () { test('copyWith no modifications', () { @@ -96,6 +98,21 @@ void main() { expect(newOptions.strictNullHandling, isFalse); }); + test('copyWith preserves throwOnLimitExceeded', () { + const DecodeOptions options = DecodeOptions(throwOnLimitExceeded: true); + final DecodeOptions newOptions = options.copyWith(); + + expect(newOptions.throwOnLimitExceeded, isTrue); + }); + + test('copyWith overrides throwOnLimitExceeded', () { + const DecodeOptions options = DecodeOptions(throwOnLimitExceeded: false); + final DecodeOptions newOptions = + options.copyWith(throwOnLimitExceeded: true); + + expect(newOptions.throwOnLimitExceeded, isTrue); + }); + test('toString', () { final DecodeOptions options = const DecodeOptions( allowDots: true, @@ -134,6 +151,7 @@ void main() { ' parameterLimit: 100,\n' ' parseLists: false,\n' ' strictDepth: false,\n' + ' throwOnLimitExceeded: false,\n' ' strictNullHandling: true\n' ')', ), @@ -142,12 +160,14 @@ void main() { }); group('DecodeOptions – allowDots / decodeDotInKeys interplay', () { - test('constructor: allowDots=false + decodeDotInKeys=true throws', () { + test('allowDots=false + decodeDotInKeys=true throws on use', () { expect( - () => DecodeOptions(allowDots: false, decodeDotInKeys: true), + () => QS.decode( + 'a=b', + DecodeOptions(allowDots: false, decodeDotInKeys: true), + ), throwsA(anyOf( isA(), - isA(), isA(), )), ); @@ -156,10 +176,32 @@ void main() { test('copyWith: making options inconsistent throws', () { final base = const DecodeOptions(decodeDotInKeys: true); expect( - () => base.copyWith(allowDots: false), + () => QS.decode('a=b', base.copyWith(allowDots: false)), + throwsA(anyOf( + isA(), + isA(), + )), + ); + }); + }); + + group('DecodeOptions runtime validation', () { + test('throws for invalid charset', () { + expect( + // ignore: prefer_const_constructors + () => QS.decode('a=b', DecodeOptions(charset: FakeEncoding())), + throwsA(anyOf( + isA(), + isA(), + )), + ); + }); + + test('throws for NaN parameterLimit', () { + expect( + () => QS.decode('a=b', DecodeOptions(parameterLimit: double.nan)), throwsA(anyOf( isA(), - isA(), isA(), )), ); @@ -324,10 +366,9 @@ void main() { () { final original = const DecodeOptions(decodeDotInKeys: true); expect( - () => original.copyWith(allowDots: false), + () => QS.decode('a=b', original.copyWith(allowDots: false)), throwsA(anyOf( isA(), - isA(), isA(), ))); }); diff --git a/test/unit/models/encode_options_test.dart b/test/unit/models/encode_options_test.dart index 86efefd..320a5b9 100644 --- a/test/unit/models/encode_options_test.dart +++ b/test/unit/models/encode_options_test.dart @@ -3,8 +3,11 @@ import 'dart:convert'; import 'package:qs_dart/src/enums/format.dart'; import 'package:qs_dart/src/enums/list_format.dart'; import 'package:qs_dart/src/models/encode_options.dart'; +import 'package:qs_dart/src/qs.dart'; import 'package:test/test.dart'; +import '../../support/fake_encoding.dart'; + void main() { group('EncodeOptions', () { test('copyWith no modifications', () { @@ -145,4 +148,22 @@ void main() { ); }); }); + + group('EncodeOptions runtime validation', () { + test('throws for invalid charset', () { + final opts = const EncodeOptions(charset: FakeEncoding()); + expect( + () => QS.encode({'a': 'b'}, opts), + throwsA(isA()), + ); + }); + + test('throws for invalid filter', () { + final opts = const EncodeOptions(filter: 123); + expect( + () => QS.encode({'a': 'b'}, opts), + throwsA(isA()), + ); + }); + }); } diff --git a/test/unit/uri_extension_test.dart b/test/unit/uri_extension_test.dart index 5289a71..c34ba41 100644 --- a/test/unit/uri_extension_test.dart +++ b/test/unit/uri_extension_test.dart @@ -1342,11 +1342,14 @@ void main() { }); group('charset', () { - test('throws an AssertionError when given an unknown charset', () { + test('throws when given an unknown charset', () { expect( () => Uri.parse('$testUrl?a=b') .queryParametersQs(DecodeOptions(charset: ShiftJIS())), - throwsA(isA()), + throwsA(anyOf( + isA(), + isA(), + )), ); }); diff --git a/test/unit/utils_test.dart b/test/unit/utils_test.dart index 6af029d..2896013 100644 --- a/test/unit/utils_test.dart +++ b/test/unit/utils_test.dart @@ -1,12 +1,14 @@ // ignore_for_file: deprecated_member_use_from_same_package -import 'dart:collection'; import 'dart:convert' show latin1, utf8; +import 'dart:typed_data' show Uint8List; import 'package:qs_dart/qs_dart.dart'; import 'package:qs_dart/src/utils.dart'; import 'package:test/test.dart'; +import '../support/fake_encoding.dart'; + import '../fixtures/dummy_enum.dart'; void main() { @@ -50,6 +52,23 @@ void main() { ); }); + test('encode throws for invalid charset', () { + expect( + () => Utils.encode('x', charset: const FakeEncoding()), + throwsA(isA()), + ); + }); + + test('encode ByteBuffer uses utf8 decoding', () { + final buffer = Uint8List.fromList([0x68, 0x69]).buffer; + expect(Utils.encode(buffer, charset: utf8), equals('hi')); + }); + + test('encode ByteBuffer uses latin1 decoding', () { + final buffer = Uint8List.fromList([0xE4]).buffer; + expect(Utils.encode(buffer, charset: latin1), equals('%E4')); + }); + test('encode huge string', () { final String hugeString = 'a' * 1000000; expect(Utils.encode(hugeString), equals(hugeString)); @@ -1298,13 +1317,76 @@ void main() { test('normalizes to map when Undefined persists and parseLists is false', () { final result = Utils.merge( - [const Undefined()], - const [Undefined()], + [const Undefined(), 'keep'], + const [Undefined(), 'add'], const DecodeOptions(parseLists: false), ); - final splay = result as SplayTreeMap; - expect(splay.isEmpty, isTrue); + final map = result as Map; + expect(map, equals({'1': 'add'})); + }); + + test('overflow merge keeps non-numeric keys', () { + final overflow = Utils.markOverflow({'foo': 'bar', '0': 'x'}, 0); + final result = Utils.merge(null, overflow); + + expect(result, isA>()); + final map = result as Map; + expect(map['foo'], equals('bar')); + expect(map['1'], equals('x')); + }); + + test('wraps scalar targets into list with map element when merging maps', + () { + final result = Utils.merge('seed', {'a': 'b'}); + expect(result, isA()); + final list = result as List; + expect(list.first, equals('seed')); + final map = list[1] as Map; + expect(map, equals({'a': 'b'})); + }); + + test('list-merge normalizes to map when Undefined persists', () { + final result = Utils.merge( + [ + {'a': 1}, + const Undefined(), + {'b': 2}, + ], + [ + {'a': 2} + ], + const DecodeOptions(parseLists: false), + ); + + expect(result, isA>()); + final map = result as Map; + expect(map.containsKey('1'), isFalse); + expect( + map['0'], + equals({ + 'a': [1, 2] + })); + expect(map['2'], equals({'b': 2})); + }); + + test('list-merge replaces holes when merging maps by index', () { + final result = Utils.merge( + [ + const Undefined(), + {'a': 1} + ], + [ + {'a': 2} + ], + ); + + expect( + result, + equals([ + {'a': 2}, + {'a': 1} + ])); }); test('combines non-iterable scalars into a list pair', () { @@ -1349,6 +1431,33 @@ void main() { expect(result.first, equals('seed')); expect(result.last, equals({'extra': 1})); }); + + test('merges deep maps without stack overflow', () { + const depth = 2048; + Map left = {}; + Map cursor = left; + for (var i = 0; i < depth; i++) { + final next = {}; + cursor['a'] = next; + cursor = next; + } + + Map right = {}; + Map rightCursor = right; + for (var i = 0; i < depth; i++) { + final next = {}; + rightCursor['a'] = next; + rightCursor = next; + } + rightCursor['leaf'] = 'x'; + + final merged = Utils.merge(left, right) as Map; + dynamic walk = merged; + for (var i = 0; i < depth; i++) { + walk = (walk as Map)['a']; + } + expect((walk as Map)['leaf'], equals('x')); + }); }); group('Utils.encode surrogate handling', () {