From 3e499ba2eb8bbfdded6eba40d76e56221b92de8a Mon Sep 17 00:00:00 2001 From: shashvat-singham Date: Sat, 15 Aug 2026 14:51:56 +0530 Subject: [PATCH 1/2] fix: reject negative score indices in formula variables qdrant core represents the score variable index as a usize (VariableId::Score(usize)), so "$score[-1]" is not a valid pattern. parse_variable used int(), which accepts a sign, underscore separators and surrounding whitespace, and evaluate_variable then bounds-checks with `var < len(scores)` -- a check that assumes a non-negative index. A negative index therefore passed the check and read a prefetch from the end of the list, while an out-of-range positive index correctly fell back to the default score: scores = [{1: 10.0}, {1: 20.0}, {1: 30.0}] "$score[3]" -> 0.0 (default, correct) "$score[-1]" -> 30.0 (silently the last prefetch) "$score[-9]" -> IndexError Validate the index against the same grammar as core instead. This is the same class of bug as the json path array index fixed in #1340, in the formula parser rather than the payload one. --- qdrant_client/hybrid/formula.py | 11 +++++++---- tests/test_formula.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) create mode 100644 tests/test_formula.py diff --git a/qdrant_client/hybrid/formula.py b/qdrant_client/hybrid/formula.py index 8365276ca..8fdfa552e 100644 --- a/qdrant_client/hybrid/formula.py +++ b/qdrant_client/hybrid/formula.py @@ -318,12 +318,15 @@ def parse_variable(var: str) -> str | int: if bracket_end == -1: raise ValueError(f"Invalid score pattern: {var}") - # try parsing the content in between brackets as integer - try: - idx = int(remaining[:bracket_end]) - except ValueError: + # parse the content in between brackets as an unsigned integer. qdrant core represents + # the score index as a usize, while `int()` would also accept a sign, underscore + # separators and surrounding whitespace + raw_idx = remaining[:bracket_end] + if not (raw_idx.isascii() and raw_idx.isdigit()): raise ValueError(f"Invalid score pattern: {var}") + idx = int(raw_idx) + # make sure the string ends after the closing bracket if len(remaining) > bracket_end + 1: raise ValueError(f"Invalid score pattern: {var}") diff --git a/tests/test_formula.py b/tests/test_formula.py new file mode 100644 index 000000000..33b204c46 --- /dev/null +++ b/tests/test_formula.py @@ -0,0 +1,31 @@ +import pytest + +from qdrant_client.hybrid.formula import evaluate_variable, parse_variable + + +def test_parse_variable_score_index() -> None: + assert parse_variable("$score") == 0 + assert parse_variable("$score[0]") == 0 + assert parse_variable("$score[2]") == 2 + assert parse_variable("$score[10]") == 10 + + # qdrant core represents the score index as a usize, so anything that is not a plain + # run of ascii digits is not a valid score pattern + for var in ("$score[-1]", "$score[+1]", "$score[1_0]", "$score[ 1 ]", "$score[²]"): + with pytest.raises(ValueError): + parse_variable(var) + + +def test_evaluate_variable_rejects_negative_score_index() -> None: + scores = [{1: 10.0}, {1: 20.0}, {1: 30.0}] + + assert evaluate_variable("$score[0]", 1, scores, {}, {}) == 10.0 + assert evaluate_variable("$score[2]", 1, scores, {}, {}) == 30.0 + # an index past the end falls back to the default score + assert evaluate_variable("$score[3]", 1, scores, {}, {}) == 0.0 + + # a negative index must not wrap around to the last prefetch, nor leak an IndexError + with pytest.raises(ValueError): + evaluate_variable("$score[-1]", 1, scores, {}, {}) + with pytest.raises(ValueError): + evaluate_variable("$score[-9]", 1, scores, {}, {}) From 6c63d6f98f0f13d2b254a7282aaab31b731b1375 Mon Sep 17 00:00:00 2001 From: George Panchuk Date: Fri, 11 Sep 2026 19:11:45 +0700 Subject: [PATCH 2/2] fix: move tests into local tests --- qdrant_client/hybrid/formula.py | 31 ------------ qdrant_client/local/tests/test_formula.py | 62 +++++++++++++++++++++++ tests/test_formula.py | 31 ------------ 3 files changed, 62 insertions(+), 62 deletions(-) create mode 100644 qdrant_client/local/tests/test_formula.py delete mode 100644 tests/test_formula.py diff --git a/qdrant_client/hybrid/formula.py b/qdrant_client/hybrid/formula.py index 8fdfa552e..a0f1342a9 100644 --- a/qdrant_client/hybrid/formula.py +++ b/qdrant_client/hybrid/formula.py @@ -340,34 +340,3 @@ def raise_non_finite_error(expression: str) -> None: def is_number(value: Any) -> bool: return isinstance(value, (int, float)) and not isinstance(value, bool) - - -def test_parsing_variable() -> None: - assert parse_variable("$score") == 0 - assert parse_variable("$score[0]") == 0 - assert parse_variable("$score[1]") == 1 - assert parse_variable("$score[2]") == 2 - - try: - parse_variable("$score[invalid]") - assert False - except ValueError as e: - assert str(e) == "Invalid score pattern: $score[invalid]" - - try: - parse_variable("$score[10].other") - assert False - except ValueError as e: - assert str(e) == "Invalid score pattern: $score[10].other" - - -def test_try_extract_payload_value() -> None: - for payload_value, expected in [(1.2, 1.2), ([1.2], 1.2), ([1.2, 2.3], [1.2, 2.3])]: - empty_defaults: dict[str, Any] = {} - - payload = {"key": payload_value} - assert try_extract_payload_value("key", payload, empty_defaults) == expected - - defaults = {"key": payload_value} - empty_payload: dict[str, Any] = {} - assert try_extract_payload_value("key", empty_payload, defaults) == expected diff --git a/qdrant_client/local/tests/test_formula.py b/qdrant_client/local/tests/test_formula.py new file mode 100644 index 000000000..056ba0204 --- /dev/null +++ b/qdrant_client/local/tests/test_formula.py @@ -0,0 +1,62 @@ +import re +from typing import Any + +import pytest + +from qdrant_client.hybrid.formula import ( + evaluate_variable, + parse_variable, + try_extract_payload_value, +) + + +def test_parse_variable_score_index() -> None: + assert parse_variable("$score") == 0 + assert parse_variable("$score[0]") == 0 + assert parse_variable("$score[1]") == 1 + assert parse_variable("$score[2]") == 2 + assert parse_variable("$score[10]") == 10 + + # qdrant core resolves the index through the json path grammar into a usize, so anything + # that is not a plain run of ascii digits is not a valid score pattern + for var in ( + "$score[-1]", + "$score[+1]", + "$score[1_0]", + "$score[ 1 ]", + "$score[²]", + "$score[invalid]", + "$score[]", + "$score[1][2]", + "$score[10].other", + "$score.invalid", + ): + with pytest.raises(ValueError, match=f"Invalid score pattern: {re.escape(var)}"): + parse_variable(var) + + +def test_evaluate_variable_rejects_negative_score_index() -> None: + scores = [{1: 10.0}, {1: 20.0}, {1: 30.0}] + + assert evaluate_variable("$score[0]", 1, scores, {}, {}) == 10.0 + assert evaluate_variable("$score[2]", 1, scores, {}, {}) == 30.0 + # an index past the end falls back to the default score + assert evaluate_variable("$score[3]", 1, scores, {}, {}) == 0.0 + + # a negative index must not wrap around to the last prefetch, nor leak an IndexError + with pytest.raises(ValueError): + evaluate_variable("$score[-1]", 1, scores, {}, {}) + with pytest.raises(ValueError): + evaluate_variable("$score[-9]", 1, scores, {}, {}) + + +def test_try_extract_payload_value() -> None: + for payload_value, expected in [(1.2, 1.2), ([1.2], 1.2), ([1.2, 2.3], [1.2, 2.3])]: + empty_defaults: dict[str, Any] = {} + + payload = {"key": payload_value} + assert try_extract_payload_value("key", payload, empty_defaults) == expected + + defaults = {"key": payload_value} + empty_payload: dict[str, Any] = {} + assert try_extract_payload_value("key", empty_payload, defaults) == expected diff --git a/tests/test_formula.py b/tests/test_formula.py deleted file mode 100644 index 33b204c46..000000000 --- a/tests/test_formula.py +++ /dev/null @@ -1,31 +0,0 @@ -import pytest - -from qdrant_client.hybrid.formula import evaluate_variable, parse_variable - - -def test_parse_variable_score_index() -> None: - assert parse_variable("$score") == 0 - assert parse_variable("$score[0]") == 0 - assert parse_variable("$score[2]") == 2 - assert parse_variable("$score[10]") == 10 - - # qdrant core represents the score index as a usize, so anything that is not a plain - # run of ascii digits is not a valid score pattern - for var in ("$score[-1]", "$score[+1]", "$score[1_0]", "$score[ 1 ]", "$score[²]"): - with pytest.raises(ValueError): - parse_variable(var) - - -def test_evaluate_variable_rejects_negative_score_index() -> None: - scores = [{1: 10.0}, {1: 20.0}, {1: 30.0}] - - assert evaluate_variable("$score[0]", 1, scores, {}, {}) == 10.0 - assert evaluate_variable("$score[2]", 1, scores, {}, {}) == 30.0 - # an index past the end falls back to the default score - assert evaluate_variable("$score[3]", 1, scores, {}, {}) == 0.0 - - # a negative index must not wrap around to the last prefetch, nor leak an IndexError - with pytest.raises(ValueError): - evaluate_variable("$score[-1]", 1, scores, {}, {}) - with pytest.raises(ValueError): - evaluate_variable("$score[-9]", 1, scores, {}, {})