From a4ff2f2d28a61cc8e36364a9e377f25c7b304c42 Mon Sep 17 00:00:00 2001 From: Lushiyao5 <321342421+Lushiyao5@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:13:30 -0500 Subject: [PATCH 1/3] Raise MalformedJSON for malformed negative numbers complete_num's backward truncation loop has no lower bound: for input starting with '-' followed by a non-number character (e.g. '-inf', '-x'), the loop walks past index 0, reads json_string[-1] from the end of the string, and yields an empty completion. parse_json then fails with a confusing bare JSONDecodeError ('Expecting value') instead of the library's documented MalformedJSON. Inputs like '-.5' / '-e3' were even returned unchanged as if they were complete numbers. Guard the sign: raise MalformedJSON when '-' is not followed by a digit. '-Infinity' is dispatched before complete_num, so it is unaffected, and every valid negative number keeps its behavior. --- src/partial_json_parser/core/complete.py | 4 ++++ tests/test_examples.py | 16 ++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/partial_json_parser/core/complete.py b/src/partial_json_parser/core/complete.py index ed9b99e..ce32892 100644 --- a/src/partial_json_parser/core/complete.py +++ b/src/partial_json_parser/core/complete.py @@ -218,6 +218,10 @@ def complete_obj(json_string: str, allow: Allow) -> CompleteResult: def complete_num(json_string: str, allow: Allow, is_top_level=False) -> CompleteResult: + # a `-` must be followed by a digit; `-Infinity` never reaches here + if json_string[0] == "-" and json_string[1] not in "1234567890": + raise MalformedJSON(f"Unexpected character {json_string[1]}") + i = 1 length = len(json_string) diff --git a/tests/test_examples.py b/tests/test_examples.py index 7bf8727..15dc536 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -95,6 +95,22 @@ def test_error(): parse_json("--") +def test_error_negative_number_prefix(): + """a `-` not followed by a digit (except `-Infinity`) is malformed, not silently completed""" + with raises(MalformedJSON): + parse_json("-x") + with raises(MalformedJSON): + parse_json("-inf") # lowercase; `-Inf`/`-Infinity` are handled separately + with raises(MalformedJSON): + parse_json("-.5") + with raises(MalformedJSON): + parse_json("-e3") + + assert parse_json("-0") == 0 + assert parse_json("-1.5") == -1.5 + assert parse_json("-Infinity") == float("-inf") + + def test_fix(): assert fix("[") == fix_fast("[") == ("[", "]") assert fix("[0.") == fix_fast("[0.") == ("[0", "]") From bbdad6578fb0e2ac94e02f342d4a1be899f7ab9f Mon Sep 17 00:00:00 2001 From: Lushiyao5 <321342421+Lushiyao5@users.noreply.github.com> Date: Fri, 6 Nov 2026 03:00:00 -0500 Subject: [PATCH 2/3] Validate malformed negative numbers in closed collections fix_fast returned a completed collection without calling _fix, so a malformed negative number inside it reached json.loads and leaked a plain JSONDecodeError instead of MalformedJSON. When the collection closes, validate any minus-prefixed atom outside strings through the existing _fix path; valid negative values and strings containing '-' remain untouched. --- src/partial_json_parser/core/myelin.py | 8 ++++++++ tests/test_examples.py | 12 ++++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/partial_json_parser/core/myelin.py b/src/partial_json_parser/core/myelin.py index 69bcd82..f0f789b 100644 --- a/src/partial_json_parser/core/myelin.py +++ b/src/partial_json_parser/core/myelin.py @@ -30,6 +30,7 @@ def is_escaped(index: int): in_string = False last_string_start = -1 last_string_end = -1 + string_ranges = [] tokens = scan(json_string) @@ -44,6 +45,7 @@ def is_escaped(index: int): elif not is_escaped(i): in_string = False last_string_end = i + string_ranges.append((last_string_start, last_string_end + 1)) elif not in_string: if char == "}": @@ -56,6 +58,12 @@ def is_escaped(index: int): stack.append((i, char)) if not stack: + # The closed-collection shortcut otherwise bypasses atomic validation + # and can leak json.JSONDecodeError for malformed negative numbers. + # Check only outside strings, where the quotes have already been found. + for match in compile(r'(? Date: Fri, 6 Nov 2026 03:23:41 -0500 Subject: [PATCH 3/3] Tighten closed-collection negative atom validation Use the same ASCII digit contract as complete_num so non-ASCII digits are rejected consistently on the fast path, and walk the string ranges once while validating only the bounded atom instead of the remaining suffix. --- src/partial_json_parser/core/myelin.py | 14 ++++++++++---- tests/test_examples.py | 2 +- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/partial_json_parser/core/myelin.py b/src/partial_json_parser/core/myelin.py index f0f789b..f17fb9b 100644 --- a/src/partial_json_parser/core/myelin.py +++ b/src/partial_json_parser/core/myelin.py @@ -60,10 +60,16 @@ def is_escaped(index: int): if not stack: # The closed-collection shortcut otherwise bypasses atomic validation # and can leak json.JSONDecodeError for malformed negative numbers. - # Check only outside strings, where the quotes have already been found. - for match in compile(r'(?= string_ranges[current_range][1]: + current_range += 1 + if current_range < len(string_ranges) and match.start() >= string_ranges[current_range][0]: + continue + atom = json_string[match.start() :].split(",", 1)[0].strip() + _fix(atom, allow, True) return json_string, "" # check if the opening tokens are allowed diff --git a/tests/test_examples.py b/tests/test_examples.py index c3e476d..dc03626 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -113,7 +113,7 @@ def test_error_negative_number_prefix(): def test_error_negative_number_prefix_inside_closed_collections(): """closed collections must validate their negative atoms too, on both paths""" - for text in ["[-x]", '{"a": -x}', "[-.5]", "[-e3]", '[{"a": 1}, -x]']: + for text in ["[-x]", '{"a": -x}', "[-.5]", "[-e3]", '[{"a": 1}, -x]', "[-١]", '{"a": -١}']: for use_fast_fix in (True, False): with raises(MalformedJSON): parse_json(text, use_fast_fix=use_fast_fix)