Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## 1.6.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 or decoding their values when `raise_on_limit_exceeded` is enabled
* [CHORE] add Node `qs` 6.15.3 regression coverage for unbalanced bracket keys and cyclic compaction

## 1.6.0

* [CHORE] make `Undefined` internal by removing `qs_codec.Undefined` and its public documentation
Expand Down
31 changes: 24 additions & 7 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Highlights
- Pluggable hooks: custom ``encoder``/``decoder`` callables; options to sort keys, filter output, and control percent-encoding (keys-only, values-only).
- Nulls & empties: ``strict_null_handling`` and ``skip_nulls``; support for empty lists/arrays when desired.
- Dates: ``serialize_date`` for ISO 8601 or custom (e.g., UNIX timestamp).
- Safety limits: configurable decode depth and encode max depth, parameter limit, and list index limit; optional strict-depth errors; duplicate-key strategies (combine/first/last).
- Safety limits: configurable decode depth and encode max depth, parameter limit, and list element limit; optional strict-depth errors; duplicate-key strategies (combine/first/last).
- Extras: numeric entity decoding (e.g. ``☺`` → ☺), alternate delimiters/regex, and query-prefix helpers.

Compatibility
Expand Down Expand Up @@ -497,12 +497,11 @@ Note that an empty ``str``\ing is also a value, and will be preserved:

assert qs.decode('a[0]=b&a[1]=&a[2]=c') == {'a': ['b', '', 'c']}

`decode <https://techouse.github.io/qs_codec/qs_codec.html#module-qs_codec.decode>`__ will also limit specifying indices
in a ``list`` to a maximum index of ``20``. Any ``list`` members with an
index of greater than ``20`` will instead be converted to a ``dict`` with
the index as the key. This is needed to handle cases when someone sent,
for example, ``a[999999999]`` and it will take significant time to iterate
over this huge ``list``.
`decode <https://techouse.github.io/qs_codec/qs_codec.html#module-qs_codec.decode>`__ also limits each ``list`` to a
maximum element count of ``20``. Index ``19`` is the last index that can create
a default ``list``; index ``20`` and higher are converted to a ``dict`` with
the index as the key. This prevents inputs such as ``a[999999999]`` from
creating massive sparse lists.

.. code:: python

Expand All @@ -522,6 +521,24 @@ option:
qs.DecodeOptions(list_limit=0),
) == {'a': {'1': 'b'}}

The same limit is enforced cumulatively when duplicate keys, mixed list
notation, or comma-separated values grow a list. A result exactly at the limit
remains a ``list``. Above the limit, decoding uses a numeric-keyed ``dict`` by
default, or raises ``ValueError`` when ``raise_on_limit_exceeded=True``.

.. code:: python

import qs_codec as qs

assert qs.decode(
'a=x&a=y',
qs.DecodeOptions(list_limit=1),
) == {'a': {'0': 'x', '1': 'y'}}

With ``comma=True``, a flat comma value is subject to the same limit. A value
assigned through ``[]=`` counts as one outer list element, so its inner
comma-separated group may contain more values than ``list_limit``.

To disable ``list`` parsing entirely, set `parse_lists <https://techouse.github.io/qs_codec/qs_codec.models.html#qs_codec.models.decode_options.DecodeOptions.parse_lists>`__
to ``False``.

Expand Down
19 changes: 19 additions & 0 deletions docs/README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,25 @@ option:
qs.DecodeOptions(list_limit=0),
) == {'a': {'1': 'b'}}

The same limit is enforced cumulatively when duplicate keys, mixed list
notation, or comma-separated values grow a list. A result exactly at the limit
remains a ``list``. Above the limit, decoding uses a numeric-keyed ``dict`` by
default, or raises ``ValueError`` when
:py:attr:`raise_on_limit_exceeded <qs_codec.models.decode_options.DecodeOptions.raise_on_limit_exceeded>` is ``True``.

.. code:: python

import qs_codec as qs

assert qs.decode(
'a=x&a=y',
qs.DecodeOptions(list_limit=1),
) == {'a': {'0': 'x', '1': 'y'}}

