Skip to content

Commit 1898e3a

Browse files
Merge remote-tracking branch 'origin/main' into stlc/promote-next
2 parents d878c36 + caf7196 commit 1898e3a

4 files changed

Lines changed: 76 additions & 26 deletions

File tree

src/hypeman/lib/_ws.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
]
1717

1818

19+
MAX_INBOUND_MESSAGE_SIZE = 2**20
20+
21+
1922
class ClientConfig(Protocol):
2023
"""The generated client settings used by the custom WebSocket APIs."""
2124

@@ -46,7 +49,7 @@ def __exit__(
4649

4750

4851
class SyncWebSocketConnector(Protocol):
49-
def __call__(self, url: str, *, additional_headers: dict[str, str], max_size: int | None) -> SyncWebSocket: ...
52+
def __call__(self, url: str, *, additional_headers: dict[str, str], max_size: int) -> SyncWebSocket: ...
5053

5154

5255
class AsyncWebSocket(Protocol):
@@ -81,16 +84,24 @@ async def __aexit__(
8184

8285

8386
class AsyncWebSocketConnector(Protocol):
84-
def __call__(
85-
self, url: str, *, additional_headers: dict[str, str], max_size: int | None
86-
) -> AsyncWebSocketContext: ...
87+
def __call__(self, url: str, *, additional_headers: dict[str, str], max_size: int) -> AsyncWebSocketContext: ...
8788

8889

89-
def sync_connect(url: str, *, additional_headers: dict[str, str], max_size: int | None) -> SyncWebSocket:
90+
def sync_connect(
91+
url: str,
92+
*,
93+
additional_headers: dict[str, str],
94+
max_size: int = MAX_INBOUND_MESSAGE_SIZE,
95+
) -> SyncWebSocket:
9096
return cast(SyncWebSocket, websocket_connect(url, additional_headers=additional_headers, max_size=max_size))
9197

9298

93-
def async_connect(url: str, *, additional_headers: dict[str, str], max_size: int | None) -> AsyncWebSocketContext:
99+
def async_connect(
100+
url: str,
101+
*,
102+
additional_headers: dict[str, str],
103+
max_size: int = MAX_INBOUND_MESSAGE_SIZE,
104+
) -> AsyncWebSocketContext:
94105
return cast(
95106
AsyncWebSocketContext,
96107
async_websocket_connect(url, additional_headers=additional_headers, max_size=max_size),

src/hypeman/lib/cp.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from collections.abc import Callable
1313

1414
from ._ws import (
15+
MAX_INBOUND_MESSAGE_SIZE,
1516
ClientConfig,
1617
SyncWebSocket,
1718
AsyncWebSocket,
@@ -220,7 +221,7 @@ def cp_to_instance(
220221
)
221222
url, headers = connection_settings(client, instance_id, "cp")
222223
for entry in entries:
223-
with connector(url, additional_headers=headers, max_size=None) as websocket:
224+
with connector(url, additional_headers=headers, max_size=MAX_INBOUND_MESSAGE_SIZE) as websocket:
224225
websocket.send(_request(entry))
225226
if entry.is_dir:
226227
websocket.send('{"type":"end"}')
@@ -280,7 +281,7 @@ async def cp_to_instance_async(
280281
)
281282
url, headers = connection_settings(client, instance_id, "cp")
282283
for entry in entries:
283-
async with connector(url, additional_headers=headers, max_size=None) as websocket:
284+
async with connector(url, additional_headers=headers, max_size=MAX_INBOUND_MESSAGE_SIZE) as websocket:
284285
await websocket.send(_request(entry))
285286
if entry.is_dir:
286287
await websocket.send('{"type":"end"}')
@@ -507,7 +508,7 @@ def cp_from_instance(
507508
url, headers = connection_settings(client, instance_id, "cp")
508509
state = _DownloadState(Path(dst_path), archive, callbacks)
509510
try:
510-
with connector(url, additional_headers=headers, max_size=None) as websocket:
511+
with connector(url, additional_headers=headers, max_size=MAX_INBOUND_MESSAGE_SIZE) as websocket:
511512
websocket.send(_download_request(src_path, follow_symlinks))
512513
while not state.complete:
513514
try:
@@ -536,7 +537,7 @@ async def cp_from_instance_async(
536537
url, headers = connection_settings(client, instance_id, "cp")
537538
state = _DownloadState(Path(dst_path), archive, callbacks)
538539
try:
539-
async with connector(url, additional_headers=headers, max_size=None) as websocket:
540+
async with connector(url, additional_headers=headers, max_size=MAX_INBOUND_MESSAGE_SIZE) as websocket:
540541
await websocket.send(_download_request(src_path, follow_symlinks))
541542
while not state.complete:
542543
try:

src/hypeman/lib/exec.py

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from typing_extensions import TypeAlias
88

99
from ._ws import (
10+
MAX_INBOUND_MESSAGE_SIZE,
1011
ClientConfig,
1112
SyncWebSocketConnector,
1213
AsyncWebSocketConnector,
@@ -53,6 +54,7 @@ class _ExecRequest:
5354
wait_for_agent: int | None
5455
rows: int | None
5556
cols: int | None
57+
resize: tuple[tuple[int, int], ...]
5658

5759
def encode(self) -> str:
5860
payload: dict[str, object] = {"command": self.command, "tty": self.tty}
@@ -79,6 +81,7 @@ def _request(
7981
tty: bool,
8082
rows: int | None,
8183
cols: int | None,
84+
resize: Iterable[tuple[int, int]],
8285
) -> _ExecRequest:
8386
if isinstance(command, str):
8487
raise ValueError("command must be an argument sequence, not a string")
@@ -93,7 +96,13 @@ def _request(
9396
raise ValueError(f"{name} must be positive")
9497
if (rows is not None or cols is not None) and not tty:
9598
raise ValueError("rows and cols require tty=True")
96-
return _ExecRequest(argv, tty, env, cwd, timeout, wait_for_agent, rows, cols)
99+
resize_events = tuple(resize)
100+
for resize_rows, resize_cols in resize_events:
101+
if any(isinstance(value, bool) or value <= 0 for value in (resize_rows, resize_cols)):
102+
raise ValueError("resize dimensions must be positive integers")
103+
if resize_events and not tty:
104+
raise ValueError("resize requires tty=True")
105+
return _ExecRequest(argv, tty, env, cwd, timeout, wait_for_agent, rows, cols, resize_events)
97106

98107

99108
def _stdin_chunks(stdin: Stdin | None) -> Iterable[bytes]:
@@ -161,17 +170,16 @@ def exec(
161170
tty=tty,
162171
rows=rows,
163172
cols=cols,
173+
resize=resize,
164174
)
165175
url, headers = connection_settings(client, instance_id, "exec")
166176
output = bytearray()
167-
with connector(url, additional_headers=headers, max_size=None) as websocket:
177+
with connector(url, additional_headers=headers, max_size=MAX_INBOUND_MESSAGE_SIZE) as websocket:
168178
websocket.send(request.encode())
169179
for chunk in _stdin_chunks(stdin):
170180
if chunk:
171181
websocket.send(chunk)
172-
for resize_rows, resize_cols in resize:
173-
if not tty or resize_rows <= 0 or resize_cols <= 0:
174-
raise ValueError("resize dimensions must be positive and require tty=True")
182+
for resize_rows, resize_cols in request.resize:
175183
websocket.send(json.dumps({"resize": {"rows": resize_rows, "cols": resize_cols}}, separators=(",", ":")))
176184

177185
while True:
@@ -212,10 +220,11 @@ async def exec_async(
212220
tty=tty,
213221
rows=rows,
214222
cols=cols,
223+
resize=resize,
215224
)
216225
url, headers = connection_settings(client, instance_id, "exec")
217226
output = bytearray()
218-
async with connector(url, additional_headers=headers, max_size=None) as websocket:
227+
async with connector(url, additional_headers=headers, max_size=MAX_INBOUND_MESSAGE_SIZE) as websocket:
219228
await websocket.send(request.encode())
220229
if isinstance(stdin, AsyncIterable):
221230
async for chunk in stdin:
@@ -225,9 +234,7 @@ async def exec_async(
225234
for chunk in _stdin_chunks(stdin):
226235
if chunk:
227236
await websocket.send(chunk)
228-
for resize_rows, resize_cols in resize:
229-
if not tty or resize_rows <= 0 or resize_cols <= 0:
230-
raise ValueError("resize dimensions must be positive and require tty=True")
237+
for resize_rows, resize_cols in request.resize:
231238
await websocket.send(
232239
json.dumps({"resize": {"rows": resize_rows, "cols": resize_cols}}, separators=(",", ":"))
233240
)

tests/lib/test_websocket_lib.py

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from collections.abc import AsyncIterator
1010

1111
import pytest
12+
from websockets.exceptions import PayloadTooBig
1213

1314
from hypeman.lib import (
1415
CopyCallbacks,
@@ -62,9 +63,9 @@ def __exit__(
6263
@dataclass
6364
class FakeConnector:
6465
connections: deque[FakeWebSocket]
65-
calls: list[tuple[str, dict[str, str], int | None]] = field(default_factory=lambda: [])
66+
calls: list[tuple[str, dict[str, str], int]] = field(default_factory=lambda: [])
6667

67-
def __call__(self, url: str, *, additional_headers: dict[str, str], max_size: int | None) -> FakeWebSocket:
68+
def __call__(self, url: str, *, additional_headers: dict[str, str], max_size: int) -> FakeWebSocket:
6869
self.calls.append((url, additional_headers, max_size))
6970
return self.connections.popleft()
7071

@@ -102,9 +103,9 @@ async def __aexit__(
102103
@dataclass
103104
class FakeAsyncConnector:
104105
connections: deque[FakeAsyncWebSocket]
105-
calls: list[tuple[str, dict[str, str], int | None]] = field(default_factory=lambda: [])
106+
calls: list[tuple[str, dict[str, str], int]] = field(default_factory=lambda: [])
106107

107-
def __call__(self, url: str, *, additional_headers: dict[str, str], max_size: int | None) -> FakeAsyncWebSocket:
108+
def __call__(self, url: str, *, additional_headers: dict[str, str], max_size: int) -> FakeAsyncWebSocket:
108109
self.calls.append((url, additional_headers, max_size))
109110
return self.connections.popleft()
110111

@@ -155,7 +156,7 @@ def test_exec_uses_client_auth_url_and_all_request_dimensions() -> None:
155156
assert result.output == b"outerr"
156157
assert result.exit_code == 0
157158
assert connector.calls == [
158-
("wss://example.test/api/instances/inst_123/exec", {"Authorization": "Bearer secret"}, None)
159+
("wss://example.test/api/instances/inst_123/exec", {"Authorization": "Bearer secret"}, 2**20)
159160
]
160161
assert json.loads(str(websocket.sent[0])) == {
161162
"command": ["sh", "-lc", "echo hi"],
@@ -196,6 +197,31 @@ def test_exec_never_retries_after_dispatch() -> None:
196197
assert len(websocket.sent) == 1
197198

198199

200+
@pytest.mark.parametrize(("tty", "resize"), [(False, [(24, 80)]), (True, [(0, 80)]), (True, [(24, -1)])])
201+
def test_exec_rejects_invalid_resize_before_connect(tty: bool, resize: list[tuple[int, int]]) -> None:
202+
connector = FakeConnector(deque())
203+
with pytest.raises(ValueError, match="resize"):
204+
exec(FakeClient(), "inst", ["true"], tty=tty, resize=resize, connector=connector)
205+
assert not connector.calls
206+
207+
208+
def test_exec_rejects_oversized_inbound_message() -> None:
209+
oversized = PayloadTooBig(2**20 + 1, 2**20)
210+
connector = FakeConnector(deque([FakeWebSocket(deque([oversized]))]))
211+
with pytest.raises(ExecProtocolError, match="before an exitCode") as exc_info:
212+
exec(FakeClient(), "inst", ["true"], connector=connector)
213+
assert isinstance(exc_info.value.__cause__, PayloadTooBig)
214+
assert connector.calls[0][2] == 2**20
215+
216+
217+
@pytest.mark.asyncio
218+
async def test_exec_async_rejects_invalid_resize_before_connect() -> None:
219+
connector = FakeAsyncConnector(deque())
220+
with pytest.raises(ValueError, match="resize"):
221+
await exec_async(FakeClient(), "inst", ["true"], tty=False, resize=[(24, 80)], connector=connector)
222+
assert not connector.calls
223+
224+
199225
@pytest.mark.asyncio
200226
async def test_exec_async_supports_streaming_stdin() -> None:
201227
websocket = FakeAsyncWebSocket(deque([b"done", '{"exitCode":7}']))
@@ -248,6 +274,7 @@ def test_cp_upload_file_preserves_mode_and_reports_progress(tmp_path: Path) -> N
248274
expected_request["gid"] = source_stat.st_gid
249275
assert request == expected_request
250276
assert websocket.sent[1:] == [b"payload", '{"type":"end"}']
277+
assert connector.calls[0][2] == 2**20
251278
assert events == [
252279
("start", (str(source), 7)),
253280
("progress", 7),
@@ -420,27 +447,31 @@ async def test_cp_async_upload_and_download(tmp_path: Path) -> None:
420447
source = tmp_path / "source"
421448
source.write_bytes(b"async")
422449
upload_socket = FakeAsyncWebSocket(deque([upload_result(5)]))
450+
upload_connector = FakeAsyncConnector(deque([upload_socket]))
423451
await cp_to_instance_async(
424452
FakeClient(),
425453
"inst",
426454
source,
427455
"/guest/source",
428-
connector=FakeAsyncConnector(deque([upload_socket])),
456+
connector=upload_connector,
429457
)
430458
assert upload_socket.sent[1] == b"async"
459+
assert upload_connector.calls[0][2] == 2**20
431460

432461
download_socket = FakeAsyncWebSocket(
433462
deque([file_header("result", size=5), b"async", text_frame({"type": "end", "final": True})])
434463
)
435464
destination = tmp_path / "dest"
465+
download_connector = FakeAsyncConnector(deque([download_socket]))
436466
await cp_from_instance_async(
437467
FakeClient(),
438468
"inst",
439469
"/guest/result",
440470
destination,
441-
connector=FakeAsyncConnector(deque([download_socket])),
471+
connector=download_connector,
442472
)
443473
assert (destination / "result").read_bytes() == b"async"
474+
assert download_connector.calls[0][2] == 2**20
444475

445476

446477
def test_invalid_instance_id_is_rejected_before_connect() -> None:

0 commit comments

Comments
 (0)