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
8 changes: 7 additions & 1 deletion runtime/python/prompty/prompty/parsers/prompty.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,15 @@

# Role boundary regex — matches lines like ``system:`` or ``user[name="Alice"]:``
_ROLE_NAMES = "|".join(sorted(ROLES))
# Possessive quantifiers + a quoted/unquoted alternation (no shared chars between
# branches) keep this linear-time — a plain `\w+\s*=\s*"?[^"]*"?` value class lets
# unquoted attributes overlap with the separator/`]`, causing catastrophic
# backtracking on malformed input. See issue #446.
# Possessive quantifiers (`++`, `*+`, `?+`) require Python 3.11+, which pyproject.toml
# already sets as this package's floor (`requires-python = ">=3.11"`).
_BOUNDARY_RE = re.compile(
r"(?im)^\s*#?\s*(" + _ROLE_NAMES + r")"
r"(\[((\w+\s*=\s*\"?[^\"]*\"?\s*,?\s*)+)\])?\s*:\s*$"
r"(\[(?:\w++\s*+=\s*+(?:\"[^\"]*\"|[^\",\]]*+)\s*+,?+\s*+)+\])?\s*:\s*$"
)
Comment on lines +31 to 40

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already covered — pyproject.toml has requires-python = ">=3.11", so the possessive-quantifier syntax can't hit an older interpreter. No change needed here.

Comment on lines 37 to 40

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is pre-existing, not new to this PR:

requires-python = ">=3.11"
already pins requires-python = ">=3.11" on main, unchanged by this diff. Possessive quantifiers are safe on the package's actual floor.

Comment on lines +31 to 40

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same point as above (already answered on the earlier duplicate at #discussion_r3732083116 and r3732328648) — pyproject.toml has pinned requires-python = ">=3.11" on main since before this PR; unaffected by this diff.

Comment on lines +31 to 40

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same already-answered point (see r3732083116, r3732328648, r3732454506) — pinned by pyproject.toml's requires-python = ">=3.11" on main, unaffected by this diff. Pushed e967c0a to state the floor directly in the code comment next to the regex so it's visible without cross-referencing pyproject.toml.



Expand Down
43 changes: 43 additions & 0 deletions runtime/python/prompty/tests/test_parsers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from __future__ import annotations

import time

import pytest

from prompty.core.types import Message, TextPart
Expand Down Expand Up @@ -257,6 +259,47 @@ def test_no_images_all_text(self):
assert isinstance(result[0].parts[0], TextPart)


# ---------------------------------------------------------------------------
# ReDoS regression (issue #446)
# ---------------------------------------------------------------------------


class TestRoleBoundaryReDoS:
"""Parsing must stay roughly linear in input size on adversarial input.

Unquoted, unterminated attributes used to let the value class overlap
with the `,` separator and closing `]`, giving the backtracking engine
an exponential number of equivalent splits to try before failing.
The assertion is mostly relative (runtime at 40 reps vs. 20 reps) so it
stays robust on a slow/contended CI runner; the small absolute floor
only keeps the relative bound from collapsing to noise when `small`
itself measures near zero on a very fast run — it is not the budget
being enforced.
"""

def setup_method(self):
self.parser = PromptyChatParser()
self.agent = _make_agent()

def _time_adversarial_parse(self, reps: int, samples: int = 20) -> float:
"""Best-of-`samples` timing to smooth out scheduler/CPU jitter."""
payload = "user[" + "a=b," * reps + "!"
best = float("inf")
for _ in range(samples):
start = time.perf_counter()
self.parser.parse(self.agent, payload)
best = min(best, time.perf_counter() - start)
return best

def test_doubling_input_does_not_blow_up_runtime(self):
small = self._time_adversarial_parse(20)
large = self._time_adversarial_parse(40)
# Exponential (catastrophic-backtracking) behavior would roughly
# square the runtime when the repeat count doubles; linear-time
# matching keeps it within a small constant factor.
assert large < max(small * 20, 0.1)


# ---------------------------------------------------------------------------
# Async
# ---------------------------------------------------------------------------
Expand Down