Coverage for tests / machinery / __init__.py: 100.000%
237 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-07-06 21:34 +0200
« prev ^ index » next coverage.py v7.13.1, created at 2026-07-06 21:34 +0200
1# SPDX-FileCopyrightText: 2026 Marco Ricci <software@the13thletter.info>
2#
3# SPDX-License-Identifier: Zlib
5"""Testing machinery for `derivepassphrase`.
7This is all the standalone, non-test-system-specific machinery used to
8test `derivepassphrase`; this includes global settings, external API
9wrappers, stub systems, and the like.
11"""
13from __future__ import annotations
15import collections
16import contextlib
17import errno
18import logging
19import os
20import re
21import sys
22from typing import TYPE_CHECKING, TypedDict
24import click.testing
25from typing_extensions import NamedTuple
27from derivepassphrase import _types, cli, ssh_agent, vault
28from derivepassphrase.ssh_agent import socketprovider
29from tests import data
31__all__ = ()
33if TYPE_CHECKING:
34 import types
35 from collections.abc import (
36 Callable,
37 Generator,
38 Iterable,
39 Mapping,
40 Sequence,
41 )
42 from contextlib import AbstractContextManager
43 from typing import IO, Literal, NotRequired
45 from typing_extensions import Any, Buffer, Self
48# Test suite settings
49# ===================
51MIN_CONCURRENCY = 4
52"""
53The minimum amount of concurrent threads used for testing.
54"""
57def get_concurrency_limit() -> int:
58 """Return the imposed limit on the number of concurrent threads.
60 We use [`os.process_cpu_count`][] as the limit on Python 3.13 and
61 higher, and [`os.cpu_count`][] on Python 3.12 and below. On
62 Python 3.12 and below, we explicitly support the `PYTHON_CPU_COUNT`
63 environment variable. We guarantee at least [`MIN_CONCURRENCY`][]
64 many threads in any case.
66 """ # noqa: RUF002
67 result: int | None = None 1aF
68 if sys.version_info >= (3, 13): 1aF
69 result = os.process_cpu_count() 1aF
70 else:
71 with contextlib.suppress(KeyError, ValueError): 1aF
72 result = result or int(os.environ["PYTHON_CPU_COUNT"], 10) 1aF
73 with contextlib.suppress(AttributeError): 1aF
74 result = result or len(os.sched_getaffinity(os.getpid())) 1aF
75 return max(result if result is not None else 0, MIN_CONCURRENCY) 1aF
78# Log/Error message searching
79# ===========================
82def message_emitted_factory(
83 level: int,
84 *,
85 logger_name: str = cli.PROG_NAME,
86) -> Callable[[str | re.Pattern[str], Sequence[tuple[str, int, str]]], bool]:
87 """Return a function to test if a matching message was emitted.
89 Args:
90 level: The level to match messages at.
91 logger_name: The name of the logger to match against.
93 """
95 def message_emitted(
96 text: str | re.Pattern[str],
97 record_tuples: Sequence[tuple[str, int, str]],
98 ) -> bool:
99 """Return true if a matching message was emitted.
101 Args:
102 text: Substring or pattern to match against.
103 record_tuples: Items to match.
105 """
107 def check_record(record: tuple[str, int, str]) -> bool: 1OzPAQRBKLSTMNUVWXYZCGHDEI
108 if record[:2] != (logger_name, level): 1OzPAQRBKLSTUVWXYZCGHDEI
109 return False 1KLCDE
110 if isinstance(text, str): 1OzPAQRBKLSTUVWXYZCGHDEI
111 return text in record[2] 1OzPAQRBKLSTUVWXYZCGHDEI
112 return text.match(record[2]) is not None # pragma: no cover
114 return any(map(check_record, record_tuples)) 1OzPAQRBKLSTMNUVWXYZCGHDEI
116 return message_emitted
119# No need to assert debug messages as of yet.
120info_emitted = message_emitted_factory(logging.INFO)
121warning_emitted = message_emitted_factory(logging.WARNING)
122deprecation_warning_emitted = message_emitted_factory(
123 logging.WARNING, logger_name=f"{cli.PROG_NAME}.deprecation"
124)
125deprecation_info_emitted = message_emitted_factory(
126 logging.INFO, logger_name=f"{cli.PROG_NAME}.deprecation"
127)
128error_emitted = message_emitted_factory(logging.ERROR)
131# click.testing.CliRunner handling
132# ================================
135class ReadableResult(NamedTuple):
136 """Helper class for formatting and testing click.testing.Result objects."""
138 exception: BaseException | None
139 exit_code: int
140 stdout: str
141 stderr: str
143 def clean_exit(
144 self, *, output: str = "", empty_stderr: bool = False
145 ) -> bool:
146 """Return whether the invocation exited cleanly.
148 Args:
149 output:
150 An expected output string.
152 """
153 return ( 2a F O ] ^ _ ` { | P Q R K L } 3 4 ~ abbbcbdbebfbS 0 gbhbs c n T ibjbkblbU mbV W nbX Y 5 @ obZ pbqbrbi
154 (
155 not self.exception
156 or (
157 isinstance(self.exception, SystemExit)
158 and self.exit_code == 0
159 )
160 )
161 and (not output or output in self.stdout)
162 and (not empty_stderr or not self.stderr)
163 )
165 def error_exit(
166 self,
167 *,
168 error: str | re.Pattern[str] | type[BaseException] = BaseException,
169 record_tuples: Sequence[tuple[str, int, str]] = (),
170 ) -> bool:
171 """Return whether the invocation exited uncleanly.
173 Args:
174 error:
175 An expected error message, or an expected numeric error
176 code, or an expected exception type.
178 """
180 def error_match(error: str | re.Pattern[str], line: str) -> bool: 16z78AB349!#$0%'(M)N*+,mo-./p:;qr=5CGHDE?I
181 return ( 16z78AB9!#$0%'(M)N*+,mo-./p:;qr=?
182 error in line
183 if isinstance(error, str)
184 else error.match(line) is not None
185 )
187 # TODO(the-13th-letter): Rewrite using structural pattern matching.
188 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9
189 if isinstance(error, type): 16z78AB349!#$0%'(M)N*+,mo-./p:;qr=5CGHDE?I
190 return isinstance(self.exception, error) 134
191 else: # noqa: RET505
192 assert isinstance(error, (str, re.Pattern)) 16z78AB9!#$0%'(M)N*+,mo-./p:;qr=5CGHDE?I
193 return ( 16z78AB9!#$0%'(M)N*+,mo-./p:;qr=5CGHDE?I
194 isinstance(self.exception, SystemExit)
195 and self.exit_code > 0
196 and (
197 not error
198 or any(
199 error_match(error, line)
200 for line in self.stderr.splitlines(True)
201 )
202 or error_emitted(error, record_tuples)
203 )
204 )
207class CliRunner:
208 """An abstracted CLI runner class.
210 Intended to provide similar functionality and scope as the
211 [`click.testing.CliRunner`][] class, though not necessarily
212 `click`-specific. Also allows for seamless migration away from
213 `click`, if/when we decide this.
215 """
217 _SUPPORTS_MIX_STDERR_ATTRIBUTE = not hasattr(click.testing, "StreamMixer")
218 """
219 True if and only if [`click.testing.CliRunner`][] supports the
220 `mix_stderr` attribute. It was removed in 8.2.0 in favor of the
221 `click.testing.StreamMixer` class.
223 See also
224 [`pallets/click#2523`](https://github.com/pallets/click/pull/2523).
225 """
227 def __init__(
228 self,
229 *,
230 mix_stderr: bool = False,
231 color: bool | None = None,
232 ) -> None:
233 self.color = color 2a F 6 O z 7 8 sb] ^ _ ` { | tbubvbP wbA Q R B K L xbybzbAbBbCbDb} 3 4 Eb~ abbbcbFbdbebfbS 9 ! # $ 0 gbhbs % c n T ibjb' ( M ) kblbN * + U , m o - . / p : ; q r mbV W nb= X Y 5 @ obZ C G H D E pbqbrbGbHbIbJbKbLbMbNb? I Obi
234 self.mix_stderr = mix_stderr 2a F 6 O z 7 8 sb] ^ _ ` { | tbubvbP wbA Q R B K L xbybzbAbBbCbDb} 3 4 Eb~ abbbcbFbdbebfbS 9 ! # $ 0 gbhbs % c n T ibjb' ( M ) kblbN * + U , m o - . / p : ; q r mbV W nb= X Y 5 @ obZ C G H D E pbqbrbGbHbIbJbKbLbMbNb? I Obi
236 class MixStderrAttribute(TypedDict): 2a F 6 O z 7 8 sb] ^ _ ` { | tbubvbP wbA Q R B K L xbybzbAbBbCbDb} 3 4 Eb~ abbbcbFbdbebfbS 9 ! # $ 0 gbhbs % c n T ibjb' ( M ) kblbN * + U , m o - . / p : ; q r mbV W nb= X Y 5 @ obZ C G H D E pbqbrbGbHbIbJbKbLbMbNb? I Obi
237 mix_stderr: NotRequired[bool] 2a F 6 O z 7 8 sb] ^ _ ` { | tbubvbP wbA Q R B K L xbybzbAbBbCbDb} 3 4 Eb~ abbbcbFbdbebfbS 9 ! # $ 0 gbhbs % c n T ibjb' ( M ) kblbN * + U , m o - . / p : ; q r mbV W nb= X Y 5 @ obZ C G H D E pbqbrbGbHbIbJbKbLbMbNb? I Obi
239 mix_stderr_args: MixStderrAttribute = ( 2a F 6 O z 7 8 sb] ^ _ ` { | tbubvbP wbA Q R B K L xbybzbAbBbCbDb} 3 4 Eb~ abbbcbFbdbebfbS 9 ! # $ 0 gbhbs % c n T ibjb' ( M ) kblbN * + U , m o - . / p : ; q r mbV W nb= X Y 5 @ obZ C G H D E pbqbrbGbHbIbJbKbLbMbNb? I Obi
240 {"mix_stderr": mix_stderr}
241 if self._SUPPORTS_MIX_STDERR_ATTRIBUTE
242 else {}
243 )
244 # The "mix_stderr" argument, mandatory for our use case, was
245 # removed in click 8.2.0 without a transition period. Since we
246 # cannot branch on the click version available to the type
247 # checker, we disable static type checking for this call in
248 # particular; our test suite will have to uncover any breakage
249 # dynamically instead.
250 self.click_testing_clirunner = click.testing.CliRunner( # type: ignore[misc] 2a F 6 O z 7 8 sb] ^ _ ` { | tbubvbP wbA Q R B K L xbybzbAbBbCbDb} 3 4 Eb~ abbbcbFbdbebfbS 9 ! # $ 0 gbhbs % c n T ibjb' ( M ) kblbN * + U , m o - . / p : ; q r mbV W nb= X Y 5 @ obZ C G H D E pbqbrbGbHbIbJbKbLbMbNb? I Obi
251 **mix_stderr_args
252 )
254 def invoke(
255 self,
256 # The click.BaseCommand abstract base class, previously the base
257 # class of click.Command and click.Group, was removed in click
258 # 8.2.0 without a transition period, and click.Command instated
259 # as the common base class instead. To keep some degree of
260 # compatibility with both old click and new click, we explicitly
261 # list the (somewhat) concrete base classes we actually care
262 # about here.
263 cli: click.Command | click.Group,
264 args: Sequence[str] | str | None = None,
265 input: str | bytes | IO[Any] | None = None,
266 env: Mapping[str, str | None] | None = None,
267 catch_exceptions: bool = True,
268 color: bool | None = None,
269 **extra: Any,
270 ) -> ReadableResult:
271 if color is None: # pragma: no cover 2a F 6 O z 7 8 sb] ^ _ ` { | P A Q R B K L } 3 4 ~ abbbcbdbebfbS 9 ! # $ 0 gbhbs % c n T ibjb' ( M ) kblbN * + U , m o - . / p : ; q r mbV W nb= X Y 5 @ obZ C G H D E pbqbrb? I i
272 color = self.color if self.color is not None else False 2a F 6 O z 7 8 ] ^ _ ` { | P A Q R B K L } 3 4 ~ abbbcbdbebfbS 9 ! # $ 0 gbhbs % c n T ibjb' ( M ) kblbN * + U , m o - . / p : ; q r mbV W nb= X Y 5 @ obZ C G H D E pbqbrb? I i
273 raw_result = self.click_testing_clirunner.invoke( 2a F 6 O z 7 8 sb] ^ _ ` { | P A Q R B K L } 3 4 ~ abbbcbdbebfbS 9 ! # $ 0 gbhbs % c n T ibjb' ( M ) kblbN * + U , m o - . / p : ; q r mbV W nb= X Y 5 @ obZ C G H D E pbqbrb? I i
274 cli,
275 args=args,
276 input=input,
277 env=env,
278 catch_exceptions=catch_exceptions,
279 color=color,
280 **extra,
281 )
282 # In 8.2.0, r.stdout is no longer a property aliasing the
283 # `output` attribute, but rather the raw stdout value.
284 try: 2a F 6 O z 7 8 sb] ^ _ ` { | P A Q R B K L } 3 4 ~ abbbcbdbebfbS 9 ! # $ 0 gbhbs % c n T ibjb' ( M ) kblbN * + U , m o - . / p : ; q r mbV W nb= X Y 5 @ obZ C G H D E pbqbrb? I i
285 stderr = raw_result.stderr 2a F 6 O z 7 8 sb] ^ _ ` { | P A Q R B K L } 3 4 ~ abbbcbdbebfbS 9 ! # $ 0 gbhbs % c n T ibjb' ( M ) kblbN * + U , m o - . / p : ; q r mbV W nb= X Y 5 @ obZ C G H D E pbqbrb? I i
286 except ValueError: 134@i
287 stderr = raw_result.stdout 134@i
288 return ReadableResult( 2a F 6 O z 7 8 sb] ^ _ ` { | P A Q R B K L } 3 4 ~ abbbcbdbebfbS 9 ! # $ 0 gbhbs % c n T ibjb' ( M ) kblbN * + U , m o - . / p : ; q r mbV W nb= X Y 5 @ obZ C G H D E pbqbrb? I i
289 raw_result.exception,
290 raw_result.exit_code,
291 (raw_result.stdout if not self.mix_stderr else raw_result.output)
292 or "",
293 stderr or "",
294 )
296 def isolated_filesystem(
297 self,
298 temp_dir: str | os.PathLike[str] | None = None,
299 ) -> AbstractContextManager[str]:
300 return self.click_testing_clirunner.isolated_filesystem( 2F 6 O z 7 8 sb] ^ _ ` { | tbubvbP wbA Q R B K L xbybzbAbBbCbDb} Eb~ abbbcbFbdbebfbS 9 ! # $ 0 gbhbs % c n T ibjb' ( M ) kblbN * + U , m o - . / p : ; q r mbV W nb= X Y 5 @ obZ C G H D E pbqbrbGbHbIbJbKbLbMbNb? I Ob
301 temp_dir=temp_dir
302 )
305# Stubbed SSH agent request/response queue
306# ========================================
309class AgentProtocolResponseQueue:
310 """A response queue for the SSH agent protocol.
312 This queue wraps a map of request messages
313 ([`AgentProtocolRequest`][data.AgentProtocolRequest]) to multiple
314 response messages
315 ([`AgentProtocolResponse`][data.AgentProtocolResponse]). Whenever
316 [`_send_request_message`][] is called with a payload that matches
317 a [`AgentProtocolRequest`][data.AgentProtocolRequest] from the map,
318 the corresponding
319 [`AgentProtocolResponse`][data.AgentProtocolResponse] objects are
320 added to the queue, to be read by subsequent
321 [`_get_response_pair`][] calls. The [`_send_request_message`][] and
322 [`_get_response_pair`][] methods are function- and
323 interface-compatible with the internal methods of the same name in
324 [`ssh_agent.SSHAgentClient`][].
326 The queue is a context manager. When entered or exited, it
327 verifies that the queue is empty.
329 Use this agent stub implementation if you are modelling well-formed
330 requests and responses, without network errors or protocol framing
331 errors. For modelling the latter, see [`StubbedSSHAgentSocket`][].
333 """
335 def __init__(
336 self,
337 request_responses_mapping: Mapping[
338 data.AgentProtocolRequest,
339 Sequence[data.AgentProtocolResponse],
340 ],
341 ) -> None:
342 self.responses_map = request_responses_mapping 1tuvw
343 self.queue: collections.deque[data.AgentProtocolResponse] = ( 1tuvw
344 collections.deque()
345 )
347 def __bool__(self) -> bool:
348 return bool(self.queue) 1tuvw
350 def __enter__(self) -> Self:
351 assert not self, ( 1tuvw
352 f"Agent I/O queue already has contents: {self.queue!r}"
353 )
354 return self 1tuvw
356 def __exit__(
357 self,
358 exc_type: type[BaseException] | None,
359 exc_value: BaseException | None,
360 exc_tb: types.TracebackType | None,
361 ) -> Literal[False]:
362 if (exc_type, exc_value, exc_tb) != ( 1tuvw
363 None,
364 None,
365 None,
366 ): # pragma: no cover [external]
367 return False
368 assert not self, f"Agent I/O queue still has contents: {self.queue!r}" 1tuvw
369 return False 1tuvw
371 def _send_request_message(
372 self,
373 request_code: int | _types.SSH_AGENTC,
374 payload: bytes | bytearray,
375 ) -> None:
376 req = data.AgentProtocolRequest(request_code, bytes(payload)) 1tuvw
377 assert req in self.responses_map, ( 1tuvw
378 f"No prepared response for the request {req!r}"
379 )
380 self.queue.extend(self.responses_map[req]) 1tuvw
382 def _get_response_pair(
383 self,
384 ) -> tuple[int | _types.SSH_AGENT, bytes | bytearray]:
385 assert self.queue, "No more responses left to replay" 1tuvw
386 response = self.queue.popleft() 1tuvw
387 return response.code, response.payload 1tuvw
390# Stubbed SSH agent socket
391# ========================
393# Base variant
394# ------------
397@socketprovider.SocketProvider.register(
398 _types.BuiltinSSHAgentSocketProvider.STUB_AGENT
399)
400class StubbedSSHAgentSocket:
401 """A stubbed SSH agent presenting an [`_types.SSHAgentSocket`][].
403 On the network protocol side, the agent implements the full
404 [`_types.SSHAgentSocket`][] interface, including pipelined and
405 unaligned agent requests. However, on the application side, the
406 agent is intrinsically tied to [the set of SSH test
407 keys][data.ALL_KEYS], and only gives meaningful answers for
408 operations on the test keys and for agent operations in use by
409 [`ssh_agent.SSHAgentClient`][]. The agent does not actually
410 implement any cryptography; all cryptography-related answers are
411 derived from the recorded test key data.
413 It is not safe to further monkeypatch the agent's [`recv`][] or
414 [`sendall`][] methods on their own: either monkeypatch both of them,
415 or manipulate the [`send_to_client`][] bytes queue directly instead.
416 Given an [`ssh_agent.SSHAgentClient`][] connected to
417 a [`StubbedSSHAgentSocket`][], if the test ensures proper message
418 serialization and protocol framing and if the monkeypatching can be
419 expressed in terms of full request messages and full response
420 messages, prefer using a [`AgentProtocolResponseQueue`][] to
421 monkeypatch the client's high-level request/response-loop instead of
422 monkeypatching the low-level socket communication in this agent
423 socket.
425 """
427 _NO_FLAG_SUPPORT = "This stubbed SSH agent socket does not support flags."
428 _PROTOCOL_VIOLATION = "SSH agent protocol violation."
429 _INVALID_REQUEST = "Invalid request."
430 _UNSUPPORTED_REQUEST = "Unsupported request."
431 _INCOMPLETE_REQUEST = "The last request was incomplete."
433 HEADER_SIZE = 4
434 CODE_SIZE = 1
436 KNOWN_EXTENSIONS = frozenset({
437 "query",
438 "list-extended@putty.projects.tartarus.org",
439 })
440 """Known and implemented protocol extensions."""
442 def __init__(self, *extensions: str) -> None:
443 """Initialize the agent."""
444 self.send_to_client = bytearray() 1a[J1dbjxhkygfscnmopqrtuvlw2
445 """ 1a[J1dbjxhkygfscnmopqrtuvlw2
446 The buffered response to the client, read piecemeal by [`recv`][].
447 """
448 self.receive_from_client = bytearray() 1a[J1dbjxhkygfscnmopqrtuvlw2
449 """The last request issued by the client.""" 1a[J1dbjxhkygfscnmopqrtuvlw2
450 self.closed = False 1a[J1dbjxhkygfscnmopqrtuvlw2
451 """True if the connection is closed, false otherwise.""" 1a[J1dbjxhkygfscnmopqrtuvlw2
452 self.enabled_extensions = frozenset(extensions) & self.KNOWN_EXTENSIONS 1a[J1dbjxhkygfscnmopqrtuvlw2
453 """ 1a[J1dbjxhkygfscnmopqrtuvlw2
454 Extensions actually enabled in this particular stubbed SSH agent.
455 """
456 self.try_rfc6979 = False 1a[J1dbjxhkygfscnmopqrtuvlw2
457 """ 1a[J1dbjxhkygfscnmopqrtuvlw2
458 Attempt to issue DSA and ECDSA signatures according to RFC 6979?
459 """
460 self.try_pageant_068_080 = False 1a[J1dbjxhkygfscnmopqrtuvlw2
461 """ 1a[J1dbjxhkygfscnmopqrtuvlw2
462 Attempt to issue DSA and ECDSA signatures as per Pageant 0.68–0.80?
463 """ # noqa: RUF001
465 def __enter__(self) -> Self:
466 """Return self."""
467 return self 1a[J1dbjhkygfscnpqrtuvlw
469 def __exit__(self, *args: object) -> None:
470 """Mark the agent's socket as closed.
472 Raises:
473 AssertionError:
474 The last request to the agent was incomplete.
476 """
477 self.closed = True 1a[J1dbjhkygfscnpqrtuvlw
478 assert not self.receive_from_client, self._INCOMPLETE_REQUEST 1a[J1dbjhkygfscnpqrtuvlw
480 def sendall(self, data: Buffer, flags: int = 0, /) -> None:
481 """Send data to the SSH agent.
483 The signature, and behavior, is identical to
484 [`socket.socket.sendall`][]. Upon successful sending, this
485 agent will parse the request, call the appropriate handler, and
486 buffer the result such that it can be read via [`recv`][], in
487 accordance with the SSH agent protocol.
489 Args:
490 data: Binary data to send to the agent.
491 flags: Reserved. Must be 0.
493 Returns:
494 Nothing. The result should be requested via [`recv`][], and
495 interpreted in accordance with the SSH agent protocol.
497 Raises:
498 AssertionError:
499 The flags argument, if specified, must be 0.
500 OSError:
501 The socket connection is already closed.
503 """
504 assert not flags, self._NO_FLAG_SUPPORT 1aJdbjhkygfceil
505 self._check_for_io_on_closed_connection() 1aJdbjhkygfceil
506 self.receive_from_client.extend(memoryview(data)) 1adbjhkygfceil
507 while self.receive_from_client: 1adbjhkygfceil
508 result: Buffer | Iterable[int]
509 if len(self.receive_from_client) < self.HEADER_SIZE: 1adbjhkygfceil
510 break 1k
511 count = int.from_bytes( 1adbjhkygfceil
512 self.receive_from_client[: self.HEADER_SIZE],
513 "big",
514 signed=False,
515 )
516 if count: 1adbjhkygfceil
517 code = int.from_bytes( 1adbjhkygfceil
518 self.receive_from_client[
519 self.HEADER_SIZE : self.HEADER_SIZE + self.CODE_SIZE
520 ],
521 "big",
522 signed=False,
523 )
524 request = bytes( 1adbjhkygfceil
525 self.receive_from_client[: self.HEADER_SIZE + count]
526 )
527 if len(request) < self.HEADER_SIZE + count: 1adbjhkygfceil
528 break 1ky
529 request_payload = request[self.HEADER_SIZE + self.CODE_SIZE :] 1adbjhkgfceil
531 if code == _types.SSH_AGENTC.REQUEST_IDENTITIES: 1adbjhkgfceil
532 result = self.request_identities(list_extended=False) 1abfel
533 elif code == _types.SSH_AGENTC.SIGN_REQUEST: 1adbjhkgcei
534 result = self.sign(request_payload) 1jhge
535 elif self._check_for_extension(code, "query"): 1adbhkcei
536 result = self.query_extensions() 1dkei
537 elif self._check_for_extension( 1adbhci
538 code, "list-extended@putty.projects.tartarus.org"
539 ):
540 result = self.request_identities(list_extended=True) 1b
541 else:
542 result = self._failure() 1adhci
543 else:
544 request = bytes(self.receive_from_client[: self.HEADER_SIZE]) 1h
545 result = self._failure() 1h
546 self.send_to_client.extend( 1adbjhkgfceil
547 ssh_agent.SSHAgentClient.string(bytes(result))
548 )
549 self.receive_from_client[: len(request)] = b"" 1adbjhkgfceil
551 def recv(self, count: int, flags: int = 0, /) -> bytes:
552 """Read data from the SSH agent.
554 As per the SSH agent protocol, data is only available to be read
555 immediately after a request via [`sendall`][] and if the socket
556 connection is still open. Calls to [`recv`][] at other points
557 in time that attempt to read data violate the protocol, and will
558 fail. (A [`recv`][] of zero bytes does not read data.) Calls
559 to [`recv`][] when the socket connection is closed always fail.
561 Args:
562 count:
563 Number of bytes to read from the agent.
564 flags:
565 Reserved. Must be 0.
567 Returns:
568 (A chunk of) the SSH agent's response to the most recent
569 request. If reading 0 bytes, the returned chunk is always
570 an empty byte string.
572 Raises:
573 AssertionError:
574 The flags argument, if specified, must be 0.
576 Alternatively, `recv` was called when there was no
577 response to be obtained, in violation of the SSH agent
578 protocol.
579 OSError:
580 The socket connection is already closed.
582 """
583 assert not flags, self._NO_FLAG_SUPPORT 1aJ1dbjhkgfceil2
584 self._check_for_io_on_closed_connection() 1aJ1dbjhkgfceil2
585 assert not count or self.send_to_client, self._PROTOCOL_VIOLATION 1a1dbjhkgfceil2
586 ret = bytes(self.send_to_client[:count]) 1adbjhkgfceil2
587 del self.send_to_client[:count] 1adbjhkgfceil2
588 return ret 1adbjhkgfceil2
590 def _failure(self) -> bytes:
591 return bytes(_types.SSH_AGENT.FAILURE) 1adhgci
593 def _check_for_io_on_closed_connection(self) -> None:
594 if self.closed: 1aJ1dbjhkygfceil2
595 raise OSError(errno.EBADF, os.strerror(errno.EBADF)) 1J
597 def _check_for_extension(self, code: int, extension: str) -> bool:
598 if ( 1adbhkcei
599 extension not in self.enabled_extensions
600 or code != _types.SSH_AGENTC.EXTENSION
601 ):
602 return False 1adhci
603 string = ssh_agent.SSHAgentClient.string 1dbkei
604 extension_marker = b"\x1b" + string(extension.encode("ascii")) 1dbkei
605 return self.receive_from_client.startswith(extension_marker, 4) 1dbkei
607 def query_extensions(self) -> Generator[int, None, None]:
608 """Answer an `SSH_AGENTC_EXTENSION` request.
610 Yields:
611 The bytes payload of the response, without the protocol
612 framing. The payload is yielded byte by byte, as an
613 iterable of 8-bit integers.
615 """
616 yield _types.SSH_AGENT.EXTENSION_RESPONSE 1dkei
617 yield from ssh_agent.SSHAgentClient.string(b"query") 1dkei
618 extension_answers = [ 1dkei
619 b"query",
620 b"list-extended@putty.projects.tartarus.org",
621 ]
622 for a in extension_answers: 1dkei
623 yield from ssh_agent.SSHAgentClient.string(a) 1dkei
625 def request_identities(
626 self, *, list_extended: bool = False
627 ) -> Generator[int, None, None]:
628 """Answer an `SSH_AGENTC_REQUEST_IDENTITIES` request.
630 Args:
631 list_extended:
632 If true, answer an `SSH_AGENTC_EXTENSION` request for
633 the `list-extended@putty.projects.tartarus.org`
634 extension. Otherwise, answer an
635 `SSH_AGENTC_REQUEST_IDENTITIES` request.
637 Yields:
638 The bytes payload of the response, without the protocol
639 framing. The payload is yielded byte by byte, as an
640 iterable of 8-bit integers.
642 """
643 if list_extended: 1abfel
644 yield _types.SSH_AGENT.SUCCESS 1b
645 else:
646 yield _types.SSH_AGENT.IDENTITIES_ANSWER 1abfel
647 signature_classes = [ 1abfel
648 data.SSHTestKeyDeterministicSignatureClass.SPEC,
649 ]
650 if ( 1abfel
651 "list-extended@putty.projects.tartarus.org"
652 in self.enabled_extensions
653 ):
654 signature_classes.append( 1abe
655 data.SSHTestKeyDeterministicSignatureClass.RFC_6979
656 )
657 keys = [ 1abfel
658 v
659 for v in data.ALL_KEYS.values()
660 if any(cls in v.expected_signatures for cls in signature_classes)
661 ]
662 yield from ssh_agent.SSHAgentClient.uint32(len(keys)) 1abfel
663 for key in keys: 1abfel
664 yield from ssh_agent.SSHAgentClient.string(key.public_key_data) 1abfel
665 yield from ssh_agent.SSHAgentClient.string( 1abfel
666 b"test key without passphrase"
667 )
668 if list_extended: 1abfel
669 yield from ssh_agent.SSHAgentClient.string( 1b
670 ssh_agent.SSHAgentClient.uint32(0)
671 )
673 def sign(self, request_payload: bytes, /) -> bytes:
674 """Answer an `SSH_AGENTC_SIGN_REQUEST` request.
676 Args:
677 request_payload:
678 The data of the sign request, without the protocol
679 framing or the request code.
681 Returns:
682 The bytes payload of the response, without the protocol
683 framing.
685 """
686 try_rfc6979 = ( 1jhge
687 "list-extended@putty.projects.tartarus.org"
688 in self.enabled_extensions
689 )
690 spec = data.SSHTestKeyDeterministicSignatureClass.SPEC 1jhge
691 rfc6979 = data.SSHTestKeyDeterministicSignatureClass.RFC_6979 1jhge
692 try: 1jhge
693 key_blob, rest = ssh_agent.SSHAgentClient.unstring_prefix( 1jhge
694 request_payload
695 )
696 sign_data, rest = ssh_agent.SSHAgentClient.unstring_prefix(rest) 1jhge
697 except ValueError: 1h
698 return self._failure() 1h
699 if len(rest) != 4: 1jhge
700 return self._failure() 1h
701 flags = int.from_bytes(rest, "big") 1jge
702 if flags: 1jge
703 return self._failure() 1g
704 if sign_data != vault.Vault.UUID: 1jge
705 return self._failure() 1g
706 for key in data.ALL_KEYS.values(): 1jge
707 if key.public_key_data == key_blob: 1jge
708 if spec in key.expected_signatures: 1jge
709 return int.to_bytes( 1je
710 _types.SSH_AGENT.SIGN_RESPONSE, 1, "big"
711 ) + ssh_agent.SSHAgentClient.string(
712 key.expected_signatures[spec].signature
713 )
714 if try_rfc6979 and rfc6979 in key.expected_signatures: 1ge
715 return int.to_bytes( 1e
716 _types.SSH_AGENT.SIGN_RESPONSE, 1, "big"
717 ) + ssh_agent.SSHAgentClient.string(
718 key.expected_signatures[rfc6979].signature
719 )
720 return self._failure() 1g
721 return self._failure() 1g
724# Standard variant
725# ----------------
728@socketprovider.SocketProvider.register(
729 _types.BuiltinSSHAgentSocketProvider.STUB_AGENT_WITH_ADDRESS
730)
731class StubbedSSHAgentSocketWithAddress(StubbedSSHAgentSocket):
732 """A [`StubbedSSHAgentSocket`][] requiring a specific address."""
734 ADDRESS = "stub-ssh-agent:"
735 """The correct address for connecting to this stubbed agent."""
737 def __init__(self, *extensions: str) -> None:
738 """Initialize the agent, based on `SSH_AUTH_SOCK`.
740 Socket addresses of the form `stub-ssh-agent:<errno_value>` will
741 raise an [`OSError`][] (or the respective subclass) with the
742 specified [`errno`][] value. For example,
743 `stub-ssh-agent:EPERM` will raise a [`PermissionError`][].
745 Raises:
746 KeyError:
747 The `SSH_AUTH_SOCK` environment variable is not set.
748 OSError:
749 The address in `SSH_AUTH_SOCK` is unsuited.
751 """
752 super().__init__(*extensions) 1adbxfcnmo
753 try: 1adbxfcnmo
754 orig_address = os.environ["SSH_AUTH_SOCK"] 1adbxfcnmo
755 except KeyError as exc: 1xfo
756 msg = "SSH_AUTH_SOCK environment variable" 1xfo
757 raise KeyError(msg) from exc 1xfo
758 address = orig_address 1adbxfcnm
759 if not address.startswith(self.ADDRESS): 1adbxfcnm
760 address = self.ADDRESS + "ENOENT" 1xm
761 errcode = address.removeprefix(self.ADDRESS) 1adbxfcnm
762 if errcode and not ( 1adbxfcnm
763 errcode.startswith("E") and hasattr(errno, errcode)
764 ):
765 errcode = "EINVAL" 1xf
766 if errcode: 1adbxfcnm
767 errno_val = getattr(errno, errcode) 1xfm
768 raise OSError(errno_val, os.strerror(errno_val), orig_address) 1xfm
771# Deterministic variant
772# ---------------------
775@socketprovider.SocketProvider.register(
776 _types.BuiltinSSHAgentSocketProvider.STUB_AGENT_WITH_ADDRESS_AND_DETERMINISTIC_DSA
777)
778class StubbedSSHAgentSocketWithAddressAndDeterministicDSA(
779 StubbedSSHAgentSocketWithAddress
780):
781 """A [`StubbedSSHAgentSocketWithAddress`][] supporting deterministic DSA."""
783 def __init__(self) -> None:
784 """Initialize the agent.
786 Set the supported extensions, and try issuing RFC 6979 and
787 Pageant 0.68–0.80 DSA/ECDSA signatures, if possible. See the
788 [superclass constructor][StubbedSSHAgentSocketWithAddress] for
789 other details.
791 Raises:
792 KeyError: See superclass.
793 OSError: See superclass.
795 """ # noqa: RUF002
796 super().__init__("query", "list-extended@putty.projects.tartarus.org") 1adb
797 self.try_rfc6979 = True 1adb
798 self.try_pageant_068_080 = True 1adb