With ``comma=True``, a flat comma value is subject to the same limit. A value
assigned through ``[]=`` counts as one outer list element, so its inner
comma-separated group may contain more values than ``list_limit``.

To disable ``list`` parsing entirely, set :py:attr:`parse_lists <qs_codec.models.decode_options.DecodeOptions.parse_lists>`
to ``False``.

Expand Down
2 changes: 1 addition & 1 deletion docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Highlights
- Pluggable hooks: custom ``encoder``/``decoder`` callables; options to sort keys, filter output, and control percent-encoding (keys-only, values-only).
- Nulls & empties: ``strict_null_handling`` and ``skip_nulls``; support for empty lists/arrays when desired.
- Dates: ``serialize_date`` for ISO 8601 or custom (e.g., UNIX timestamp).
- Safety limits: configurable decode depth and encode max depth, parameter limit, and list index limit; optional strict-depth errors; duplicate-key strategies (combine/first/last).
- Safety limits: configurable decode depth and encode max depth, parameter limit, and list element limit; optional strict-depth errors; duplicate-key strategies (combine/first/last).
- Extras: numeric entity decoding (e.g. ``&#9786;`` → ☺), alternate delimiters/regex, and query-prefix helpers.

Compatibility
Expand Down
11 changes: 8 additions & 3 deletions skills/qs-codec/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,8 +184,11 @@ Use these options with `qs.decode(query, qs.DecodeOptions(...))`:
list; use `qs.Duplicates.FIRST` or `qs.Duplicates.LAST` to collapse.
- Bracket lists: enabled by default; set `parse_lists=False` to treat list
syntax as dictionary keys.
- Large or sparse list indices: default `list_limit` is `20`; indices above the
limit become dictionary keys.
- List limits: default `list_limit` is `20`; numeric indices at or above the
limit become dictionary keys. The limit also applies cumulatively to lists
grown by duplicate keys, mixed notation, or comma-separated values. Exact-limit
results remain lists; soft overflow becomes a numeric-keyed dictionary, while
`raise_on_limit_exceeded=True` raises `ValueError`.
- Comma-separated values such as `a=b,c`: `comma=True`.
- Tokens without `=` as `None`: `strict_null_handling=True`.
- Custom delimiters: `delimiter=";"` or `delimiter=re.compile(r"[;,]")`.
Expand Down Expand Up @@ -279,7 +282,9 @@ Warn or adjust before giving code for these cases:
silently truncating.
- `list_limit` has nuanced list-construction behavior; negative values disable
numeric-index list parsing, and `raise_on_limit_exceeded=True` turns list
limit violations into `ValueError`.
limit violations into `ValueError`. With `comma=True`, a flat comma value is
checked before value decoding, while a comma group assigned through `[]=`
counts as one outer list element.
- Built-in charset handling supports only `qs.Charset.UTF8` and
`qs.Charset.LATIN1`; other encodings require a custom `encoder` or `decoder`.
- `EncodeOptions.encoder` is ignored when `encode=False`.
Expand Down
29 changes: 13 additions & 16 deletions src/qs_codec/decode.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@ def decode(
Notes
-----
- Empty/falsey ``value`` returns an empty dict.
- When the *number of top-level tokens* exceeds ``list_limit`` and ``parse_lists`` is enabled, the parser temporarily **disables list parsing** for this invocation to avoid quadratic work. This mirrors the behavior of other ports and keeps large flat query strings efficient.
- ``parse_lists`` is honored directly throughout decoding. ``list_limit`` is enforced while constructing and
merging lists without changing the configured list-parsing mode.
"""
obj: t.Dict[str, t.Any] = {}

Expand All @@ -78,17 +79,6 @@ def decode(
str_value: str = t.cast(str, value) if decode_from_string else ""
mapping_value: t.Mapping[str, t.Any] = t.cast(t.Mapping[str, t.Any], value) if not decode_from_string else {}

parse_lists_effective: bool = opts.parse_lists
if decode_from_string and parse_lists_effective:
# Keep caller options immutable: compute a local parse_lists switch only for this invocation.
query = str_value.replace("?", "", 1) if opts.ignore_query_prefix else str_value
if isinstance(opts.delimiter, re.Pattern):
parts_count = len(re.split(opts.delimiter, query)) if query else 0
else:
parts_count = (query.count(opts.delimiter) + 1) if query else 0
if 0 < opts.list_limit < parts_count:
parse_lists_effective = False

if decode_from_string:
temp_obj: t.Optional[t.Dict[str, t.Any]] = _parse_query_string_values(str_value, opts)
else:
Comment thread
techouse marked this conversation as resolved.
Expand Down Expand Up @@ -118,7 +108,7 @@ def decode(
obj[key] = val
continue

new_obj: t.Any = _parse_keys(key, val, opts, decode_from_string, parse_lists=parse_lists_effective)
new_obj: t.Any = _parse_keys(key, val, opts, decode_from_string, parse_lists=opts.parse_lists)

if not obj and isinstance(new_obj, dict):
obj = new_obj
Expand Down Expand Up @@ -253,10 +243,17 @@ def _parse_array_value(
Either the original value or a list of values, without decoding (that happens later).
"""
if isinstance(value, str) and value and options.comma and "," in value:
if enforce_comma_limit and options.raise_on_limit_exceeded:
comma_count = 0
comma_index = value.find(",")
while comma_index >= 0:
comma_count += 1
if comma_count >= options.list_limit:
raise ValueError(_list_limit_exceeded_message(options.list_limit))
comma_index = value.find(",", comma_index + 1)

split_val: t.List[str] = value.split(",")
if enforce_comma_limit and len(split_val) > options.list_limit:
if options.raise_on_limit_exceeded:
raise ValueError(_list_limit_exceeded_message(options.list_limit))
return CommaOverflowDict({str(i): item for i, item in enumerate(split_val)})
return split_val

Expand Down Expand Up @@ -384,7 +381,7 @@ def _parse_query_string_values(value: str, options: DecodeOptions) -> t.Dict[str
part[pos + 1 :],
options,
len(obj[key]) if key in obj and isinstance(obj[key], (list, tuple)) else 0,
enforce_comma_limit=False,
enforce_comma_limit=not bracket_array_assignment,
)
list_limit_exceeded = isinstance(parsed_value, (list, tuple)) and len(parsed_value) > options.list_limit
if isinstance(parsed_value, (list, tuple)):
Expand Down
10 changes: 7 additions & 3 deletions src/qs_codec/models/decode_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,11 @@ class DecodeOptions:
limit, index ``19`` is the last index that can create a list; index ``20`` already
overflows to a ``dict``.

This limit also applies to decoded list growth from comma-split values when ``comma=True``.
This limit also applies cumulatively to list growth from duplicate keys, mixed list
notation, and comma-split values when ``comma=True``. A result exactly at the limit remains
a list. Above the limit, decoding either uses a numeric-keyed mapping or raises ``ValueError``
when ``raise_on_limit_exceeded=True``.

For bracket-array assignments such as ``foo[]=1,2,3``, the comma-split payload is wrapped
as a single outer list element, so the inner payload may contain more values than
``list_limit`` while still respecting the outer container limit.
Expand Down Expand Up @@ -122,11 +126,11 @@ class DecodeOptions:
"""Raise instead of degrading gracefully when limits are exceeded.

When ``True``, the decoder raises:
- a ``DecodeError`` for parameter and list limit violations; and
- a ``ValueError`` for parameter and list limit violations; and
- an ``IndexError`` when nesting deeper than ``depth`` **and** ``strict_depth=True``.

When ``False`` (default), the decoder degrades gracefully: it slices the parameter list
at ``parameter_limit``, stops adding items beyond ``list_limit``, and—if
at ``parameter_limit``, represents list overflows as numeric-keyed mappings, and—if
``strict_depth=True``—stops descending once ``depth`` is reached without raising.
"""

Expand Down
Loading