Marco Ricci commited on 2026-06-13 18:52:55
Zeige 2 geänderte Dateien mit 94 Einfügungen und 76 Löschungen.
Generalize the mocked SSH agent machinery for simulating error responses slightly, so that instead of passing one-to-one request/response pairs, we pass one-to-many request/list-of-responses pairs. Responses are kept on a queue, and the machinery asserts that the queue is empty at the end of the interaction. This new flexibility is not yet required anywhere, but will be used in the next commit.
| ... | ... |
@@ -21,7 +21,7 @@ from __future__ import annotations |
| 21 | 21 |
|
| 22 | 22 |
import base64 |
| 23 | 23 |
import enum |
| 24 |
-from typing import TYPE_CHECKING, Protocol, cast |
|
| 24 |
+from typing import TYPE_CHECKING, cast |
|
| 25 | 25 |
|
| 26 | 26 |
from typing_extensions import NamedTuple |
| 27 | 27 |
|
| ... | ... |
@@ -31,7 +31,7 @@ from derivepassphrase.ssh_agent import socketprovider |
| 31 | 31 |
__all__ = () |
| 32 | 32 |
|
| 33 | 33 |
if TYPE_CHECKING: |
| 34 |
- from collections.abc import Iterable, Mapping |
|
| 34 |
+ from collections.abc import Mapping |
|
| 35 | 35 |
|
| 36 | 36 |
from typing_extensions import Any |
| 37 | 37 |
|
| ... | ... |
@@ -255,26 +255,6 @@ class RunningSSHAgentInfo(NamedTuple): |
| 255 | 255 |
return self.socket |
| 256 | 256 |
|
| 257 | 257 |
|
| 258 |
-# `derivepassphrase` internal functions |
|
| 259 |
-# ------------------------------------- |
|
| 260 |
- |
|
| 261 |
- |
|
| 262 |
-class RequestFunc(Protocol): |
|
| 263 |
- """The call signature of [`ssh_agent.SSHAgentClient.request`][].""" |
|
| 264 |
- |
|
| 265 |
- def __call__( |
|
| 266 |
- self, |
|
| 267 |
- request_code: int | _types.SSH_AGENTC, |
|
| 268 |
- payload: bytes | bytearray, |
|
| 269 |
- /, |
|
| 270 |
- *, |
|
| 271 |
- response_code: Iterable[int | _types.SSH_AGENT] |
|
| 272 |
- | int |
|
| 273 |
- | _types.SSH_AGENT |
|
| 274 |
- | None = None, |
|
| 275 |
- ) -> tuple[int, bytes | bytearray] | bytes | bytearray: ... |
|
| 276 |
- |
|
| 277 |
- |
|
| 278 | 258 |
# Vault configurations |
| 279 | 259 |
# ==================== |
| 280 | 260 |
|
| ... | ... |
@@ -7,6 +7,7 @@ |
| 7 | 7 |
from __future__ import annotations |
| 8 | 8 |
|
| 9 | 9 |
import base64 |
| 10 |
+import collections |
|
| 10 | 11 |
import contextlib |
| 11 | 12 |
import ctypes |
| 12 | 13 |
import importlib.metadata |
| ... | ... |
@@ -31,9 +32,9 @@ from tests.data import callables |
| 31 | 32 |
from tests.machinery import pytest as pytest_machinery |
| 32 | 33 |
|
| 33 | 34 |
if TYPE_CHECKING: |
| 34 |
- from collections.abc import Callable, Iterable, Iterator, Mapping |
|
| 35 |
+ from collections.abc import Callable, Iterator, Mapping, Sequence |
|
| 35 | 36 |
|
| 36 |
- from typing_extensions import Any, Literal |
|
| 37 |
+ from typing_extensions import Any, Literal, Self |
|
| 37 | 38 |
|
| 38 | 39 |
if sys.version_info < (3, 11): |
| 39 | 40 |
from exceptiongroup import ExceptionGroup |
| ... | ... |
@@ -1169,9 +1170,9 @@ class TestAgentErrorResponses: |
| 1169 | 1170 |
|
| 1170 | 1171 |
""" |
| 1171 | 1172 |
|
| 1172 |
- code: _types.SSH_AGENTC |
|
| 1173 |
+ code: _types.SSH_AGENTC | int |
|
| 1173 | 1174 |
"""""" |
| 1174 |
- payload: bytes | None |
|
| 1175 |
+ payload: bytes |
|
| 1175 | 1176 |
"""""" |
| 1176 | 1177 |
|
| 1177 | 1178 |
class Response(NamedTuple): |
| ... | ... |
@@ -1185,61 +1186,89 @@ class TestAgentErrorResponses: |
| 1185 | 1186 |
|
| 1186 | 1187 |
""" |
| 1187 | 1188 |
|
| 1188 |
- code: _types.SSH_AGENT |
|
| 1189 |
+ code: _types.SSH_AGENT | int |
|
| 1189 | 1190 |
"""""" |
| 1190 | 1191 |
payload: bytes |
| 1191 | 1192 |
"""""" |
| 1192 | 1193 |
|
| 1193 |
- def _make_request_stub( |
|
| 1194 |
- self, |
|
| 1195 |
- request_response_map: Mapping[Request, Response], |
|
| 1196 |
- /, |
|
| 1197 |
- ) -> data.RequestFunc: |
|
| 1198 |
- """Return a stubbed SSH agent client `request` function. |
|
| 1194 |
+ class _RequestResponseIOQueue: |
|
| 1195 |
+ """A response queue for the SSH agent protocol. |
|
| 1196 |
+ |
|
| 1197 |
+ This queue wraps a map of request messages |
|
| 1198 |
+ ([`Request`][TestAgentErrorResponses.Request]) to multiple |
|
| 1199 |
+ response messages |
|
| 1200 |
+ ([`Response`][TestAgentErrorResponses.Response]). Whenever |
|
| 1201 |
+ [`_request_message`][] is called with a payload that matches |
|
| 1202 |
+ a [`Request`][TestAgentErrorResponses.Response] from the map, |
|
| 1203 |
+ the corresponding [`Response`][TestAgentErrorResponses.Response] |
|
| 1204 |
+ objects are added to the queue, to be read by subsequent |
|
| 1205 |
+ [`_response_pair`][] calls. |
|
| 1206 |
+ |
|
| 1207 |
+ Use this agent stub implementation if you are modelling |
|
| 1208 |
+ well-formed requests and responses, without network errors or |
|
| 1209 |
+ protocol framing errors. If the latter are needed, a custom SSH |
|
| 1210 |
+ agent implementation may be necessary. |
|
| 1199 | 1211 |
|
| 1200 |
- Args: |
|
| 1201 |
- request_response_map: |
|
| 1202 |
- A map of request/response pairs. |
|
| 1212 |
+ The queue is a context manager. When entered or exited, it |
|
| 1213 |
+ verifies that the queue is empty. |
|
| 1203 | 1214 |
|
| 1204 | 1215 |
""" |
| 1205 | 1216 |
|
| 1206 |
- def request( |
|
| 1217 |
+ def __init__( |
|
| 1218 |
+ self, |
|
| 1219 |
+ request_responses_mapping: Mapping[ |
|
| 1220 |
+ TestAgentErrorResponses.Request, |
|
| 1221 |
+ Sequence[TestAgentErrorResponses.Response], |
|
| 1222 |
+ ], |
|
| 1223 |
+ ) -> None: |
|
| 1224 |
+ self.responses_map = request_responses_mapping |
|
| 1225 |
+ self.queue: collections.deque[TestAgentErrorResponses.Response] = ( |
|
| 1226 |
+ collections.deque() |
|
| 1227 |
+ ) |
|
| 1228 |
+ |
|
| 1229 |
+ def __bool__(self) -> bool: |
|
| 1230 |
+ return bool(self.queue) |
|
| 1231 |
+ |
|
| 1232 |
+ def __enter__(self) -> Self: |
|
| 1233 |
+ assert not self, ( |
|
| 1234 |
+ f"Agent I/O queue already has contents: {self.queue!r}"
|
|
| 1235 |
+ ) |
|
| 1236 |
+ return self |
|
| 1237 |
+ |
|
| 1238 |
+ def __exit__( |
|
| 1239 |
+ self, |
|
| 1240 |
+ exc_type: type[BaseException] | None, |
|
| 1241 |
+ exc_value: BaseException | None, |
|
| 1242 |
+ exc_tb: types.TracebackType | None, |
|
| 1243 |
+ ) -> Literal[False]: |
|
| 1244 |
+ if (exc_type, exc_value, exc_tb) != (None, None, None): |
|
| 1245 |
+ return False |
|
| 1246 |
+ assert not self, ( |
|
| 1247 |
+ f"Agent I/O queue still has contents: {self.queue!r}"
|
|
| 1248 |
+ ) |
|
| 1249 |
+ return False |
|
| 1250 |
+ |
|
| 1251 |
+ def _send_request_message( |
|
| 1252 |
+ self, |
|
| 1207 | 1253 |
request_code: int | _types.SSH_AGENTC, |
| 1208 | 1254 |
payload: bytes | bytearray, |
| 1209 |
- /, |
|
| 1210 |
- *, |
|
| 1211 |
- response_code: Iterable[int | _types.SSH_AGENT] |
|
| 1212 |
- | int |
|
| 1213 |
- | _types.SSH_AGENT |
|
| 1214 |
- | None = None, |
|
| 1215 |
- ) -> tuple[int, bytes | bytearray] | bytes | bytearray: |
|
| 1216 |
- request_code = _types.SSH_AGENTC(request_code) |
|
| 1217 |
- response_code = ( |
|
| 1218 |
- frozenset({_types.SSH_AGENT.SUCCESS})
|
|
| 1219 |
- if response_code is None |
|
| 1220 |
- else frozenset({response_code})
|
|
| 1221 |
- if isinstance(response_code, int) |
|
| 1222 |
- else response_code |
|
| 1223 |
- ) |
|
| 1224 |
- response_ = request_response_map.get( |
|
| 1225 |
- self.Request(request_code, bytes(payload)) |
|
| 1226 |
- ) or request_response_map.get(self.Request(request_code, None)) |
|
| 1227 |
- if response_ is None: # pragma: no cover [failsafe] |
|
| 1228 |
- raise ssh_agent.SSHAgentFailedError( |
|
| 1229 |
- _types.SSH_AGENT.FAILURE, |
|
| 1230 |
- "No prepared response for the request " |
|
| 1231 |
- f"({request_code!r}, {payload!r})".encode(),
|
|
| 1255 |
+ ) -> None: |
|
| 1256 |
+ req = TestAgentErrorResponses.Request(request_code, bytes(payload)) |
|
| 1257 |
+ assert req in self.responses_map, ( |
|
| 1258 |
+ f"No prepared response for the request {req!r}"
|
|
| 1232 | 1259 |
) |
| 1233 |
- code, response = response_ |
|
| 1234 |
- if code not in response_code: |
|
| 1235 |
- raise ssh_agent.SSHAgentFailedError(code, response) |
|
| 1236 |
- return response |
|
| 1260 |
+ self.queue.extend(self.responses_map[req]) |
|
| 1237 | 1261 |
|
| 1238 |
- return request |
|
| 1262 |
+ def _get_response_pair( |
|
| 1263 |
+ self, |
|
| 1264 |
+ ) -> tuple[int | _types.SSH_AGENT, bytes | bytearray]: |
|
| 1265 |
+ assert self.queue, "No more responses left to replay" |
|
| 1266 |
+ response = self.queue.popleft() |
|
| 1267 |
+ return response.code, response.payload |
|
| 1239 | 1268 |
|
| 1240 | 1269 |
@contextlib.contextmanager |
| 1241 | 1270 |
def _setup_agent_and_request_handler( |
| 1242 |
- self, request_response_map: Mapping[Request, Response], / |
|
| 1271 |
+ self, request_responses_map: Mapping[Request, Sequence[Response]], / |
|
| 1243 | 1272 |
) -> Iterator[tuple[pytest.MonkeyPatch, ssh_agent.SSHAgentClient]]: |
| 1244 | 1273 |
# TODO(the-13th-letter): Rewrite using parenthesized |
| 1245 | 1274 |
# with-statements. |
| ... | ... |
@@ -1249,10 +1278,17 @@ class TestAgentErrorResponses: |
| 1249 | 1278 |
client = stack.enter_context( |
| 1250 | 1279 |
ssh_agent.SSHAgentClient.ensure_agent_subcontext() |
| 1251 | 1280 |
) |
| 1281 |
+ agent_io_queue = self._RequestResponseIOQueue( |
|
| 1282 |
+ request_responses_map |
|
| 1283 |
+ ) |
|
| 1284 |
+ stack.enter_context(agent_io_queue) |
|
| 1252 | 1285 |
monkeypatch.setattr( |
| 1253 | 1286 |
client, |
| 1254 |
- "request", |
|
| 1255 |
- self._make_request_stub(request_response_map), |
|
| 1287 |
+ "_send_request_message", |
|
| 1288 |
+ agent_io_queue._send_request_message, |
|
| 1289 |
+ ) |
|
| 1290 |
+ monkeypatch.setattr( |
|
| 1291 |
+ client, "_get_response_pair", agent_io_queue._get_response_pair |
|
| 1256 | 1292 |
) |
| 1257 | 1293 |
yield monkeypatch, client |
| 1258 | 1294 |
|
| ... | ... |
@@ -1301,9 +1337,9 @@ class TestAgentErrorResponses: |
| 1301 | 1337 |
""" |
| 1302 | 1338 |
del running_ssh_agent |
| 1303 | 1339 |
with self._setup_agent_and_request_handler({
|
| 1304 |
- self.Request( |
|
| 1305 |
- _types.SSH_AGENTC.REQUEST_IDENTITIES, None |
|
| 1306 |
- ): self.Response(response_code, response), |
|
| 1340 |
+ self.Request(_types.SSH_AGENTC.REQUEST_IDENTITIES, b""): [ |
|
| 1341 |
+ self.Response(response_code, response) |
|
| 1342 |
+ ], |
|
| 1307 | 1343 |
}) as contexts: |
| 1308 | 1344 |
_, client = contexts |
| 1309 | 1345 |
with pytest.raises(exc_type, match=exc_pattern): |
| ... | ... |
@@ -1342,9 +1378,9 @@ class TestAgentErrorResponses: |
| 1342 | 1378 |
+ ssh_agent.SSHAgentClient.uint32(0) |
| 1343 | 1379 |
) |
| 1344 | 1380 |
with self._setup_agent_and_request_handler({
|
| 1345 |
- self.Request( |
|
| 1346 |
- _types.SSH_AGENTC.SIGN_REQUEST, sign_payload |
|
| 1347 |
- ): self.Response(response_code, response), |
|
| 1381 |
+ self.Request(_types.SSH_AGENTC.SIGN_REQUEST, sign_payload): [ |
|
| 1382 |
+ self.Response(response_code, response) |
|
| 1383 |
+ ], |
|
| 1348 | 1384 |
}) as contexts: |
| 1349 | 1385 |
monkeypatch, client = contexts |
| 1350 | 1386 |
monkeypatch.setattr(client, "list_keys", lambda: loaded_keys) |
| ... | ... |
@@ -1391,9 +1427,11 @@ class TestAgentErrorResponses: |
| 1391 | 1427 |
self.Request( |
| 1392 | 1428 |
_types.SSH_AGENTC.EXTENSION, |
| 1393 | 1429 |
ssh_agent.SSHAgentClient.string(b"query"), |
| 1394 |
- ): self.Response( |
|
| 1430 |
+ ): [ |
|
| 1431 |
+ self.Response( |
|
| 1395 | 1432 |
_types.SSH_AGENT.EXTENSION_RESPONSE, response_data |
| 1396 |
- ), |
|
| 1433 |
+ ) |
|
| 1434 |
+ ], |
|
| 1397 | 1435 |
}) as contexts: |
| 1398 | 1436 |
_, client = contexts |
| 1399 | 1437 |
with pytest.raises( |
| 1400 | 1438 |