Skip to content

Commit 2b4e66b

Browse files
Harden transport model/key display fallback sanitization
Co-authored-by: Shri Sukhani <shrisukhani@users.noreply.github.com>
1 parent 05ba2dd commit 2b4e66b

File tree

2 files changed

+83
-14
lines changed

2 files changed

+83
-14
lines changed

hyperbrowser/transport/base.py

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,23 @@
1313

1414

1515
def _sanitize_display_text(value: str, *, max_length: int) -> str:
16-
sanitized_value = "".join(
17-
"?" if ord(character) < 32 or ord(character) == 127 else character
18-
for character in value
19-
).strip()
20-
if not sanitized_value:
16+
try:
17+
sanitized_value = "".join(
18+
"?" if ord(character) < 32 or ord(character) == 127 else character
19+
for character in value
20+
).strip()
21+
if not isinstance(sanitized_value, str):
22+
return ""
23+
if not sanitized_value:
24+
return ""
25+
if len(sanitized_value) <= max_length:
26+
return sanitized_value
27+
available_length = max_length - len(_TRUNCATED_DISPLAY_SUFFIX)
28+
if available_length <= 0:
29+
return _TRUNCATED_DISPLAY_SUFFIX
30+
return f"{sanitized_value[:available_length]}{_TRUNCATED_DISPLAY_SUFFIX}"
31+
except Exception:
2132
return ""
22-
if len(sanitized_value) <= max_length:
23-
return sanitized_value
24-
available_length = max_length - len(_TRUNCATED_DISPLAY_SUFFIX)
25-
if available_length <= 0:
26-
return _TRUNCATED_DISPLAY_SUFFIX
27-
return f"{sanitized_value[:available_length]}{_TRUNCATED_DISPLAY_SUFFIX}"
2833

2934

3035
def _safe_model_name(model: object) -> str:
@@ -34,9 +39,12 @@ def _safe_model_name(model: object) -> str:
3439
return "response model"
3540
if not isinstance(model_name, str):
3641
return "response model"
37-
normalized_model_name = _sanitize_display_text(
38-
model_name, max_length=_MAX_MODEL_NAME_DISPLAY_LENGTH
39-
)
42+
try:
43+
normalized_model_name = _sanitize_display_text(
44+
model_name, max_length=_MAX_MODEL_NAME_DISPLAY_LENGTH
45+
)
46+
except Exception:
47+
return "response model"
4048
if not normalized_model_name:
4149
return "response model"
4250
return normalized_model_name
@@ -48,6 +56,10 @@ def _format_mapping_key_for_error(key: str) -> str:
4856
)
4957
if normalized_key:
5058
return normalized_key
59+
try:
60+
_ = "".join(character for character in key)
61+
except Exception:
62+
return "<unreadable key>"
5163
return "<blank key>"
5264

5365

tests/test_transport_base.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,39 @@ def __getitem__(self, key: str) -> object:
101101
raise KeyError(key)
102102

103103

104+
class _BrokenRenderedModelNameString(str):
105+
def __iter__(self):
106+
raise RuntimeError("cannot iterate rendered model name")
107+
108+
109+
class _UnreadableNameCallableModel:
110+
__name__ = _BrokenRenderedModelNameString("UnreadableModelName")
111+
112+
def __call__(self, **kwargs):
113+
_ = kwargs
114+
raise RuntimeError("call failed")
115+
116+
117+
class _BrokenRenderedMappingKey(str):
118+
def __iter__(self):
119+
raise RuntimeError("cannot iterate rendered mapping key")
120+
121+
122+
class _BrokenRenderedKeyValueMapping(Mapping[str, object]):
123+
_KEY = _BrokenRenderedMappingKey("name")
124+
125+
def __iter__(self):
126+
return iter([self._KEY])
127+
128+
def __len__(self) -> int:
129+
return 1
130+
131+
def __getitem__(self, key: str) -> object:
132+
if key == self._KEY:
133+
raise RuntimeError("cannot read rendered key value")
134+
raise KeyError(key)
135+
136+
104137
def test_api_response_from_json_parses_model_data() -> None:
105138
response = APIResponse.from_json(
106139
{"name": "job-1", "retries": 2}, _SampleResponseModel
@@ -206,6 +239,17 @@ def test_api_response_from_json_sanitizes_and_truncates_model_name_in_errors() -
206239
)
207240

208241

242+
def test_api_response_from_json_falls_back_for_unreadable_model_name_text() -> None:
243+
with pytest.raises(
244+
HyperbrowserError,
245+
match="Failed to parse response data for response model",
246+
):
247+
APIResponse.from_json(
248+
{"name": "job-1"},
249+
cast("type[_SampleResponseModel]", _UnreadableNameCallableModel()),
250+
)
251+
252+
209253
def test_api_response_from_json_uses_placeholder_for_blank_mapping_key_in_errors() -> (
210254
None
211255
):
@@ -232,6 +276,19 @@ def test_api_response_from_json_sanitizes_and_truncates_mapping_keys_in_errors()
232276
APIResponse.from_json(_BrokenLongKeyValueMapping(), _SampleResponseModel)
233277

234278

279+
def test_api_response_from_json_falls_back_for_unreadable_mapping_keys_in_errors() -> (
280+
None
281+
):
282+
with pytest.raises(
283+
HyperbrowserError,
284+
match=(
285+
"Failed to parse response data for _SampleResponseModel: "
286+
"unable to read value for key '<unreadable key>'"
287+
),
288+
):
289+
APIResponse.from_json(_BrokenRenderedKeyValueMapping(), _SampleResponseModel)
290+
291+
235292
def test_api_response_from_json_preserves_hyperbrowser_errors() -> None:
236293
with pytest.raises(HyperbrowserError, match="model validation failed") as exc_info:
237294
APIResponse.from_json({}, _RaisesHyperbrowserModel)

0 commit comments

Comments
 (0)