Skip to content

Commit 5822106

Browse files
Harden polling operation_name normalization boundaries
Co-authored-by: Shri Sukhani <shrisukhani@users.noreply.github.com>
1 parent 0021187 commit 5822106

File tree

2 files changed

+188
-6
lines changed

2 files changed

+188
-6
lines changed

hyperbrowser/client/polling.py

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -104,19 +104,58 @@ def _normalize_non_negative_real(value: float, *, field_name: str) -> float:
104104
def _validate_operation_name(operation_name: str) -> None:
105105
if not isinstance(operation_name, str):
106106
raise HyperbrowserError("operation_name must be a string")
107-
if not operation_name.strip():
107+
try:
108+
normalized_operation_name = operation_name.strip()
109+
if type(normalized_operation_name) is not str:
110+
raise TypeError("normalized operation_name must be a string")
111+
except HyperbrowserError:
112+
raise
113+
except Exception as exc:
114+
raise HyperbrowserError(
115+
"Failed to normalize operation_name",
116+
original_error=exc,
117+
) from exc
118+
if not normalized_operation_name:
108119
raise HyperbrowserError("operation_name must not be empty")
109-
if operation_name != operation_name.strip():
120+
try:
121+
has_surrounding_whitespace = operation_name != normalized_operation_name
122+
except HyperbrowserError:
123+
raise
124+
except Exception as exc:
125+
raise HyperbrowserError(
126+
"Failed to normalize operation_name",
127+
original_error=exc,
128+
) from exc
129+
if has_surrounding_whitespace:
110130
raise HyperbrowserError(
111131
"operation_name must not contain leading or trailing whitespace"
112132
)
113-
if len(operation_name) > _MAX_OPERATION_NAME_LENGTH:
133+
try:
134+
operation_name_length = len(operation_name)
135+
except HyperbrowserError:
136+
raise
137+
except Exception as exc:
138+
raise HyperbrowserError(
139+
"Failed to validate operation_name length",
140+
original_error=exc,
141+
) from exc
142+
if operation_name_length > _MAX_OPERATION_NAME_LENGTH:
114143
raise HyperbrowserError(
115144
f"operation_name must be {_MAX_OPERATION_NAME_LENGTH} characters or fewer"
116145
)
117-
if any(
118-
ord(character) < 32 or ord(character) == 127 for character in operation_name
119-
):
146+
try:
147+
contains_control_character = any(
148+
ord(character) < 32 or ord(character) == 127
149+
for character in operation_name
150+
)
151+
except HyperbrowserError:
152+
raise
153+
except Exception as exc:
154+
raise HyperbrowserError(
155+
"Failed to validate operation_name characters",
156+
original_error=exc,
157+
) from exc
158+
if contains_control_character:
120159
raise HyperbrowserError("operation_name must not contain control characters")
121160

122161

