From b2e61abde747029c150d042c62c52e62a182e47a Mon Sep 17 00:00:00 2001 From: umerkhan Date: Tue, 8 Sep 2026 00:50:50 +0530 Subject: [PATCH 1/4] FIX: validate BinaryConverter bit width per converted word BinaryConverter.validate_input inspected the entire prompt, but WordLevelConverter.convert_async validates before applying the word selection strategy. A character in an unselected word therefore failed the conversion even though that word is passed through unencoded and cannot overflow bits_per_char. Move the check to the words that are actually converted. The default all-words path is unchanged, so this only affects prompts where a selection strategy leaves the offending word untouched. Rename the helper to _validate_word to match the style guide, which marks internal validation helpers as private. Co-Authored-By: Claude Opus 5 (1M context) --- pyrit/converter/binary_converter.py | 17 ++++++++++----- tests/unit/converter/test_binary_converter.py | 21 +++++++++++++++++++ 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/pyrit/converter/binary_converter.py b/pyrit/converter/binary_converter.py index 73e5a1e133..bbadaabfd8 100644 --- a/pyrit/converter/binary_converter.py +++ b/pyrit/converter/binary_converter.py @@ -64,18 +64,18 @@ def _build_identifier(self) -> ComponentIdentifier: } ) - def validate_input(self, prompt: str) -> None: + def _validate_word(self, word: str) -> None: """ - Check if ``bits_per_char`` is sufficient for the characters in the prompt. + Check if ``bits_per_char`` is sufficient for the characters in a word being converted. Args: - prompt (str): The input text prompt to validate. + word (str): The word that is about to be converted. Raises: - ValueError: If ``bits_per_char`` is too small to represent any character in the prompt. + ValueError: If ``bits_per_char`` is too small to represent any character in the word. """ bits = self.bits_per_char.value - max_code_point = max((ord(char) for char in prompt), default=0) + max_code_point = max((ord(char) for char in word), default=0) min_bits_required = max_code_point.bit_length() if bits < min_bits_required: raise ValueError( @@ -92,7 +92,14 @@ async def convert_word_async(self, word: str) -> str: Returns: str: The converted word. + + Raises: + ValueError: If ``bits_per_char`` is too small to represent any character in the word. """ + # Validated per word rather than over the whole prompt: a word selection strategy may + # leave words untouched, and a character that is never encoded cannot overflow + # bits_per_char. + self._validate_word(word) bits = self.bits_per_char.value return " ".join(format(ord(char), f"0{bits}b") for char in word) diff --git a/tests/unit/converter/test_binary_converter.py b/tests/unit/converter/test_binary_converter.py index 41bfc3da14..c21e7e19de 100644 --- a/tests/unit/converter/test_binary_converter.py +++ b/tests/unit/converter/test_binary_converter.py @@ -4,6 +4,7 @@ import pytest from pyrit.converter import BinaryConverter, ConverterResult +from pyrit.converter.text_selection_strategy import WordIndexSelectionStrategy async def test_binary_converter_8_bit_ascii(): @@ -38,3 +39,23 @@ async def test_binary_converter_32_bit_emoji(): async def test_binary_converter_invalid_bits_per_char(): with pytest.raises(TypeError, match="bits_per_char must be an instance of BinaryConverter.BitsPerChar Enum."): BinaryConverter(bits_per_char=10) # Invalid bits_per_char + + +async def test_binary_converter_raises_when_selected_word_exceeds_bits(): + converter = BinaryConverter(bits_per_char=BinaryConverter.BitsPerChar.BITS_16) + with pytest.raises(ValueError, match="bits_per_char=16 is too small"): + await converter.convert_async(prompt="hello 👋", input_type="text") + + +async def test_binary_converter_ignores_unselected_word_exceeding_bits(): + # Only "hello" is converted, so the emoji in the unselected word is passed + # through untouched and must not fail validation. + converter = BinaryConverter( + bits_per_char=BinaryConverter.BitsPerChar.BITS_16, + word_selection_strategy=WordIndexSelectionStrategy(indices=[0]), + ) + result = await converter.convert_async(prompt="hello 👋", input_type="text") + expected_hello = " ".join(format(ord(char), "016b") for char in "hello") + space_binary = format(ord(" "), "016b") + assert result.output_text == f"{expected_hello} {space_binary} 👋" + assert result.output_type == "text" From 14516db9f34c57462052c6d252635e1e065a5c34 Mon Sep 17 00:00:00 2001 From: Adrian Gavrila Date: Sun, 13 Sep 2026 20:45:20 -0400 Subject: [PATCH 2/4] Preserve BinaryConverter validation compatibility until 1.4.0 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- pyrit/converter/binary_converter.py | 30 ++++++++++ pyrit/converter/word_level_converter.py | 8 ++- tests/unit/converter/test_binary_converter.py | 57 +++++++++++++++++++ .../converter/test_word_level_converter.py | 23 ++++++++ 4 files changed, 117 insertions(+), 1 deletion(-) diff --git a/pyrit/converter/binary_converter.py b/pyrit/converter/binary_converter.py index bbadaabfd8..1c4c29beda 100644 --- a/pyrit/converter/binary_converter.py +++ b/pyrit/converter/binary_converter.py @@ -6,6 +6,8 @@ from enum import Enum from typing import TYPE_CHECKING +# Deprecation support: remove in 1.4.0. +from pyrit.common.deprecation import print_deprecation_message from pyrit.converter.word_level_converter import WordLevelConverter if TYPE_CHECKING: @@ -64,6 +66,34 @@ def _build_identifier(self) -> ComponentIdentifier: } ) + # Deprecation shim: remove in 1.4.0 with both hooks; keep _validate_word. + def validate_input(self, prompt: str) -> None: + """ + Validate whole-prompt bit width, ignoring word selection (deprecated until 1.4.0). + + Conversion checks selected words instead; subclass overrides may still call this + method via ``super()``. After removal, standalone preflight is caller-owned: + inherited ``WordLevelConverter.validate_input`` does not check bit width. + + Args: + prompt (str): The input text prompt to validate. + + Raises: + ValueError: If ``bits_per_char`` is too small to represent any character in the prompt. + """ + print_deprecation_message( + old_item="BinaryConverter.validate_input", + new_item="automatic selected-word validation during BinaryConverter.convert_async", + removed_in="1.4.0", + ) + self._validate_word(prompt) + + # Deprecation helper: remove in 1.4.0 with validate_input. + def _validate_before_conversion(self, prompt: str) -> None: + """Skip only the built-in deprecated validator, preserving subclass overrides.""" + if type(self).validate_input is not BinaryConverter.validate_input: + self.validate_input(prompt=prompt) + def _validate_word(self, word: str) -> None: """ Check if ``bits_per_char`` is sufficient for the characters in a word being converted. diff --git a/pyrit/converter/word_level_converter.py b/pyrit/converter/word_level_converter.py index 8778ce7982..5cd7fc871c 100644 --- a/pyrit/converter/word_level_converter.py +++ b/pyrit/converter/word_level_converter.py @@ -78,6 +78,11 @@ async def convert_word_async(self, word: str) -> str: def validate_input(self, prompt: str) -> None: """Validate the input before processing (can be overridden by subclasses).""" + # Deprecation helper: remove in 1.4.0 with BinaryConverter's override. + def _validate_before_conversion(self, prompt: str) -> None: + """Delegate automatic validation to the existing subclass hook.""" + self.validate_input(prompt=prompt) + def join_words(self, words: list[str]) -> str: """ Provide a way for subclasses to override the default behavior of joining words. @@ -117,7 +122,8 @@ async def convert_async(self, *, prompt: str, input_type: PromptDataType = "text if input_type != "text": raise ValueError(f"Input type {input_type} not supported") - self.validate_input(prompt=prompt) + # Deprecation helper: restore self.validate_input(prompt=prompt) in 1.4.0. + self._validate_before_conversion(prompt=prompt) words = prompt.split() if self._word_split_separator is None else prompt.split(self._word_split_separator) diff --git a/tests/unit/converter/test_binary_converter.py b/tests/unit/converter/test_binary_converter.py index c21e7e19de..29289cab1d 100644 --- a/tests/unit/converter/test_binary_converter.py +++ b/tests/unit/converter/test_binary_converter.py @@ -1,6 +1,10 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT license. +# Deprecation test support: remove in 1.4.0. +import warnings +from contextlib import nullcontext + import pytest from pyrit.converter import BinaryConverter, ConverterResult @@ -59,3 +63,56 @@ async def test_binary_converter_ignores_unselected_word_exceeding_bits(): space_binary = format(ord(" "), "016b") assert result.output_text == f"{expected_hello} {space_binary} 👋" assert result.output_type == "text" + + +# Deprecation tests: remove in 1.4.0. +class TestBinaryConverterValidationDeprecation: + @pytest.mark.parametrize("prompt", ["", "hello", "hello 👋"]) + def test_validate_input_warns_and_checks_whole_prompt(self, prompt: str) -> None: + converter = BinaryConverter(word_selection_strategy=WordIndexSelectionStrategy(indices=[0])) + with pytest.warns(DeprecationWarning) as recorded: + with pytest.raises(ValueError, match="Minimum required bits: 17") if "👋" in prompt else nullcontext(): + assert converter.validate_input(prompt) is None + assert len(recorded) == 1 + assert str(recorded[0].message) == ( + "BinaryConverter.validate_input is deprecated and will be removed in 1.4.0. " + "Use automatic selected-word validation during BinaryConverter.convert_async instead." + ) + assert recorded[0].filename == __file__ + + @pytest.mark.parametrize(("prompt", "index"), [("", 0), ("hello 👋", 0), ("hello 👋", 1)]) + async def test_builtin_validation_does_not_warn_async(self, *, prompt: str, index: int) -> None: + class PlainBinaryConverter(BinaryConverter): + pass + + for converter_type in (BinaryConverter, PlainBinaryConverter): + converter = converter_type(word_selection_strategy=WordIndexSelectionStrategy(indices=[index])) + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + with pytest.raises(ValueError, match="bits_per_char=16") if index == 1 else nullcontext(): + await converter.convert_async(prompt=prompt) + + @pytest.mark.parametrize("mode", ["accept", "reject", "super"]) + async def test_custom_validation_is_preserved_async(self, mode: str) -> None: + validated_prompts: list[str] = [] + + class CustomBinaryConverter(BinaryConverter): + def validate_input(self, prompt: str) -> None: + validated_prompts.append(prompt) + if mode == "reject": + raise ValueError("Rejected by custom validation") + if mode == "super": + super().validate_input(prompt) + + class InheritedCustomBinaryConverter(CustomBinaryConverter): + pass + + for converter_type in (CustomBinaryConverter, InheritedCustomBinaryConverter): + validated_prompts.clear() + converter = converter_type(word_selection_strategy=WordIndexSelectionStrategy(indices=[0])) + with warnings.catch_warnings(record=True) as recorded: + warnings.simplefilter("always", DeprecationWarning) + with pytest.raises(ValueError) if mode != "accept" else nullcontext(): + await converter.convert_async(prompt="hello 👋") + assert validated_prompts == ["hello 👋"] + assert [warning.category for warning in recorded] == ([DeprecationWarning] if mode == "super" else []) diff --git a/tests/unit/converter/test_word_level_converter.py b/tests/unit/converter/test_word_level_converter.py index a7c59c97e9..6411ae2e99 100644 --- a/tests/unit/converter/test_word_level_converter.py +++ b/tests/unit/converter/test_word_level_converter.py @@ -160,3 +160,26 @@ async def test_custom_separator_preserved_with_partial_selection(self): ) result = await converter.convert_async(prompt="alpha,beta,gamma") assert result.output_text == "ALPHA,beta,GAMMA" + + +@pytest.mark.parametrize("prompt", ["", "hello world"]) +@pytest.mark.parametrize("reject", [False, True]) +async def test_convert_async_calls_subclass_validation_async(*, prompt: str, reject: bool) -> None: + validated_prompts: list[str] = [] + + class ValidatingWordLevelConverter(SimpleWordLevelConverter): + def validate_input(self, prompt: str) -> None: + validated_prompts.append(prompt) + if reject: + raise ValueError("Rejected by subclass validation") + + converter = ValidatingWordLevelConverter( + word_selection_strategy=WordIndexSelectionStrategy(indices=[10] if reject else [0]) + ) + if reject: + with pytest.raises(ValueError, match="Rejected by subclass validation"): + await converter.convert_async(prompt=prompt) + else: + result = await converter.convert_async(prompt=prompt) + assert result.output_text == ("" if not prompt else "HELLO world") + assert validated_prompts == [prompt] From dc8720f2a19c5b4a7e17aec4a1bda709b8aba79a Mon Sep 17 00:00:00 2001 From: Adrian Gavrila Date: Mon, 14 Sep 2026 11:27:31 -0400 Subject: [PATCH 3/4] Make BinaryConverter preflight validation selection-aware Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- pyrit/converter/binary_converter.py | 13 +++++++----- tests/unit/converter/test_binary_converter.py | 20 ++++++++++++------- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/pyrit/converter/binary_converter.py b/pyrit/converter/binary_converter.py index 1c4c29beda..a1d09e7f8b 100644 --- a/pyrit/converter/binary_converter.py +++ b/pyrit/converter/binary_converter.py @@ -69,11 +69,11 @@ def _build_identifier(self) -> ComponentIdentifier: # Deprecation shim: remove in 1.4.0 with both hooks; keep _validate_word. def validate_input(self, prompt: str) -> None: """ - Validate whole-prompt bit width, ignoring word selection (deprecated until 1.4.0). + Validate the bit width of selected words (deprecated until 1.4.0). - Conversion checks selected words instead; subclass overrides may still call this - method via ``super()``. After removal, standalone preflight is caller-owned: - inherited ``WordLevelConverter.validate_input`` does not check bit width. + Subclass overrides may still call this method via ``super()``. After removal, + standalone preflight is caller-owned: inherited ``WordLevelConverter.validate_input`` + does not check bit width. Args: prompt (str): The input text prompt to validate. @@ -86,7 +86,10 @@ def validate_input(self, prompt: str) -> None: new_item="automatic selected-word validation during BinaryConverter.convert_async", removed_in="1.4.0", ) - self._validate_word(prompt) + words = prompt.split() if self._word_split_separator is None else prompt.split(self._word_split_separator) + selected_indices = self._word_selection_strategy.select_words(words=words) + for idx in selected_indices: + self._validate_word(words[idx]) # Deprecation helper: remove in 1.4.0 with validate_input. def _validate_before_conversion(self, prompt: str) -> None: diff --git a/tests/unit/converter/test_binary_converter.py b/tests/unit/converter/test_binary_converter.py index 29289cab1d..8f5a6f7ee7 100644 --- a/tests/unit/converter/test_binary_converter.py +++ b/tests/unit/converter/test_binary_converter.py @@ -67,12 +67,12 @@ async def test_binary_converter_ignores_unselected_word_exceeding_bits(): # Deprecation tests: remove in 1.4.0. class TestBinaryConverterValidationDeprecation: - @pytest.mark.parametrize("prompt", ["", "hello", "hello 👋"]) - def test_validate_input_warns_and_checks_whole_prompt(self, prompt: str) -> None: - converter = BinaryConverter(word_selection_strategy=WordIndexSelectionStrategy(indices=[0])) + @pytest.mark.parametrize(("index", "raises"), [(0, False), (1, True)]) + def test_validate_input_warns_and_checks_selected_words(self, *, index: int, raises: bool) -> None: + converter = BinaryConverter(word_selection_strategy=WordIndexSelectionStrategy(indices=[index])) with pytest.warns(DeprecationWarning) as recorded: - with pytest.raises(ValueError, match="Minimum required bits: 17") if "👋" in prompt else nullcontext(): - assert converter.validate_input(prompt) is None + with pytest.raises(ValueError, match="Minimum required bits: 17") if raises else nullcontext(): + assert converter.validate_input("hello 👋") is None assert len(recorded) == 1 assert str(recorded[0].message) == ( "BinaryConverter.validate_input is deprecated and will be removed in 1.4.0. " @@ -112,7 +112,13 @@ class InheritedCustomBinaryConverter(CustomBinaryConverter): converter = converter_type(word_selection_strategy=WordIndexSelectionStrategy(indices=[0])) with warnings.catch_warnings(record=True) as recorded: warnings.simplefilter("always", DeprecationWarning) - with pytest.raises(ValueError) if mode != "accept" else nullcontext(): - await converter.convert_async(prompt="hello 👋") + with ( + pytest.raises(ValueError, match="Rejected by custom validation") + if mode == "reject" + else nullcontext() + ): + result = await converter.convert_async(prompt="hello 👋") assert validated_prompts == ["hello 👋"] assert [warning.category for warning in recorded] == ([DeprecationWarning] if mode == "super" else []) + if mode != "reject": + assert result.output_text.endswith("👋") From b824659cc9f4301d26ef9c61086dccabeafdbe41 Mon Sep 17 00:00:00 2001 From: Adrian Gavrila Date: Mon, 14 Sep 2026 13:18:13 -0400 Subject: [PATCH 4/4] Clarify BinaryConverter validation deprecation warning Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- pyrit/converter/binary_converter.py | 6 ++---- tests/unit/converter/test_binary_converter.py | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/pyrit/converter/binary_converter.py b/pyrit/converter/binary_converter.py index a1d09e7f8b..96270e606e 100644 --- a/pyrit/converter/binary_converter.py +++ b/pyrit/converter/binary_converter.py @@ -83,7 +83,7 @@ def validate_input(self, prompt: str) -> None: """ print_deprecation_message( old_item="BinaryConverter.validate_input", - new_item="automatic selected-word validation during BinaryConverter.convert_async", + new_item="BinaryConverter.convert_async", removed_in="1.4.0", ) words = prompt.split() if self._word_split_separator is None else prompt.split(self._word_split_separator) @@ -129,9 +129,7 @@ async def convert_word_async(self, word: str) -> str: Raises: ValueError: If ``bits_per_char`` is too small to represent any character in the word. """ - # Validated per word rather than over the whole prompt: a word selection strategy may - # leave words untouched, and a character that is never encoded cannot overflow - # bits_per_char. + # Validate per word because unselected words are not encoded. self._validate_word(word) bits = self.bits_per_char.value return " ".join(format(ord(char), f"0{bits}b") for char in word) diff --git a/tests/unit/converter/test_binary_converter.py b/tests/unit/converter/test_binary_converter.py index 8f5a6f7ee7..0de685517f 100644 --- a/tests/unit/converter/test_binary_converter.py +++ b/tests/unit/converter/test_binary_converter.py @@ -76,7 +76,7 @@ def test_validate_input_warns_and_checks_selected_words(self, *, index: int, rai assert len(recorded) == 1 assert str(recorded[0].message) == ( "BinaryConverter.validate_input is deprecated and will be removed in 1.4.0. " - "Use automatic selected-word validation during BinaryConverter.convert_async instead." + "Use BinaryConverter.convert_async instead." ) assert recorded[0].filename == __file__