Reorganize and rename SSH agent mock objects
Marco Ricci

Marco Ricci commited on 2026-06-13 19:22:20
Zeige 3 geänderte Dateien mit 153 Einfügungen und 139 Löschungen.


Rename the `Request` and `Response` classes to `AgentProtocolRequest`
and `AgentProtocolResponse`, respectively, and move them into the
`tests.data` package.  Rename the `_RequestResponseIOQueue` class to
`AgentProtocolResponseQueue`, and move it into the `tests.machinery`
package.

Along the way, fix the self-reference of `tests.data` and
`tests.machinery` contents within the `tests.machinery` package.
... ...
@@ -255,6 +255,40 @@ class RunningSSHAgentInfo(NamedTuple):
255 255
         return self.socket
256 256
 
257 257
 
258
+class AgentProtocolRequest(NamedTuple):
259
+    """A request for the SSH agent protocol.
260
+
261
+    Attributes:
262
+        code:
263
+            The expected request code.
264
+        payload:
265
+            The expected payload.  Optional.
266
+
267
+    """
268
+
269
+    code: _types.SSH_AGENTC | int
270
+    """"""
271
+    payload: bytes
272
+    """"""
273
+
274
+
275
+class AgentProtocolResponse(NamedTuple):
276
+    """A response for the SSH agent protocol.
277
+
278
+    Attributes:
279
+        code:
280
+            The desired response code.
281
+        payload:
282
+            The desired response payload.
283
+
284
+    """
285
+
286
+    code: _types.SSH_AGENT | int
287
+    """"""
288
+    payload: bytes
289
+    """"""
290
+
291
+
258 292
 # Vault configurations
259 293
 # ====================
260 294
 
... ...
@@ -12,6 +12,7 @@ wrappers, stub systems, and the like.
12 12
 
13 13
 from __future__ import annotations
14 14
 
15
+import collections
15 16
 import contextlib
16 17
 import errno
17 18
 import logging
... ...
@@ -23,16 +24,17 @@ from typing import TYPE_CHECKING, TypedDict
23 24
 import click.testing
24 25
 from typing_extensions import NamedTuple
25 26
 
26
-import tests.data
27 27
 from derivepassphrase import _types, cli, ssh_agent, vault
28 28
 from derivepassphrase.ssh_agent import socketprovider
29
+from tests import data
29 30
 
30 31
 __all__ = ()
31 32
 
32 33
 if TYPE_CHECKING:
34
+    import types
33 35
     from collections.abc import Callable, Iterator, Mapping, Sequence
34 36
     from contextlib import AbstractContextManager
35
-    from typing import IO, NotRequired
37
+    from typing import IO, Literal, NotRequired
36 38
 
37 39
     from typing_extensions import Any, Buffer, Self
38 40
 
... ...
@@ -191,7 +193,7 @@ class ReadableResult(NamedTuple):
191 193
                         error_match(error, line)
192 194
                         for line in self.stderr.splitlines(True)
193 195
                     )
194
-                    or tests.machinery.error_emitted(error, record_tuples)
196
+                    or error_emitted(error, record_tuples)
195 197
                 )
196 198
             )
197 199
 
... ...
@@ -295,6 +297,84 @@ class CliRunner:
295 297
         )
296 298
 
297 299
 