tests/test_polling.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6847,3 +6847,146 @@ def test_retry_operation_sanitizes_control_characters_in_errors():
68476847
max_attempts=1,
68486848
retry_delay_seconds=0.0,
68496849
)
6850+
6851+
6852+
def _run_retry_operation_sync_with_name(operation_name: str) -> None:
6853+
retry_operation(
6854+
operation_name=operation_name,
6855+
operation=lambda: "ok",
6856+
max_attempts=1,
6857+
retry_delay_seconds=0.0,
6858+
)
6859+
6860+
6861+
def _run_retry_operation_async_with_name(operation_name: str) -> None:
6862+
async def _run() -> None:
6863+
async def _operation() -> str:
6864+
return "ok"
6865+
6866+
await retry_operation_async(
6867+
operation_name=operation_name,
6868+
operation=lambda: _operation(),
6869+
max_attempts=1,
6870+
retry_delay_seconds=0.0,
6871+
)
6872+
6873+
asyncio.run(_run())
6874+
6875+
6876+
@pytest.mark.parametrize(
6877+
"runner",
6878+
[_run_retry_operation_sync_with_name, _run_retry_operation_async_with_name],
6879+
)
6880+
def test_retry_operation_wraps_operation_name_strip_runtime_errors(runner):
6881+
class _BrokenOperationName(str):
6882+
def strip(self, chars=None): # type: ignore[override]
6883+
_ = chars
6884+
raise RuntimeError("operation_name strip exploded")
6885+
6886+
with pytest.raises(
6887+
HyperbrowserError, match="Failed to normalize operation_name"
6888+
) as exc_info:
6889+
runner(_BrokenOperationName("poll operation"))
6890+
6891+
assert isinstance(exc_info.value.original_error, RuntimeError)
6892+
6893+
6894+
@pytest.mark.parametrize(
6895+
"runner",
6896+
[_run_retry_operation_sync_with_name, _run_retry_operation_async_with_name],
6897+
)
6898+
def test_retry_operation_preserves_operation_name_strip_hyperbrowser_errors(runner):
6899+
class _BrokenOperationName(str):
6900+
def strip(self, chars=None): # type: ignore[override]
6901+
_ = chars
6902+
raise HyperbrowserError("custom operation_name strip failure")
6903+
6904+
with pytest.raises(
6905+
HyperbrowserError, match="custom operation_name strip failure"
6906+
) as exc_info:
6907+
runner(_BrokenOperationName("poll operation"))
6908+
6909+
assert exc_info.value.original_error is None
6910+
6911+
6912+
@pytest.mark.parametrize(
6913+
"runner",
6914+
[_run_retry_operation_sync_with_name, _run_retry_operation_async_with_name],
6915+
)
6916+
def test_retry_operation_wraps_non_string_operation_name_strip_results(runner):
6917+
class _BrokenOperationName(str):
6918+
def strip(self, chars=None): # type: ignore[override]
6919+
_ = chars
6920+
return object()
6921+
6922+
with pytest.raises(
6923+
HyperbrowserError, match="Failed to normalize operation_name"
6924+
) as exc_info:
6925+
runner(_BrokenOperationName("poll operation"))
6926+
6927+
assert isinstance(exc_info.value.original_error, TypeError)
6928+
6929+
6930+
@pytest.mark.parametrize(
6931+
"runner",
6932+
[_run_retry_operation_sync_with_name, _run_retry_operation_async_with_name],
6933+
)
6934+
def test_retry_operation_wraps_operation_name_length_runtime_errors(runner):
6935+
class _BrokenOperationName(str):
6936+
def strip(self, chars=None): # type: ignore[override]
6937+
_ = chars
6938+
return "poll operation"
6939+
6940+
def __len__(self):
6941+
raise RuntimeError("operation_name length exploded")
6942+
6943+
with pytest.raises(
6944+
HyperbrowserError, match="Failed to validate operation_name length"
6945+
) as exc_info:
6946+
runner(_BrokenOperationName("poll operation"))
6947+
6948+
assert isinstance(exc_info.value.original_error, RuntimeError)
6949+
6950+
6951+
@pytest.mark.parametrize(
6952+
"runner",
6953+
[_run_retry_operation_sync_with_name, _run_retry_operation_async_with_name],
6954+
)
6955+
def test_retry_operation_wraps_operation_name_character_validation_failures(runner):
6956+
class _BrokenOperationName(str):
6957+
def strip(self, chars=None): # type: ignore[override]
6958+
_ = chars
6959+
return "poll operation"
6960+
6961+
def __iter__(self):
6962+
raise RuntimeError("operation_name iteration exploded")
6963+
6964+
with pytest.raises(
6965+
HyperbrowserError, match="Failed to validate operation_name characters"
6966+
) as exc_info:
6967+
runner(_BrokenOperationName("poll operation"))
6968+
6969+
assert isinstance(exc_info.value.original_error, RuntimeError)
6970+
6971+
6972+
@pytest.mark.parametrize(
6973+
"runner",
6974+
[_run_retry_operation_sync_with_name, _run_retry_operation_async_with_name],
6975+
)
6976+
def test_retry_operation_preserves_operation_name_character_validation_hyperbrowser_errors(
6977+
runner,
6978+
):
6979+
class _BrokenOperationName(str):
6980+
def strip(self, chars=None): # type: ignore[override]
6981+
_ = chars
6982+
return "poll operation"
6983+
6984+
def __iter__(self):
6985+
raise HyperbrowserError("custom operation_name character failure")
6986+
6987+
with pytest.raises(
6988+
HyperbrowserError, match="custom operation_name character failure"
6989+
) as exc_info:
6990+
runner(_BrokenOperationName("poll operation"))
6991+
6992+
assert exc_info.value.original_error is None

0 commit comments

Comments
 (0)