Skip to content

Commit d878c36

Browse files
Merge remote-tracking branch 'origin/main' into stlc/promote-next
2 parents 019c2dc + dec9f14 commit d878c36

4 files changed

Lines changed: 35 additions & 8 deletions

File tree

src/hypeman/lib/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,23 @@
88
cp_to_instance_async,
99
cp_from_instance_async,
1010
)
11+
from ._ws import (
12+
SyncWebSocket,
13+
AsyncWebSocket,
14+
SyncWebSocketConnector,
15+
AsyncWebSocketConnector,
16+
)
1117
from .exec import ExecResult, ExecProtocolError, exec, exec_async
1218

1319
__all__ = [
20+
"AsyncWebSocket",
21+
"AsyncWebSocketConnector",
1422
"CopyCallbacks",
1523
"CopyProtocolError",
1624
"ExecProtocolError",
1725
"ExecResult",
26+
"SyncWebSocket",
27+
"SyncWebSocketConnector",
1828
"cp_from_instance",
1929
"cp_from_instance_async",
2030
"cp_to_instance",

src/hypeman/lib/cp.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -150,10 +150,12 @@ def _parse_message(frame: str) -> dict[str, object]:
150150
return message
151151

152152

153-
def _integer_field(message: dict[str, object], name: str, default: int | None = None) -> int:
153+
def _integer_field(
154+
message: dict[str, object], name: str, default: int | None = None, maximum: int | None = None
155+
) -> int:
154156
value = message.get(name, default)
155-
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
156-
raise CopyProtocolError(f"cp header {name} must be a non-negative integer")
157+
if isinstance(value, bool) or not isinstance(value, int) or value < 0 or (maximum is not None and value > maximum):
158+
raise CopyProtocolError(f"cp header {name} has an invalid integer value")
157159
return value
158160

159161

@@ -371,7 +373,7 @@ def _start(self, message: dict[str, object]) -> None:
371373
if self.header is not None:
372374
raise CopyProtocolError("cp sent a new header before ending the previous entry")
373375
target = self._safe_target(message.get("path"))
374-
mode = _integer_field(message, "mode")
376+
mode = _integer_field(message, "mode", maximum=0o777)
375377
size = _integer_field(message, "size")
376378
mtime = _integer_field(message, "mtime")
377379
uid = _integer_field(message, "uid", 0)
@@ -532,7 +534,7 @@ async def cp_from_instance_async(
532534
"""Asynchronous counterpart to :func:`cp_from_instance`."""
533535

534536
url, headers = connection_settings(client, instance_id, "cp")
535-
state = await asyncio.to_thread(_DownloadState, Path(dst_path), archive, callbacks)
537+
state = _DownloadState(Path(dst_path), archive, callbacks)
536538
try:
537539
async with connector(url, additional_headers=headers, max_size=None) as websocket:
538540
await websocket.send(_download_request(src_path, follow_symlinks))
@@ -541,7 +543,7 @@ async def cp_from_instance_async(
541543
frame = await websocket.recv()
542544
except Exception as exc:
543545
raise CopyProtocolError("cp download ended before the final marker") from exc
544-
await asyncio.to_thread(state.consume, frame)
546+
state.consume(frame)
545547
except BaseException:
546-
await asyncio.to_thread(state.abort)
548+
state.abort()
547549
raise

src/hypeman/lib/exec.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,8 @@ def _request(
8080
rows: int | None,
8181
cols: int | None,
8282
) -> _ExecRequest:
83+
if isinstance(command, str):
84+
raise ValueError("command must be an argument sequence, not a string")
8385
argv = list(command)
8486
if not argv:
8587
raise ValueError("command must contain at least one string argument")
@@ -145,7 +147,9 @@ def exec(
145147
"""Execute a command and collect its merged stdout/stderr bytes.
146148
147149
The request is dispatched once and is never retried. ``stdin`` is sent as binary
148-
WebSocket frames. TTY resize tuples are ``(rows, cols)``.
150+
WebSocket frames. The protocol has no stdin EOF frame, so commands must stop
151+
reading based on their input, another condition, or ``timeout``. TTY resize
152+
tuples are ``(rows, cols)``.
149153
"""
150154

151155
request = _request(

tests/lib/test_websocket_lib.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -468,3 +468,14 @@ def test_cp_download_rejects_malformed_control_frame(tmp_path: Path) -> None:
468468
connector = FakeConnector(deque([FakeWebSocket(deque(["not-json"]))]))
469469
with pytest.raises(CopyProtocolError, match="malformed JSON"):
470470
cp_from_instance(FakeClient(), "inst", "/guest", tmp_path, connector=connector)
471+
472+
473+
def test_exec_rejects_a_bare_command_string() -> None:
474+
with pytest.raises(ValueError, match="argument sequence"):
475+
exec(FakeClient(), "inst", "echo")
476+
477+
478+
def test_cp_download_rejects_invalid_mode(tmp_path: Path) -> None:
479+
connector = FakeConnector(deque([FakeWebSocket(deque([file_header("file", size=0, mode=0o4777)]))]))
480+
with pytest.raises(CopyProtocolError, match="mode"):
481+
cp_from_instance(FakeClient(), "inst", "/guest/file", tmp_path, connector=connector)

0 commit comments

Comments
 (0)