300
+# Stubbed SSH agent request/response queue
301
+# ========================================
302
+
303
+
304
+class AgentProtocolResponseQueue:
305
+    """A response queue for the SSH agent protocol.
306
+
307
+    This queue wraps a map of request messages
308
+    ([`AgentProtocolRequest`][data.AgentProtocolRequest]) to multiple
309
+    response messages
310
+    ([`AgentProtocolResponse`][data.AgentProtocolResponse]).  Whenever
311
+    [`_request_message`][] is called with a payload that matches
312
+    a [`AgentProtocolRequest`][data.AgentProtocolRequest] from the map,
313
+    the corresponding
314
+    [`AgentProtocolResponse`][data.AgentProtocolResponse] objects are
315
+    added to the queue, to be read by subsequent [`_response_pair`][]
316
+    calls.
317
+
318
+    The queue is a context manager.  When entered or exited, it
319
+    verifies that the queue is empty.
320
+
321
+    Use this agent stub implementation if you are modelling well-formed
322
+    requests and responses, without network errors or protocol framing
323
+    errors.  For modelling the latter, see [`StubbedSSHAgentSocket`][].
324
+
325
+    """
326
+
327
+    def __init__(
328
+        self,
329
+        request_responses_mapping: Mapping[
330
+            data.AgentProtocolRequest,
331
+            Sequence[data.AgentProtocolResponse],
332
+        ],
333
+    ) -> None:
334
+        self.responses_map = request_responses_mapping
335
+        self.queue: collections.deque[data.AgentProtocolResponse] = (
336
+            collections.deque()
337
+        )
338
+
339
+    def __bool__(self) -> bool:
340
+        return bool(self.queue)
341
+
342
+    def __enter__(self) -> Self:
343
+        assert not self, (
344
+            f"Agent I/O queue already has contents: {self.queue!r}"
345
+        )
346
+        return self
347
+
348
+    def __exit__(
349
+        self,
350
+        exc_type: type[BaseException] | None,
351
+        exc_value: BaseException | None,
352
+        exc_tb: types.TracebackType | None,
353
+    ) -> Literal[False]:
354
+        if (exc_type, exc_value, exc_tb) != (None, None, None):
355
+            return False
356
+        assert not self, f"Agent I/O queue still has contents: {self.queue!r}"
357
+        return False
358
+
359
+    def _send_request_message(
360
+        self,
361
+        request_code: int | _types.SSH_AGENTC,
362
+        payload: bytes | bytearray,
363
+    ) -> None:
364
+        req = data.AgentProtocolRequest(request_code, bytes(payload))
365
+        assert req in self.responses_map, (
366
+            f"No prepared response for the request {req!r}"
367
+        )
368
+        self.queue.extend(self.responses_map[req])
369
+
370
+    def _get_response_pair(
371
+        self,
372
+    ) -> tuple[int | _types.SSH_AGENT, bytes | bytearray]:
373
+        assert self.queue, "No more responses left to replay"
374
+        response = self.queue.popleft()
375
+        return response.code, response.payload
376
+
377
+
298 378
 # Stubbed SSH agent socket
299 379
 # ========================
300 380
 
... ...
@@ -522,18 +602,18 @@ class StubbedSSHAgentSocket:
522 602
         else:
523 603
             yield _types.SSH_AGENT.IDENTITIES_ANSWER
524 604
         signature_classes = [
525
-            tests.data.SSHTestKeyDeterministicSignatureClass.SPEC,
605
+            data.SSHTestKeyDeterministicSignatureClass.SPEC,
526 606
         ]
527 607
         if (
528 608
             "list-extended@putty.projects.tartarus.org"
529 609
             in self.enabled_extensions
530 610
         ):
531 611
             signature_classes.append(
532
-                tests.data.SSHTestKeyDeterministicSignatureClass.RFC_6979
612
+                data.SSHTestKeyDeterministicSignatureClass.RFC_6979
533 613
             )
534 614
         keys = [
535 615
             v
536
-            for v in tests.data.ALL_KEYS.values()
616
+            for v in data.ALL_KEYS.values()
537 617
             if any(cls in v.expected_signatures for cls in signature_classes)
538 618
         ]
539 619
         yield from ssh_agent.SSHAgentClient.uint32(len(keys))
... ...
@@ -559,8 +639,8 @@ class StubbedSSHAgentSocket:
559 639
             "list-extended@putty.projects.tartarus.org"
560 640
             in self.enabled_extensions
561 641
         )
562
-        spec = tests.data.SSHTestKeyDeterministicSignatureClass.SPEC
563
-        rfc6979 = tests.data.SSHTestKeyDeterministicSignatureClass.RFC_6979
642
+        spec = data.SSHTestKeyDeterministicSignatureClass.SPEC
643
+        rfc6979 = data.SSHTestKeyDeterministicSignatureClass.RFC_6979
564 644
         key_blob, rest = ssh_agent.SSHAgentClient.unstring_prefix(
565 645
             self.receive_from_client[self.HEADER_SIZE + self.CODE_SIZE :]
566 646
         )
... ...
@@ -572,7 +652,7 @@ class StubbedSSHAgentSocket:
572 652
             raise ValueError(self._UNSUPPORTED_REQUEST)
573 653
         if sign_data != vault.Vault.UUID:
574 654
             raise ValueError(self._UNSUPPORTED_REQUEST)
575
-        for key in tests.data.ALL_KEYS.values():
655
+        for key in data.ALL_KEYS.values():
576 656
             if key.public_key_data == key_blob:
577 657
                 if spec in key.expected_signatures:
