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/src/partial_json_parser/core/myelin.py b/src/partial_json_parser/core/myelin.py index 69bcd82..f17fb9b 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,18 @@ 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. + # Walk the string ranges once, using the same ASCII digits as the + # number parser; only the bounded atom is validated. + current_range = 0 + 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 7bf8727..dc03626 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -95,6 +95,34 @@ 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_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]', "[-١]", '{"a": -١}']: + for use_fast_fix in (True, False): + with raises(MalformedJSON): + parse_json(text, use_fast_fix=use_fast_fix) + + for text in ["[-1]", '{"a": -1}', "[1, -2]", '{"a": 1, "b": -2.5}', "[-Infinity]"]: + for use_fast_fix in (True, False): + assert parse_json(text, use_fast_fix=use_fast_fix) == __import__("json").loads(text) + + def test_fix(): assert fix("[") == fix_fast("[") == ("[", "]") assert fix("[0.") == fix_fast("[0.") == ("[0", "]")