Skip to content

Commit 7f8d16b

Browse files
Harden open_binary_file path normalization boundaries
Co-authored-by: Shri Sukhani <shrisukhani@users.noreply.github.com>
1 parent 909831b commit 7f8d16b

File tree

2 files changed

+117
-59
lines changed

2 files changed

+117
-59
lines changed

hyperbrowser/client/file_utils.py

Lines changed: 52 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -7,52 +7,7 @@
77
from hyperbrowser.type_utils import is_plain_string
88

99

10-
def _validate_error_message_text(message_value: str, *, field_name: str) -> None:
11-
if not is_plain_string(message_value):
12-
raise HyperbrowserError(f"{field_name} must be a string")
13-
try:
14-
normalized_message = message_value.strip()
15-
if not is_plain_string(normalized_message):
16-
raise TypeError(f"normalized {field_name} must be a string")
17-
is_empty = len(normalized_message) == 0
18-
except HyperbrowserError:
19-
raise
20-
except Exception as exc:
21-
raise HyperbrowserError(
22-
f"Failed to normalize {field_name}",
23-
original_error=exc,
24-
) from exc
25-
if is_empty:
26-
raise HyperbrowserError(f"{field_name} must not be empty")
27-
try:
28-
contains_control_character = any(
29-
ord(character) < 32 or ord(character) == 127 for character in message_value
30-
)
31-
except HyperbrowserError:
32-
raise
33-
except Exception as exc:
34-
raise HyperbrowserError(
35-
f"Failed to validate {field_name} characters",
36-
original_error=exc,
37-
) from exc
38-
if contains_control_character:
39-
raise HyperbrowserError(f"{field_name} must not contain control characters")
40-
41-
42-
def ensure_existing_file_path(
43-
file_path: Union[str, PathLike[str]],
44-
*,
45-
missing_file_message: str,
46-
not_file_message: str,
47-
) -> str:
48-
_validate_error_message_text(
49-
missing_file_message,
50-
field_name="missing_file_message",
51-
)
52-
_validate_error_message_text(
53-
not_file_message,
54-
field_name="not_file_message",
55-
)
10+
def _normalize_file_path_text(file_path: Union[str, PathLike[str]]) -> str:
5611
try:
5712
normalized_path = os.fspath(file_path)
5813
except HyperbrowserError:
@@ -105,6 +60,56 @@ def ensure_existing_file_path(
10560
raise HyperbrowserError("file_path is invalid", original_error=exc) from exc
10661
if contains_control_character:
10762
raise HyperbrowserError("file_path must not contain control characters")
63+
return normalized_path
64+
65+
66+
def _validate_error_message_text(message_value: str, *, field_name: str) -> None:
67+
if not is_plain_string(message_value):
68+
raise HyperbrowserError(f"{field_name} must be a string")
69+
try:
70+
normalized_message = message_value.strip()
71+
if not is_plain_string(normalized_message):
72+
raise TypeError(f"normalized {field_name} must be a string")
73+
is_empty = len(normalized_message) == 0
74+
except HyperbrowserError:
75+
raise
76+
except Exception as exc:
77+
raise HyperbrowserError(
78+
f"Failed to normalize {field_name}",
79+
original_error=exc,
80+
) from exc
81+
if is_empty:
82+
raise HyperbrowserError(f"{field_name} must not be empty")
83+
try:
84+
contains_control_character = any(
85+
ord(character) < 32 or ord(character) == 127 for character in message_value
86+
)
87+
except HyperbrowserError:
88+
raise
89+
except Exception as exc:
90+
raise HyperbrowserError(
91+
f"Failed to validate {field_name} characters",
92+
original_error=exc,
93+
) from exc
94+
if contains_control_character:
95+
raise HyperbrowserError(f"{field_name} must not contain control characters")
96+
97+
98+
def ensure_existing_file_path(
99+
file_path: Union[str, PathLike[str]],
100+
*,
101+
missing_file_message: str,
102+
not_file_message: str,
103+
) -> str:
104+
_validate_error_message_text(
105+
missing_file_message,
106+
field_name="missing_file_message",
107+
)
108+
_validate_error_message_text(
109+
not_file_message,
110+
field_name="not_file_message",
111+
)
112+
normalized_path = _normalize_file_path_text(file_path)
108113
try:
109114
path_exists = bool(os.path.exists(normalized_path))
110115
except HyperbrowserError:
@@ -138,19 +143,7 @@ def open_binary_file(
138143
open_error_message,
139144
field_name="open_error_message",
140145
)
141-
try:
142-
normalized_path = os.fspath(file_path)
143-
except HyperbrowserError:
144-
raise
145-
except TypeError as exc:
146-
raise HyperbrowserError(
147-
"file_path must be a string or os.PathLike object",
148-
original_error=exc,
149-
) from exc
150-
except Exception as exc:
151-
raise HyperbrowserError("file_path is invalid", original_error=exc) from exc
152-
if not is_plain_string(normalized_path):
153-
raise HyperbrowserError("file_path must resolve to a string path")
146+
normalized_path = _normalize_file_path_text(file_path)
154147
try:
155148
with open(normalized_path, "rb") as file_obj:
156149
yield file_obj

tests/test_file_utils.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -528,6 +528,71 @@ def test_open_binary_file_rejects_non_string_fspath_results():
528528
pass
529529

530530

531+
def test_open_binary_file_rejects_string_subclass_fspath_results():
532+
class _PathLike:
533+
class _PathString(str):
534+
pass
535+
536+
def __fspath__(self):
537+
return self._PathString("/tmp/subclass-path")
538+
539+
with pytest.raises(HyperbrowserError, match="file_path must resolve to a string"):
540+
with open_binary_file(
541+
_PathLike(), # type: ignore[arg-type]
542+
open_error_message="open failed",
543+
):
544+
pass
545+
546+
547+
def test_open_binary_file_rejects_empty_string_paths():
548+
with pytest.raises(HyperbrowserError, match="file_path must not be empty"):
549+
with open_binary_file(
550+
"",
551+
open_error_message="open failed",
552+
):
553+
pass
554+
with pytest.raises(HyperbrowserError, match="file_path must not be empty"):
555+
with open_binary_file(
556+
" ",
557+
open_error_message="open failed",
558+
):
559+
pass
560+
561+
562+
def test_open_binary_file_rejects_surrounding_whitespace():
563+
with pytest.raises(
564+
HyperbrowserError,
565+
match="file_path must not contain leading or trailing whitespace",
566+
):
567+
with open_binary_file(
568+
" /tmp/file.txt",
569+
open_error_message="open failed",
570+
):
571+
pass
572+
573+
574+
def test_open_binary_file_rejects_null_byte_paths():
575+
with pytest.raises(
576+
HyperbrowserError, match="file_path must not contain null bytes"
577+
):
578+
with open_binary_file(
579+
"bad\x00path.txt",
580+
open_error_message="open failed",
581+
):
582+
pass
583+
584+
585+
def test_open_binary_file_rejects_control_character_paths():
586+
with pytest.raises(
587+
HyperbrowserError, match="file_path must not contain control characters"
588+
):
589+
with open_binary_file(
590+
"bad\tpath.txt",
591+
open_error_message="open failed",
592+
):
593+
pass
594+
595+
531596
def test_open_binary_file_wraps_open_errors(tmp_path: Path):
532597
missing_path = tmp_path / "missing.bin"
533598

0 commit comments

Comments
 (0)