578 658
                     return int.to_bytes(
... ...
@@ -7,7 +7,6 @@
7 7
 from __future__ import annotations
8 8
 
9 9
 import base64
10
-import collections
11 10
 import contextlib
12 11
 import ctypes
13 12
 import importlib.metadata
... ...
@@ -16,7 +15,7 @@ import re
16 15
 import socket
17 16
 import sys
18 17
 import types
19
-from typing import TYPE_CHECKING, Final, NamedTuple
18
+from typing import TYPE_CHECKING, Final
20 19
 
21 20
 import click
22 21
 import hypothesis
... ...
@@ -34,7 +33,7 @@ from tests.machinery import pytest as pytest_machinery
34 33
 if TYPE_CHECKING:
35 34
     from collections.abc import Callable, Iterator, Mapping, Sequence
36 35
 
37
-    from typing_extensions import Any, Literal, Self
36
+    from typing_extensions import Any, Literal
38 37
 
39 38
 if sys.version_info < (3, 11):
40 39
     from exceptiongroup import ExceptionGroup
... ...
@@ -1159,116 +1158,13 @@ class TestOtherConstructorFeatures:
1159 1158
 class TestAgentErrorResponses:
1160 1159
     """Test actually talking to the SSH agent: errors from the SSH agent."""
1161 1160
 
1162
-    class Request(NamedTuple):
1163
-        """A request for the SSH agent protocol.
1164
-
1165
-        Attributes:
1166
-            code:
1167
-                The expected request code.
1168
-            payload:
1169
-                The expected payload.  Optional.
1170
-
1171
-        """
1172
-
1173
-        code: _types.SSH_AGENTC | int
1174
-        """"""
1175
-        payload: bytes
1176
-        """"""
1177
-
1178
-    class Response(NamedTuple):
1179
-        """A response for the SSH agent protocol.
1180
-
1181
-        Attributes:
1182
-            code:
1183
-                The desired response code.
1184
-            payload:
1185
-                The desired response payload.
1186
-
1187
-        """
1188
-
1189
-        code: _types.SSH_AGENT | int
1190
-        """"""
1191
-        payload: bytes
1192
-        """"""
1193
-
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.
1211
-
1212
-        The queue is a context manager.  When entered or exited, it
1213
-        verifies that the queue is empty.
1214
-
1215
-        """
1216
-
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,
1253
-            request_code: int | _types.SSH_AGENTC,
1254
-            payload: bytes | bytearray,
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}"
1259
-            )
1260
-            self.queue.extend(self.responses_map[req])
1261
-
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
1268
-
1269 1161
     @contextlib.contextmanager
1270 1162
     def _setup_agent_and_request_handler(
1271
-        self, request_responses_map: Mapping[Request, Sequence[Response]], /
1163
+        self,
1164
+        request_responses_map: Mapping[
1165
+            data.AgentProtocolRequest, Sequence[data.AgentProtocolResponse]
1166
+        ],
1167
+        /,
1272 1168
     ) -> Iterator[tuple[pytest.MonkeyPatch, ssh_agent.SSHAgentClient]]:
1273 1169
         # TODO(the-13th-letter): Rewrite using parenthesized
1274 1170
         # with-statements.
... ...
@@ -1278,7 +1174,7 @@ class TestAgentErrorResponses:
1278 1174
             client = stack.enter_context(
1279 1175
                 ssh_agent.SSHAgentClient.ensure_agent_subcontext()
1280 1176
             )
1281
-            agent_io_queue = self._RequestResponseIOQueue(
1177
+            agent_io_queue = machinery.AgentProtocolResponseQueue(
1282 1178
                 request_responses_map
1283 1179
             )
1284 1180
             stack.enter_context(agent_io_queue)
... ...
@@ -1337,9 +1233,9 @@ class TestAgentErrorResponses:
1337 1233
         """
1338 1234
         del running_ssh_agent
1339 1235
         with self._setup_agent_and_request_handler({
1340
-            self.Request(_types.SSH_AGENTC.REQUEST_IDENTITIES, b""): [
1341
-                self.Response(response_code, response)
1342
-            ],
1236
+            data.AgentProtocolRequest(
1237
+                _types.SSH_AGENTC.REQUEST_IDENTITIES, b""
1238
+            ): [data.AgentProtocolResponse(response_code, response)],
1343 1239
         }) as contexts:
1344 1240
             _, client = contexts
1345 1241
             with pytest.raises(exc_type, match=exc_pattern):
... ...
@@ -1378,9 +1274,9 @@ class TestAgentErrorResponses:
1378 1274
             + ssh_agent.SSHAgentClient.uint32(0)
1379 1275
         )
1380 1276
         with self._setup_agent_and_request_handler({
1381
-            self.Request(_types.SSH_AGENTC.SIGN_REQUEST, sign_payload): [
1382
-                self.Response(response_code, response)
1383
-            ],
1277
+            data.AgentProtocolRequest(
1278
+                _types.SSH_AGENTC.SIGN_REQUEST, sign_payload
1279
+            ): [data.AgentProtocolResponse(response_code, response)],
1384 1280
         }) as contexts:
1385 1281
             monkeypatch, client = contexts
1386 1282
             monkeypatch.setattr(client, "list_keys", lambda: loaded_keys)
