From f7400a35cbe842dd19db7ce7f9c99c827dabbcb8 Mon Sep 17 00:00:00 2001 From: Jon Bogaty Date: Mon, 24 Aug 2026 12:17:12 -0500 Subject: [PATCH] fix: remove numeric regex backtracking --- .../src/extended_data/primitives/types.py | 21 +++++++++++++++---- .../tests/core/test_type_utils.py | 5 +++++ uv.lock | 4 ++-- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/packages/extended-data/src/extended_data/primitives/types.py b/packages/extended-data/src/extended_data/primitives/types.py index c49c658..c754c14 100644 --- a/packages/extended-data/src/extended_data/primitives/types.py +++ b/packages/extended-data/src/extended_data/primitives/types.py @@ -23,7 +23,6 @@ - DATETIME_PATTERN: Regex for matching ISO 8601 datetime strings. - TIME_PATTERN: Regex for matching time strings. - INTEGER_PATTERN: Regex for matching integer strings. - - NUMBER_PATTERN: Regex for matching numeric strings. - TRUTHY_PATTERN: Regex for matching truthy strings. - FALSY_PATTERN: Regex for matching falsy strings. """ @@ -54,7 +53,6 @@ ) # Matches extended datetime formats like YYYY-MM-DDTHH:MM[:SS][.fff][Z|±hh:mm] TIME_PATTERN: re.Pattern[str] = re.compile(r"^\d{2}:\d{2}(:\d{2}(\.\d{1,6})?)?$") # Matches HH:MM[:SS] and microseconds INTEGER_PATTERN: re.Pattern[str] = re.compile(r"^-?\d+$") -NUMBER_PATTERN: re.Pattern[str] = re.compile(r"^-?\d+(\.\d+)?$") TRUTHY_PATTERN: re.Pattern[str] = re.compile(r"^(y|yes|t|true|on|1)$", re.IGNORECASE) FALSY_PATTERN: re.Pattern[str] = re.compile(r"^(n|no|f|false|off|0)$", re.IGNORECASE) @@ -85,6 +83,21 @@ def _is_valid_absolute_path_string(value: str) -> bool: ) +def _is_decimal_number_string(value: str) -> bool: + """Return whether *value* uses the package's integer or decimal syntax. + + A tiny parser keeps untrusted numeric text out of a backtracking regular + expression. It intentionally accepts only an optional leading minus sign, + one non-empty integer component, and an optional non-empty fractional + component, matching the existing conversion contract. + """ + unsigned = value.removeprefix("-") + integer_part, separator, fractional_part = unsigned.partition(".") + if not integer_part.isdecimal(): + return False + return not separator or fractional_part.isdecimal() + + class ConversionError(ValueError): """Custom error class for handling conversion failures. @@ -182,7 +195,7 @@ def string_to_float(val: str, raise_on_error: bool = False) -> float | None: ConversionError: If the value is invalid and raise_on_error is True. """ val = str(val) - if NUMBER_PATTERN.match(val): + if _is_decimal_number_string(val): try: return float(val) except ValueError as exc: @@ -463,7 +476,7 @@ def reconstruct_special_type(converted_obj: str, fail_silently: bool = False) -> return Path(converted_obj) if TRUTHY_PATTERN.match(converted_obj) or FALSY_PATTERN.match(converted_obj): return string_to_bool(converted_obj) - if NUMBER_PATTERN.match(converted_obj): + if _is_decimal_number_string(converted_obj): if INTEGER_PATTERN.match(converted_obj): return string_to_int(converted_obj) return string_to_float(converted_obj) diff --git a/packages/extended-data/tests/core/test_type_utils.py b/packages/extended-data/tests/core/test_type_utils.py index 2443c46..c0e142a 100644 --- a/packages/extended-data/tests/core/test_type_utils.py +++ b/packages/extended-data/tests/core/test_type_utils.py @@ -284,6 +284,11 @@ def test_string_to_int_swallows_nested_conversion_errors_when_not_requested(mock assert string_to_int("3.14") is None +def test_string_to_float_rejects_adversarial_decimal_text() -> None: + """Reject long malformed decimals without regex backtracking.""" + assert string_to_float(("9" * 10_000) + ".") is None + + def test_string_to_int_raises_when_nested_conversion_returns_none(mocker) -> None: """Raise an integer conversion error when nested conversion returns no value.""" mocker.patch("extended_data.primitives.types.string_to_float", return_value=None) diff --git a/uv.lock b/uv.lock index 68cc6ba..cec7bbf 100644 --- a/uv.lock +++ b/uv.lock @@ -372,7 +372,7 @@ wheels = [ [[package]] name = "extended-data" -version = "8.5.1" +version = "8.5.2" source = { editable = "packages/extended-data" } dependencies = [ { name = "deepmerge" }, @@ -1105,7 +1105,7 @@ wheels = [ [[package]] name = "pytest-extended-data" -version = "0.2.0" +version = "0.2.1" source = { editable = "packages/pytest-extended-data" } dependencies = [ { name = "extended-data" },