Allow testing for mismatched SSH agent communication
Marco Ricci

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