... ...
@@ -1424,11 +1320,11 @@ class TestAgentErrorResponses:
1424 1320
         """Fail on malformed responses while querying extensions."""
1425 1321
         del running_ssh_agent
1426 1322
         with self._setup_agent_and_request_handler({
1427
-            self.Request(
1323
+            data.AgentProtocolRequest(
1428 1324
                 _types.SSH_AGENTC.EXTENSION,
1429 1325
                 ssh_agent.SSHAgentClient.string(b"query"),
1430 1326
             ): [
1431
-                self.Response(
1327
+                data.AgentProtocolResponse(
1432 1328
                     _types.SSH_AGENT.EXTENSION_RESPONSE, response_data
1433 1329
                 )
1434 1330
             ],
... ...
@@ -1447,7 +1343,7 @@ class TestAgentErrorResponses:
1447 1343
         code: Literal[
1448 1344
             _types.SSH_AGENT.EXTENSION_RESPONSE, _types.SSH_AGENT.SUCCESS
1449 1345
         ] = _types.SSH_AGENT.EXTENSION_RESPONSE,
1450
-    ) -> Response:
1346
+    ) -> data.AgentProtocolResponse:
1451 1347
         """Construct a response object from the given extensions.
1452 1348
 
1453 1349
         Args:
... ...
@@ -1463,30 +1359,32 @@ class TestAgentErrorResponses:
1463 1359
         payload = bytearray(b"\x00\x00\x00\x05query")
1464 1360
         for ext in extensions:
1465 1361
             payload.extend(TestStaticFunctionality.as_ssh_string(ext.encode()))
1466
-        return self.Response(code, bytes(payload))
1362
+        return data.AgentProtocolResponse(code, bytes(payload))
1467 1363
 
1468
-    IDENTITIES_REQUEST: Final = Request(
1364
+    IDENTITIES_REQUEST: Final = data.AgentProtocolRequest(
1469 1365
         _types.SSH_AGENTC.REQUEST_IDENTITIES, b""
1470 1366
     )
1471
-    IDENTITIES_RESPONSE: Final = Response(
1367
+    IDENTITIES_RESPONSE: Final = data.AgentProtocolResponse(
1472 1368
         _types.SSH_AGENT.IDENTITIES_ANSWER, b"\x00\x00\x00\x00"
1473 1369
     )
1474
-    QUERY_REQUEST: Final = Request(
1370
+    QUERY_REQUEST: Final = data.AgentProtocolRequest(
1475 1371
         _types.SSH_AGENTC.EXTENSION, b"\x00\x00\x00\x05query"
1476 1372
     )
1477
-    EXTRA_RESPONSE: Final = Response(_types.SSH_AGENT.SUCCESS, b"")
1373
+    EXTRA_RESPONSE: Final = data.AgentProtocolResponse(
1374
+        _types.SSH_AGENT.SUCCESS, b""
1375
+    )
1478 1376
 
1479 1377
     @pytest.mark.parametrize(
1480 1378
         ["success_after_query", "extensions"],
1481 1379
         [
1482 1380
             pytest.param(
1483 1381
                 False,
1484
-                Response(_types.SSH_AGENT.FAILURE, b""),
1382
+                data.AgentProtocolResponse(_types.SSH_AGENT.FAILURE, b""),
1485 1383
                 id="openssh-10.2",
1486 1384
             ),
1487 1385
             pytest.param(
1488 1386
                 True,
1489
-                Response(_types.SSH_AGENT.FAILURE, b""),
1387
+                data.AgentProtocolResponse(_types.SSH_AGENT.FAILURE, b""),
1490 1388
                 id="openssh-10.2-mocked",
1491 1389
                 marks=[
1492 1390
                     pytest.mark.xfail(
... ...
@@ -1528,7 +1426,9 @@ class TestAgentErrorResponses:
1528 1426
         ],
1529 1427
     )
1530 1428
     def test_tolerating_extra_success_after_extension_response(
1531
-        self, success_after_query: bool, extensions: list[str] | Response
1429
+        self,
1430
+        success_after_query: bool,
1431
+        extensions: list[str] | data.AgentProtocolResponse,
1532 1432
     ) -> None:
1533 1433
         """Tolerate an extra empty SUCCESS message from a "query" EXTENSION.
1534 1434
 
... ...
@@ -1548,7 +1448,7 @@ class TestAgentErrorResponses:
1548 1448
         """
1549 1449
         query_response = (
1550 1450
             extensions
1551
-            if isinstance(extensions, self.Response)
1451
+            if isinstance(extensions, data.AgentProtocolResponse)
1552 1452
             else self._query_response(*extensions)
1553 1453
         )
1554 1454
         request_responses_map = {
1555 1455