Coverage for tests / conftest.py: 100.000%

92 statements  

« 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 

4 

5from __future__ import annotations 

6 

7import base64 

8import contextlib 

9import ctypes 

10import os 

11import shutil 

12import socket 

13import subprocess 

14from typing import TYPE_CHECKING, Any, Protocol, TypeVar 

15 

16import packaging.version 

17import pytest 

18from typing_extensions import NamedTuple 

19 

20from derivepassphrase import _types, ssh_agent 

21from derivepassphrase.ssh_agent import socketprovider 

22from tests import data, machinery 

23from tests.data import callables 

24from tests.machinery import hypothesis as hypothesis_machinery 

25from tests.machinery import pytest as pytest_machinery 

26 

27if TYPE_CHECKING: 

28 from collections.abc import Generator, Sequence 

29 

30startup_ssh_auth_sock = os.environ.get("SSH_AUTH_SOCK", None) 

31 

32 

33def pytest_configure(config: pytest.Config) -> None: 

34 """Configure `pytest`: add custom markers.""" 

35 config.addinivalue_line( 

36 "markers", ("heavy_duty: mark test as a slow, heavy-duty test") 

37 ) 

38 config.addinivalue_line( 

39 "markers", 

40 ( 

41 "correctness_focused: " 

42 "mark test as a potentially slow, correctness-focused test" 

43 ), 

44 ) 

45 config.addinivalue_line( 

46 "markers", 

47 ( 

48 "external_agent: " 

49 "mark the agent (fixture output) as external to the test suite" 

50 ), 

51 ) 

52 config.addinivalue_line( 

53 "markers", 

54 ( 

55 "non_reentrant_agent: " 

56 "mark the agent (fixture output) as non-reentrant" 

57 ), 

58 ) 

59 hypothesis_machinery._hypothesis_settings_setup() 

60 

61 

62# https://docs.pytest.org/en/stable/explanation/fixtures.html#a-note-about-fixture-cleanup 

63# https://github.com/pytest-dev/pytest/issues/5243#issuecomment-491522595 

64@pytest.fixture(scope="session", autouse=False) 

65def term_handler() -> Generator[ 

66 None, None, None 

67]: # pragma: no cover [external] 

68 try: 

69 import signal # noqa: PLC0415 

70 

71 sigint_handler = signal.getsignal(signal.SIGINT) 

72 except (ImportError, OSError): 

73 return 

74 else: 

75 orig_term = signal.signal(signal.SIGTERM, sigint_handler) 

76 yield 

77 signal.signal(signal.SIGTERM, orig_term) 

78 

79 

80@pytest.fixture(scope="session") 

81def skip_if_no_af_unix_support() -> None: # pragma: no cover [external] 

82 """Skip the test if Python does not support `AF_UNIX`. 

83 

84 Implemented as a fixture instead of a mark because another "autouse" 

85 session fixture may want to force/simulate non-support of 

86 [`socket.AF_UNIX`][]. 

87 

88 """ 

89 if not hasattr(socket, "AF_UNIX"): 

90 pytest.skip("No Python or system support for UNIX domain sockets.") 

91 

92 

93@pytest.fixture(scope="session") 

94def skip_if_no_ctypes_windll_support() -> None: # pragma: no cover [external] 

95 """Skip the test if Python does not support WinDLL in `ctypes`. 

96 

97 Implemented as a fixture instead of a mark because another "autouse" 

98 session fixture may want to force/simulate non-support of 

99 [`ctypes.WinDLL`][]. 

100 

101 """ 

102 if not hasattr(ctypes, "WinDLL"): 

103 pytest.skip( 

104 "No Python or system support for interfacing Windows DLLs." 

105 ) 

106 

107 

108@pytest.fixture 

109def use_stub_agent() -> Generator[None, None, None]: 

110 """Enforce use of the stubbed SSH agent.""" 

111 with pytest.MonkeyPatch.context() as monkeypatch: 

112 monkeypatch.setattr( 

113 ssh_agent.SSHAgentClient, 

114 "SOCKET_PROVIDERS", 

115 (_types.BuiltinSSHAgentSocketProvider.STUB_AGENT,), 

116 ) 

117 monkeypatch.delenv("SSH_AUTH_SOCK", raising=False) 

118 yield 

119 

120 

121@pytest.fixture 

122def use_stub_agent_with_address() -> Generator[None, None, None]: 

123 """Enforce use of the stub SSH agent with socket address.""" 

124 with pytest.MonkeyPatch.context() as monkeypatch: 

125 monkeypatch.setattr( 

126 ssh_agent.SSHAgentClient, 

127 "SOCKET_PROVIDERS", 

128 (_types.BuiltinSSHAgentSocketProvider.STUB_AGENT_WITH_ADDRESS,), 

129 ) 

130 monkeypatch.setenv( 

131 "SSH_AUTH_SOCK", machinery.StubbedSSHAgentSocketWithAddress.ADDRESS 

132 ) 

133 yield 

134 

135 

136class SSHAgentSpawnFunc(Protocol): 

137 """Spawns an SSH agent, if possible.""" 

138 

139 def __call__( 

140 self, 

141 executable: str | None, 

142 env: dict[str, str], 

143 ) -> subprocess.Popen[str] | None: 

144 """Spawn the SSH agent. 

145 

146 Args: 

147 executable: 

148 The respective SSH agent executable. 

149 env: 

150 The new environment for the respective agent. Should 

151 typically not include an SSH_AUTH_SOCK variable. 

152 

153 Returns: 

154 The spawned SSH agent subprocess. If the executable is 

155 `None`, then return `None` directly. 

156 

157 It is the caller's responsibility to clean up the spawned 

158 subprocess. 

159 

160 Raises: 

161 OSError: 

162 The [`subprocess.Popen`][] call failed. See there. 

163 

164 """ 

165 

166 

167class SSHAgentInterfaceFunc(Protocol): 

168 """Interface an SSH agent, if possible.""" 

169 

170 def __call__( 

171 self, 

172 executable: str | None, 

173 env: dict[str, Any], 

174 ) -> tuple[_types.SSHAgentSocket, str] | None: 

175 """Interface the SSH agent. 

176 

177 Args: 

178 env: 

179 A configuration environment to interface the agent. 

180 Will typically include some kind of address setting 

181 detailing how to communicate with the agent. The 

182 exact contents depend on the specific agent. 

183 

184 Other Args: 

185 executable: 

186 (Ignored. Not relevant for interfacing functions.) 

187 

188 Returns: 

189 A 2-tuple, if possible, containing an abstract socket 

190 connected to the specified SSH agent and the associated 

191 address; otherwise, `None`. 

192 

193 It is the caller's responsibility to clean up the socket 

194 (through use of the context manager protocol). 

195 

196 """ 

197 

198 

199def spawn_pageant_on_posix( # pragma: no cover [external] 

200 executable: str | None, env: dict[str, str] 

201) -> subprocess.Popen[str] | None: 

202 """Spawn an isolated (UNIX) Pageant, if possible. 

203 

204 We attempt to detect whether Pageant is usable, i.e. whether Pageant 

205 has output buffering problems when announcing its authentication 

206 socket. This is the case for Pageant 0.81 and earlier. 

207 

208 Args: 

209 executable: 

210 The path to the Pageant executable. 

211 env: 

212 The new environment for Pageant. Should typically not 

213 include an SSH_AUTH_SOCK variable. 

214 

215 Returns: 

216 The spawned Pageant subprocess. If the executable is `None`, or 

217 if we detect that Pageant cannot be sensibly controlled as 

218 a subprocess, then return `None` directly. 

219 

220 It is the caller's responsibility to clean up the spawned 

221 subprocess. 

222 

223 """ 

224 if os.name == "nt": # pragma: no cover [external] 

225 return None 

226 if executable is None: # pragma: no cover [external] 

227 executable = shutil.which("pageant") 

228 if executable is None: 

229 return None 

230 

231 # Apparently, Pageant 0.81 and lower running in debug mode does 

232 # not actively flush its output. As a result, the first two 

233 # lines, which set the SSH_AUTH_SOCK and the SSH_AGENT_PID, only 

234 # print once the output buffer is flushed, whenever that is. 

235 # 

236 # This has been reported to the PuTTY developers. It is fixed in 

237 # version 0.82, though the PuTTY developers consider this to be an 

238 # abuse of debug mode. A new foreground mode (`--foreground`), also 

239 # introduced in 0.82, provides the desired behavior: no forking, and 

240 # immediately parsable instructions for SSH_AUTH_SOCK and 

241 # SSH_AGENT_PID. 

242 

243 help_output = subprocess.run( 

244 ["pageant", "--help"], 

245 executable=executable, 

246 env=env, 

247 capture_output=True, 

248 text=True, 

249 check=False, 

250 ).stdout 

251 help_lines = help_output.splitlines(True) 

252 pageant_version_string = ( 

253 help_lines[1].strip().removeprefix("Release ") 

254 if len(help_lines) >= 2 

255 else "" 

256 ) 

257 v0_82 = packaging.version.Version("0.82") 

258 pageant_version = packaging.version.Version(pageant_version_string) 

259 

260 if pageant_version < v0_82: # pragma: no cover [external] 

261 return None 

262 

263 return subprocess.Popen( 

264 ["pageant", "--foreground", "-s"], 

265 executable=executable, 

266 stdin=subprocess.DEVNULL, 

267 stdout=subprocess.PIPE, 

268 shell=False, 

269 env=env, 

270 text=True, 

271 bufsize=1, 

272 ) 

273 

274 

275def spawn_openssh_agent_on_posix( # pragma: no cover [external] 

276 executable: str | None, env: dict[str, str] 

277) -> subprocess.Popen[str] | None: 

278 """Spawn an isolated OpenSSH agent (on UNIX), if possible. 

279 

280 Args: 

281 executable: 

282 The path to the OpenSSH agent executable. 

283 env: 

284 The new environment for the OpenSSH agent. Should typically 

285 not include an SSH_AUTH_SOCK variable. 

286 

287 Returns: 

288 The spawned OpenSSH agent subprocess. If the executable is 

289 `None`, then return `None` directly. 

290 

291 It is the caller's responsibility to clean up the spawned 

292 subprocess. 

293 

294 """ 

295 if os.name == "nt": # pragma: no cover [external] 

296 return None 

297 if executable is None: # pragma: no cover [external] 

298 executable = shutil.which("ssh-agent") 

299 if executable is None: 

300 return None 

301 return subprocess.Popen( 

302 ["ssh-agent", "-D", "-s"], 

303 executable=executable, 

304 stdin=subprocess.DEVNULL, 

305 stdout=subprocess.PIPE, 

306 shell=False, 

307 env=env, 

308 text=True, 

309 bufsize=1, 

310 ) 

311 

312 

313def spawn_noop( # pragma: no cover [unused] 

314 executable: str | None, env: dict[str, str] 

315) -> None: 

316 """Placeholder function. Does nothing.""" 

317 

318 

319def interface_pageant_on_the_annoying_os( 

320 executable: str | None, 

321 env: dict[str, Any], 

322) -> tuple[_types.SSHAgentSocket, str] | None: # pragma: no cover [external] 

323 """Interface a Pageant instance on The Annoying OS, if possible. 

324 

325 Args: 

326 env: 

327 (Ignored.) 

328 

329 Returns: 

330 An abstract socket connected to Pageant, if possible; else 

331 `None`. 

332 

333 It is the caller's responsibility to clean up the socket 

334 (through use of the context manager protocol). 

335 

336 """ 

337 del executable, env 

338 try: 

339 socket = socketprovider.SocketProvider.windows_named_pipe_for_pageant() 

340 except socketprovider.WindowsNamedPipesNotAvailableError: 

341 return None 

342 else: 

343 return (socket, socket.named_pipe_name()) 

344 

345 

346def interface_openssh_agent_on_the_annoying_os( 

347 executable: str | None, 

348 env: dict[str, Any], 

349) -> tuple[_types.SSHAgentSocket, str] | None: # pragma: no cover [external] 

350 """Interface an OpenSSH agent instance on The Annoying OS, if possible. 

351 

352 Args: 

353 env: 

354 (Ignored.) 

355 

356 Returns: 

357 An abstract socket connected to the OpenSSH agent, if possible; 

358 else `None`. 

359 

360 It is the caller's responsibility to clean up the socket 

361 (through use of the context manager protocol). 

362 

363 """ 

364 del executable, env 

365 try: 

366 socket = socketprovider.SocketProvider.windows_named_pipe_for_openssh() 

367 except socketprovider.WindowsNamedPipesNotAvailableError: 

368 return None 

369 else: 

370 return (socket, socket.named_pipe_name()) 

371 

372 

373class SpawnHandler(NamedTuple): 

374 """A handler for a spawned or interfaced SSH agent. 

375 

376 (The choice of terminology "spawn" instead of the more accurate 

377 "spawn or interface" is due to historical reasons.) 

378 

379 Attributes: 

380 instance: 

381 The SSH agent instance type under which this handler is 

382 registered. 

383 

384 This is a full enum, not just a string. In particular, the 

385 enum can be queried for the type of agent represented by 

386 this entry. 

387 executable: 

388 The (optional) full path to the executable. 

389 spawn_func: 

390 The spawn function. 

391 agent_name: 

392 The (optional) display name for this handler. 

393 

394 """ 

395 

396 instance: data.KnownSSHAgentInstance 

397 executable: str | None 

398 spawn_func: SSHAgentSpawnFunc | SSHAgentInterfaceFunc 

399 agent_name: str | None 

400 

401 

402spawn_handlers: dict[str, SpawnHandler] = { 

403 data.KnownSSHAgentInstance.unix_pageant: SpawnHandler( 

404 data.KnownSSHAgentInstance.unix_pageant, 

405 None, 

406 spawn_pageant_on_posix, 

407 "Pageant (UNIX)", 

408 ), 

409 data.KnownSSHAgentInstance.openssh: SpawnHandler( 

410 data.KnownSSHAgentInstance.openssh, 

411 None, 

412 spawn_openssh_agent_on_posix, 

413 "ssh-agent (OpenSSH)", 

414 ), 

415 data.KnownSSHAgentInstance.pageant: SpawnHandler( 

416 data.KnownSSHAgentInstance.pageant, 

417 None, 

418 interface_pageant_on_the_annoying_os, 

419 "Pageant", 

420 ), 

421 data.KnownSSHAgentInstance.openssh_on_windows: SpawnHandler( 

422 data.KnownSSHAgentInstance.openssh_on_windows, 

423 None, 

424 interface_openssh_agent_on_the_annoying_os, 

425 "ssh-agent (OpenSSH on Windows)", 

426 ), 

427 data.KnownSSHAgentInstance.stub_agent_with_address: SpawnHandler( 

428 data.KnownSSHAgentInstance.stub_agent_with_address, 

429 None, 

430 spawn_noop, 

431 "stub_agent_with_address (derivepassphrase test suite)", 

432 ), 

433 data.KnownSSHAgentInstance.stub_agent_with_address_and_deterministic_dsa: SpawnHandler( 

434 data.KnownSSHAgentInstance.stub_agent_with_address_and_deterministic_dsa, 

435 None, 

436 spawn_noop, 

437 "stub_agent_with_address_and_deterministic_dsa " 

438 "(derivepassphrase test suite)", 

439 ), 

440 data.KnownSSHAgentInstance.system_agent: SpawnHandler( 

441 data.KnownSSHAgentInstance.system_agent, 

442 None, 

443 spawn_noop, 

444 None, 

445 ), 

446} 

447""" 

448The standard registry of agent spawning/interfacing functions. 

449 

450(The choice of terminology "spawn" instead of the more accurate "spawn 

451or interface" is due to historical reasons.) 

452""" 

453 

454 

455def external_agent_restriction( 

456 agent_type: data.KnownSSHAgentType, 

457 env_var: str, 

458 *, 

459 default: bool = True, 

460) -> bool: # pragma: no cover [external] 

461 """Classify an SSH agent according to some environment variable. 

462 

463 Look up the given SSH agent type in a certain environment variable, 

464 and report on whether the agent type is listed in that variable. 

465 This can be used to check if the agent is e.g. in a list of 

466 permitted agents, or if it is in the list of agents requiring 

467 special workarounds. Invalid agent type names are silently ignored. 

468 

469 If the environment variable is empty, return a default value. 

470 

471 By design, the stubbed agents have a fixed classification (the 

472 default value), unaffected by the environment variable. 

473 

474 Args: 

475 agent_type: 

476 The agent type to classify. 

477 env_var: 

478 The environment variable in which to look up the agent type 

479 name. 

480 default: 

481 The value to return if the environment variable is unset, or 

482 if a stub agent is to be classified. 

483 

484 Returns: 

485 If the agent type is not a stub agent, and the environment 

486 variable is non-empty, then `True` if the agent type is 

487 mentioned in the environment variable, and `False` otherwise; 

488 the default value otherwise. 

489 

490 See also: 

491 [`is_agent_permitted`][] and [`is_agent_non_reentrant`][], which 

492 make use of this function internally. 

493 

494 """ 

495 if agent_type == data.KnownSSHAgentType.StubbedSSHAgent: 

496 return default 

497 if not os.environ.get(env_var): 

498 return default 

499 marked_agents = { 

500 data.KnownSSHAgentType(x) 

501 for x in os.environ[env_var].split(",") 

502 if x in data.KnownSSHAgentType.__members__ 

503 } 

504 return agent_type in marked_agents 

505 

506 

507def is_agent_permitted( 

508 agent_type: data.KnownSSHAgentType, 

509) -> bool: # pragma: no cover [external] 

510 """May the given SSH agent be spawned by the test harness? 

511 

512 If the environment variable `PERMITTED_SSH_AGENTS` is given, it 

513 names a comma-separated list of known SSH agent names that the test 

514 harness may spawn. Invalid names are silently ignored. If not 

515 given, or empty, then any agent may be spawned. 

516 

517 (To not allow any agent to be spawned, specify a single comma as the 

518 list. But see below.) 

519 

520 The stubbed agents cannot be restricted in this manner, as the test 

521 suite depends on their availability. 

522 

523 """ 

524 return external_agent_restriction( 

525 agent_type, "PERMITTED_SSH_AGENTS", default=True 

526 ) 

527 

528 

529def is_agent_non_reentrant( 

530 agent_type: data.KnownSSHAgentType, 

531) -> bool: # pragma: no cover [external] 

532 """Is the given SSH agent assumed to be non-reentrant? 

533 

534 If the environment variable `NON_REENTRANT_SSH_AGENTS` is given, it 

535 names a comma-separated list of known SSH agent names that are 

536 assumed to be non-reentrant, i.e., where the SSH agent cannot 

537 tolerate multiple clients connecting to it at the same time, and 

538 thus no client for the same agent may be constructed while another 

539 one is already connected. Invalid names are silently ignored. If 

540 not given, or empty, then all agents are considered reentrant, i.e., 

541 all agents are not considered non-reentrant. 

542 

543 (To consider all agents non-reentrant, specify a single comma as the 

544 list. But see below.) 

545 

546 The stubbed agents cannot be influenced in this manner, as the test 

547 suite depends on their reentrancy. 

548 

549 """ 

550 return external_agent_restriction( 

551 agent_type, "NON_REENTRANT_SSH_AGENTS", default=False 

552 ) 

553 

554 

555spawn_handlers_params: list[Sequence] = [] 

556""" 

557The standard registry of agent spawning functions, annotated as 

558a `pytest` parameter set. (In particular, this includes the conditional 

559skip marks.) Used by some test fixtures. 

560 

561(The choice of terminology "spawn" instead of the more accurate "spawn 

562or interface" is due to historical reasons.) 

563""" 

564for instance, handler in spawn_handlers.items(): 

565 marks = [ 

566 pytest.mark.skipif( 

567 not is_agent_permitted(handler.instance.agent_type), 

568 reason="SSH agent excluded via PERMITTED_AGENTS " 

569 "environment variable.", 

570 ), 

571 ] 

572 agent_type = handler.instance.agent_type 

573 # Distinguish spawned/interfaced agents ("external agents") from the 

574 # stub agents ("internal agents"). While this could be inferred from 

575 # the other marks, it is sensible to have a dedicated mark for this. 

576 # Tests that unnecessarily use external agents without testing anything 

577 # that is specific to external agents can be found more easily using 

578 # this mark, and converted to using internal agents only, with speed and 

579 # flakiness-resistance benefits. 

580 if agent_type != data.KnownSSHAgentType.StubbedSSHAgent: 

581 marks.append(pytest.mark.external_agent) 

582 # Allow agents to be marked as non-reentrant. Workaround for an 

583 # issue with GnuPG 2.4.8 on The Annoying OS when `gpg-agent` 

584 # masquerades as OpenSSH's `ssh-agent`. 

585 if is_agent_non_reentrant(agent_type): # pragma: no cover [external] 

586 marks.append(pytest.mark.non_reentrant_agent) 

587 # Mark non-isolated agents as, well, using non-isolated agents. 

588 # Assume by default that an agent is potentially non-isolated unless 

589 # we can be absolutely sure it is isolated, by design. (The "stub" 

590 # agents fall into the latter category.) 

591 if agent_type != data.KnownSSHAgentType.StubbedSSHAgent: 

592 marks.append(pytest_machinery.non_isolated_agent_use) 

593 # Some agents require UNIX domain socket support. 

594 if instance in { 

595 data.KnownSSHAgentInstance.unix_pageant, 

596 data.KnownSSHAgentInstance.openssh, 

597 }: 

598 marks.append( 

599 pytest.mark.skipif( 

600 not hasattr(socket, "AF_UNIX"), 

601 reason="No Python or system support for UNIX domain sockets.", 

602 ) 

603 ) 

604 # Others require Windows named pipe support. 

605 if instance in { 

606 data.KnownSSHAgentInstance.pageant, 

607 data.KnownSSHAgentInstance.openssh_on_windows, 

608 }: 

609 marks.append( 

610 pytest.mark.skipif( 

611 not hasattr(ctypes, "WinDLL"), 

612 reason="No Python or system support for Windows named pipes.", 

613 ) 

614 ) 

615 spawn_handlers_params.append( 

616 pytest.param(handler, id=instance, marks=marks) 

617 ) 

618 

619 

620Popen = TypeVar("Popen", bound=subprocess.Popen) 

621 

622 

623# As of v0.6, all SSH agents we interact with on The Annoying OS are 

624# interfaced, not spawned. Therefore, this context manager is unused on 

625# The Annoying OS. 

626@contextlib.contextmanager 

627def terminate_on_exit( 

628 proc: Popen, 

629) -> Generator[Popen, None, None]: # pragma: unless posix no cover [unused] 

630 """Terminate and wait for the subprocess upon exiting the context. 

631 

632 Args: 

633 proc: 

634 The subprocess to manage. 

635 

636 Returns: 

637 A context manager. Upon entering the manager, return the 

638 managed subprocess. Upon exiting the manager, terminate the 

639 process and wait for it. 

640 

641 """ 

642 try: 

643 yield proc 

644 finally: 

645 proc.terminate() 

646 proc.wait() 

647 

648 

649class CannotSpawnError(RuntimeError): 

650 """Cannot spawn the SSH agent.""" 

651 

652 

653def spawn_named_agent( # noqa: C901 

654 executable: str | None, 

655 spawn_func: SSHAgentSpawnFunc | SSHAgentInterfaceFunc, 

656 agent_type: data.KnownSSHAgentType, 

657 agent_name: str, 

658) -> Generator[ 

659 data.SpawnedSSHAgentInfo, None, None 

660]: # pragma: no cover [external] 

661 """Spawn the named SSH agent and check that it is operational. 

662 

663 Using the correct agent-specific spawn function from the 

664 [`spawn_handlers`][] registry, spawn the named SSH agent (according 

665 to its declared type), then set up the communication channel and 

666 yield an SSH agent client connected to this agent. After resuming, 

667 tear down the communication channel and terminate the SSH agent. 

668 

669 The SSH agent's instructions for setting up the communication 

670 channel are parsed with [`callables.parse_sh_export_line`][]. See 

671 the caveats there. 

672 

673 (The choice of terminology "spawned" instead of the more accurate 

674 "spawned or interfaced" is due to historical reasons.) 

675 

676 Args: 

677 executable: 

678 The full path of the executable to spawn. If not given, we 

679 attempt to spawn the agent under its conventional executable 

680 name. 

681 spawn_func: 

682 The agent-specific spawn function. 

683 agent_type: 

684 The agent type. 

685 agent_name: 

686 The agent's display name. 

687 

688 Yields: 

689 A 3-tuple containing the agent type, an SSH agent client 

690 connected to this agent, and a boolean indicating whether this 

691 agent was actually spawned in an isolated manner. 

692 

693 Only one tuple will ever be yielded. After resuming, the 

694 connected client will be torn down, as will the agent if it was 

695 isolated. 

696 

697 Raises: 

698 CannotSpawnError: 

699 We failed to spawn the agent or otherwise set up the 

700 environment/communication channel/etc. 

701 

702 """ 

703 # pytest's fixture system does not seem to guarantee that 

704 # environment variables are set up correctly if nested and 

705 # parametrized fixtures are used: it is possible that "outer" 

706 # parametrized fixtures are torn down only after other "outer" 

707 # fixtures of the same parameter set have run. So our fixtures set 

708 # SSH_AUTH_SOCK explicitly to the value saved at interpreter 

709 # startup. 

710 # 

711 # Here, we verify at most major steps that SSH_AUTH_SOCK didn't 

712 # change under our nose. 

713 assert os.environ.get("SSH_AUTH_SOCK") == startup_ssh_auth_sock, ( 

714 f"SSH_AUTH_SOCK mismatch when checking for spawnable {agent_name}" 

715 ) 

716 exit_stack = contextlib.ExitStack() 

717 agent_env = os.environ.copy() 

718 ssh_auth_sock = agent_env.pop("SSH_AUTH_SOCK", None) 

719 proc_or_socket_data = spawn_func(executable=executable, env=agent_env) 

720 with exit_stack: 

721 if agent_type == data.KnownSSHAgentType.StubbedSSHAgent: 

722 ssh_auth_sock = None 

723 elif spawn_func is spawn_noop: 

724 ssh_auth_sock = os.environ.get("SSH_AUTH_SOCK") 

725 elif proc_or_socket_data is None: # pragma: no cover [external] 

726 err_msg = f"Cannot spawn or interface with usable {agent_name}" 

727 raise CannotSpawnError(err_msg) 

728 elif isinstance(proc_or_socket_data, subprocess.Popen): 

729 proc = proc_or_socket_data 

730 exit_stack.enter_context(terminate_on_exit(proc)) 

731 assert os.environ.get("SSH_AUTH_SOCK") == startup_ssh_auth_sock, ( 

732 f"SSH_AUTH_SOCK mismatch after spawning {agent_name}" 

733 ) 

734 assert proc.stdout is not None 

735 ssh_auth_sock_line = proc.stdout.readline() 

736 try: 

737 ssh_auth_sock = callables.parse_sh_export_line( 

738 ssh_auth_sock_line, env_name="SSH_AUTH_SOCK" 

739 ) 

740 except ValueError: # pragma: no cover [external] 

741 err_msg = f"Cannot parse agent output: {ssh_auth_sock_line!r}" 

742 raise CannotSpawnError(err_msg) from None 

743 pid_line = proc.stdout.readline() 

744 if ( 

745 "pid" not in pid_line.lower() 

746 and "_pid" not in pid_line.lower() 

747 ): # pragma: no cover [external] 

748 err_msg = f"Cannot parse agent output: {pid_line!r}" 

749 raise CannotSpawnError(err_msg) 

750 else: 

751 ssh_auth_sock = proc_or_socket_data[1] 

752 monkeypatch = exit_stack.enter_context(pytest.MonkeyPatch.context()) 

753 if agent_type == data.KnownSSHAgentType.StubbedSSHAgent: 

754 assert ssh_auth_sock is None 

755 monkeypatch.setenv( 

756 "SSH_AUTH_SOCK", 

757 machinery.StubbedSSHAgentSocketWithAddress.ADDRESS, 

758 ) 

759 monkeypatch.setattr( 

760 ssh_agent.SSHAgentClient, 

761 "SOCKET_PROVIDERS", 

762 [ 

763 data.KnownSSHAgentInstance.stub_agent_with_address_and_deterministic_dsa 

764 ] 

765 if "stub_agent_with_address_and_deterministic_dsa" 

766 in agent_name 

767 else [data.KnownSSHAgentInstance.stub_agent_with_address], 

768 ) 

769 client = exit_stack.enter_context( 

770 ssh_agent.SSHAgentClient.ensure_agent_subcontext( 

771 machinery.StubbedSSHAgentSocketWithAddressAndDeterministicDSA() 

772 if "stub_agent_with_address_and_deterministic_dsa" 

773 in agent_name 

774 else machinery.StubbedSSHAgentSocketWithAddress() 

775 ) 

776 ) 

777 elif ( 

778 not isinstance(proc_or_socket_data, subprocess.Popen) 

779 and proc_or_socket_data is not None 

780 ): 

781 socket: _types.SSHAgentSocket 

782 socket, ssh_auth_sock = proc_or_socket_data 

783 monkeypatch.setenv("SSH_AUTH_SOCK", ssh_auth_sock) 

784 client = exit_stack.enter_context( 

785 ssh_agent.SSHAgentClient.ensure_agent_subcontext(conn=socket) 

786 ) 

787 else: 

788 if ssh_auth_sock: 

789 monkeypatch.setenv("SSH_AUTH_SOCK", ssh_auth_sock) 

790 client = exit_stack.enter_context( 

791 ssh_agent.SSHAgentClient.ensure_agent_subcontext() 

792 ) 

793 # We sanity-test the connected SSH agent if it is not one of our 

794 # test agents, because allowing the user to run the test suite 

795 # with a clearly faulty agent would likely and unfairly 

796 # misattribute the agent's protocol violations to our test 

797 # suite. On the flip side, for our own test agents, the 

798 # correctness tests are part of the test suite, so we don't want 

799 # the *setup* code here to already decide that the agent is 

800 # unsuitable. Therefore, do a sanity check if and only if the 

801 # agent is not one of our test agents, and if the check fails, 

802 # skip this agent. 

803 if ( 

804 agent_type != data.KnownSSHAgentType.StubbedSSHAgent 

805 ): # pragma: no cover [external] 

806 try: 

807 client.list_keys() # sanity test 

808 except ( 

809 EOFError, 

810 OSError, 

811 ssh_agent.SSHAgentFailedError, 

812 ) as exc: # pragma: no cover [failsafe] 

813 msg = f'agent failed the "list keys" sanity test: {exc!r}' 

814 raise CannotSpawnError(msg) from exc 

815 yield data.SpawnedSSHAgentInfo( 

816 agent_type, 

817 client, 

818 spawn_func is not spawn_noop 

819 if isinstance(proc_or_socket_data, subprocess.Popen) 

820 else False, 

821 ) 

822 assert os.environ.get("SSH_AUTH_SOCK", None) == startup_ssh_auth_sock, ( 

823 f"SSH_AUTH_SOCK mismatch after tearing down {agent_name}" 

824 ) 

825 

826 

827# Hack: We cannot associate markers with fixtures (the pytest devs say 

828# there are many unresolved questions when it comes to fixture and 

829# marker overriding in such a case), so we fake this by trivially 

830# parametrizing the fixture. (The parametrizations *can* contain the 

831# necessary marks.) 

832@pytest.fixture( 

833 params=[ 

834 pytest.param( 

835 "non_isolated_agent_use", 

836 marks=pytest_machinery.non_isolated_agent_use, 

837 ) 

838 ] 

839) 

840def running_ssh_agent( # pragma: no cover [external] 

841 request: pytest.FixtureRequest, 

842) -> Generator[data.RunningSSHAgentInfo, None, None]: 

843 """Ensure a running SSH agent, if possible, as a pytest fixture. 

844 

845 Check for a running SSH agent, or spawn/interface a new one if 

846 possible. We know how to spawn OpenSSH's agent and PuTTY's Pageant 

847 on UNIX, or interface them on The Annoying OS. If spawned this way, 

848 the agent does not persist beyond the test. 

849 

850 This fixture can neither guarantee a particular running agent, nor 

851 can it guarantee a particular set of loaded keys. 

852 

853 Yields: 

854 A 2-tuple `(ssh_auth_sock, agent_type)`, where `ssh_auth_sock` 

855 is the value of the `SSH_AUTH_SOCK` environment variable, to be 

856 used to connect to the running agent, and `agent_type` is the 

857 agent type. 

858 

859 Raises: 

860 pytest.skip.Exception: 

861 If no agent is running or can be spawned, skip this test. 

862 

863 """ 

864 del request 

865 

866 def prepare_environment( 

867 agent_type: data.KnownSSHAgentType, 

868 ) -> Generator[data.RunningSSHAgentInfo, None, None]: 

869 with pytest.MonkeyPatch.context() as monkeypatch: 

870 if agent_type == data.KnownSSHAgentType.StubbedSSHAgent: 

871 monkeypatch.setattr( 

872 ssh_agent.SSHAgentClient, 

873 "SOCKET_PROVIDERS", 

874 [ 

875 _types.BuiltinSSHAgentSocketProvider.STUB_AGENT_WITH_ADDRESS 

876 ], 

877 ) 

878 monkeypatch.setenv( 

879 "SSH_AUTH_SOCK", 

880 machinery.StubbedSSHAgentSocketWithAddress.ADDRESS, 

881 ) 

882 yield data.RunningSSHAgentInfo( 

883 machinery.StubbedSSHAgentSocketWithAddressAndDeterministicDSA, 

884 data.KnownSSHAgentType.StubbedSSHAgent, 

885 ) 

886 else: 

887 yield data.RunningSSHAgentInfo( 

888 os.environ["SSH_AUTH_SOCK"], 

889 agent_type, 

890 ) 

891 

892 with pytest.MonkeyPatch.context() as monkeypatch: 

893 # pytest's fixture system does not seem to guarantee that 

894 # environment variables are set up correctly if nested and 

895 # parametrized fixtures are used: it is possible that "outer" 

896 # parametrized fixtures are torn down only after other "outer" 

897 # fixtures of the same parameter set have run. So set 

898 # SSH_AUTH_SOCK explicitly to the value saved at interpreter 

899 # startup. 

900 if startup_ssh_auth_sock: 

901 monkeypatch.setenv("SSH_AUTH_SOCK", startup_ssh_auth_sock) 

902 else: 

903 monkeypatch.delenv("SSH_AUTH_SOCK", raising=False) 

904 for handler in spawn_handlers.values(): 

905 if not is_agent_permitted(handler.instance.agent_type): 

906 continue 

907 try: 

908 for _agent_info in spawn_named_agent( 

909 executable=handler.executable, 

910 spawn_func=handler.spawn_func, 

911 agent_type=handler.instance.agent_type, 

912 agent_name=handler.agent_name or handler.instance, 

913 ): 

914 yield from prepare_environment(handler.instance.agent_type) 

915 except (KeyError, OSError, CannotSpawnError): 

916 continue 

917 return 

918 pytest.skip("No SSH agent running or spawnable") 

919 

920 

921@pytest.fixture(params=spawn_handlers_params) 

922def spawn_ssh_agent( 

923 request: pytest.FixtureRequest, 

924) -> Generator[ 

925 data.SpawnedSSHAgentInfo, None, None 

926]: # pragma: no cover [external] 

927 """Spawn or interface an SSH agent, as a pytest fixture. 

928 

929 Spawn a new SSH agent (isolated from other SSH use by other 

930 processes, on a best-effort basis), or interface a running 

931 (non-isolated) SSH agent. We know how to spawn OpenSSH's agent (on 

932 UNIX) and PuTTY's Pageant (on UNIX) and the stubbed agents, and how 

933 to interface PuTTY's Pageant (on The Annoying OS), OpenSSH's agent 

934 (on The Annoying OS) and the "system" agent (on every OS). 

935 

936 (The choice of terminology "spawned" instead of the more accurate 

937 "spawned or interfaced" is due to historical reasons.) 

938 

939 Yields: 

940 A [named tuple][collections.namedtuple] containing information 

941 about the spawned agent, e.g. the software product, a client 

942 connected to the agent, and whether the agent is isolated from 

943 other clients. 

944 

945 Raises: 

946 pytest.skip.Exception: 

947 If the agent cannot be spawned or interfaced, skip this 

948 test. 

949 

950 """ 

951 

952 with pytest.MonkeyPatch.context() as monkeypatch: 

953 # pytest's fixture system does not seem to guarantee that 

954 # environment variables are set up correctly if nested and 

955 # parametrized fixtures are used: it is possible that "outer" 

956 # parametrized fixtures are torn down only after other "outer" 

957 # fixtures of the same parameter set have run. So set 

958 # SSH_AUTH_SOCK explicitly to the value saved at interpreter 

959 # startup. 

960 if startup_ssh_auth_sock: # pragma: no cover [external] 

961 monkeypatch.setenv("SSH_AUTH_SOCK", startup_ssh_auth_sock) 

962 else: # pragma: no cover [external] 

963 monkeypatch.delenv("SSH_AUTH_SOCK", raising=False) 

964 try: 

965 yield from spawn_named_agent( 

966 executable=request.param.executable, 

967 spawn_func=request.param.spawn_func, 

968 agent_type=request.param.instance.agent_type, 

969 agent_name=request.param.agent_name or request.param.instance, 

970 ) 

971 except KeyError as exc: 

972 pytest.skip( 

973 f"The environment variable {exc.args[0]!r} was not found" 

974 if exc.__class__ is KeyError 

975 else exc.args[0] 

976 ) 

977 except (OSError, CannotSpawnError) as exc: 

978 name = request.param.agent_name or request.param.instance 

979 pytest.skip(f"Cannot spawn {name}: {exc}") 

980 return 

981 

982 

983def _prepare_payload( 

984 payload: bytes | bytearray, 

985 *, 

986 isolated: bool = True, 

987 time_to_live: int = 30, 

988) -> tuple[_types.SSH_AGENTC, bytes]: 

989 """Return a full payload for an "add key" request to the agent. 

990 

991 For isolated agents, return a standard 

992 [`_types.SSH_AGENTC.ADD_IDENTITY`][] request payload. For 

993 non-isolated agents, return a 

994 [`_types.SSH_AGENTC.ADD_ID_CONSTRAINED`][] request payload with the 

995 specified time-to-live value. 

996 

997 Args: 

998 payload: 

999 The private key to upload, already formatted suitably. 

1000 isolated: 

1001 `True` if the agent is isolated, `False` otherwise. 

1002 time_to_live: 

1003 The amount of seconds to hold the key in the agent, if not 

1004 isolated. 

1005 

1006 """ 

1007 return_code = ( 

1008 _types.SSH_AGENTC.ADD_IDENTITY 

1009 if isolated 

1010 else _types.SSH_AGENTC.ADD_ID_CONSTRAINED 

1011 ) 

1012 lifetime_constraint = ( 

1013 b"" 

1014 if isolated 

1015 else b"\x01" + ssh_agent.SSHAgentClient.uint32(time_to_live) 

1016 ) 

1017 return (return_code, bytes(payload) + lifetime_constraint) 

1018 

1019 

1020def _load_key_optimistically( 

1021 spawned_agent_info: data.SpawnedSSHAgentInfo, 

1022 key_struct: data.SSHTestKey, 

1023) -> bool: 

1024 """Load a test key optimistically into the spawned agent. 

1025 

1026 Load the key with a time-to-live constraint (if isolated) or not, as 

1027 appropriate. If that fails because this agent does not support key 

1028 constraints, retry it without constraints. 

1029 

1030 Args: 

1031 spawned_agent_info: Info on the spawned agent. 

1032 key_struct: The struct for the SSH test key to upload. 

1033 

1034 Returns: 

1035 `True` if the key was successfully uploaded, `False` otherwise. 

1036 Retrying an upload because key constraints are not supported 

1037 still counts as successfully uploaded. 

1038 

1039 """ 

1040 agent_type, client, isolated = spawned_agent_info 

1041 private_key_data = key_struct.private_key_blob 

1042 request_code, payload = _prepare_payload( 

1043 private_key_data, isolated=isolated, time_to_live=30 

1044 ) 

1045 try: 

1046 try: 

1047 client.request( 

1048 request_code, 

1049 payload, 

1050 response_code=_types.SSH_AGENT.SUCCESS, 

1051 ) 

1052 except ssh_agent.SSHAgentFailedError: # pragma: no cover [external] 

1053 # Pageant can fail to accept a key for two separate reasons: 

1054 # 

1055 # - Pageant refuses to accept a key it already holds in 

1056 # memory. Verify this by listing keys. 

1057 # - Pageant does not support key constraints (see 

1058 # references below). 

1059 # 

1060 # https://www.chiark.greenend.org.uk/~sgtatham/putty/wishlist/pageant-timeout.html 

1061 # https://www.chiark.greenend.org.uk/~sgtatham/putty/wishlist/pageant-key-confirm.html 

1062 current_loaded_keys = frozenset({ 

1063 pair.key for pair in client.list_keys() 

1064 }) 

1065 if ( 

1066 agent_type 

1067 in { 

1068 data.KnownSSHAgentType.UNIXPageant, 

1069 data.KnownSSHAgentType.Pageant, 

1070 } 

1071 and key_struct.public_key_data in current_loaded_keys 

1072 ): 

1073 pass 

1074 elif ( 

1075 agent_type 

1076 in { 

1077 data.KnownSSHAgentType.UNIXPageant, 

1078 data.KnownSSHAgentType.Pageant, 

1079 } 

1080 and not isolated 

1081 ): 

1082 request_code, payload = _prepare_payload( 

1083 private_key_data, isolated=True 

1084 ) 

1085 client.request( 

1086 request_code, 

1087 payload, 

1088 response_code=_types.SSH_AGENT.SUCCESS, 

1089 ) 

1090 else: 

1091 raise 

1092 except ( 

1093 EOFError, 

1094 OSError, 

1095 ssh_agent.SSHAgentFailedError, 

1096 ): # pragma: no cover [external] 

1097 return False 

1098 else: # pragma: no cover [external] 

1099 return True 

1100 

1101 

1102@pytest.fixture 

1103def ssh_agent_client_with_test_keys_loaded( 

1104 spawn_ssh_agent: data.SpawnedSSHAgentInfo, 

1105) -> Generator[ssh_agent.SSHAgentClient, None, None]: 

1106 """Provide an SSH agent with loaded test keys, as a pytest fixture. 

1107 

1108 Use the `spawn_ssh_agent` fixture to acquire a usable SSH agent, 

1109 upload the known test keys into the agent, and return a connected 

1110 client. 

1111 

1112 The agent may reject several of the test keys due to unsupported or 

1113 obsolete key types. Rejected keys will be silently ignored, unless 

1114 all keys are rejected; then the test will be skipped. You must not 

1115 automatically assume any particular key is present in the agent. 

1116 

1117 Yields: 

1118 A [named tuple][collections.namedtuple] containing 

1119 information about the spawned agent, e.g. the software 

1120 product, a client connected to the agent, and whether the 

1121 agent is isolated from other clients. 

1122 

1123 Raises: 

1124 OSError: 

1125 There was a communication or a socket setup error with the 

1126 agent. 

1127 pytest.skip.Exception: 

1128 If the agent is unusable or if it rejected all test keys, 

1129 skip this test. 

1130 

1131 Warning: 

1132 It is this fixture's responsibility to clean up the SSH agent 

1133 client after the test. Closing the client's socket connection 

1134 beforehand (e.g. by using the client as a context manager) may 

1135 lead to exceptions being thrown upon fixture teardown. 

1136 

1137 """ 

1138 agent_type, client, isolated = spawn_ssh_agent 

1139 successfully_loaded_keys: set[str] = set() 

1140 

1141 # This fixture relies on `spawn_ssh_agent`, which in turn uses 

1142 # `spawn_named_agent` to, well, spawn agents. `spawn_named_agent` 

1143 # runs sanity tests on the agents it spawns, via 

1144 # `SSHAgentClient.list_keys`. There is thus little point in 

1145 # repeating the very same sanity test here, on an agent that was 

1146 # already sanity-tested. 

1147 # 

1148 # (But see below, too.) 

1149 

1150 try: 

1151 successfully_loaded_keys = { 

1152 key_type 

1153 for key_type, key_struct in data.ALL_KEYS.items() 

1154 if _load_key_optimistically(spawn_ssh_agent, key_struct) 

1155 } 

1156 if ( 

1157 agent_type != data.KnownSSHAgentType.StubbedSSHAgent 

1158 and not successfully_loaded_keys 

1159 ): # pragma: no cover [external] 

1160 pytest.skip("Failed to load any test keys at all into the agent.") 

1161 # Sanity-test the agent. It makes sense to do this here (unlike 

1162 # at the top of this fixture), because we ignore errors when 

1163 # adding keys above, but badly behaved SSH agents such as the 

1164 # Annoying OS port of OpenSSH 10.0p1 (Dec 2025) may have already 

1165 # closed the connection. 

1166 try: 

1167 client.list_keys() 

1168 except ( 

1169 EOFError, 

1170 OSError, 

1171 ssh_agent.SSHAgentFailedError, 

1172 ): # pragma: no cover [external] 

1173 pytest.skip( 

1174 "The agent became unusable during test fixture setup. " 

1175 "(Sanity check failed.)" 

1176 ) 

1177 yield client 

1178 finally: 

1179 request_code = _types.SSH_AGENTC.REMOVE_IDENTITY 

1180 # For isolated agents, we have full control over the loaded key 

1181 # set, and thus expect to successfully unload every key. For 

1182 # non-isolated agents, the user or another process may load or 

1183 # unload keys in the meantime, so we cannot assume that the 

1184 # operation will succeed. 

1185 response_code = ( 

1186 frozenset({_types.SSH_AGENT.SUCCESS}) 

1187 if isolated 

1188 else frozenset({ 

1189 _types.SSH_AGENT.SUCCESS, 

1190 _types.SSH_AGENT.FAILURE, 

1191 }) 

1192 ) 

1193 for key_type in successfully_loaded_keys: 

1194 key_struct = data.ALL_KEYS[key_type] 

1195 # The public key blob is the base64-encoded part in 

1196 # the "public key line". 

1197 public_key = base64.standard_b64decode( 

1198 key_struct.public_key.split(None, 2)[1] 

1199 ) 

1200 # Though unnecessary for isolated agents, unloading the key 

1201 # is vital functionality for non-isolated agents. We thus 

1202 # always unload keys, and use the "isolated agent" case as 

1203 # a smoke test for the more brittle "non-isolated agent" 

1204 # case. 

1205 client.request( 

1206 request_code, 

1207 ssh_agent.SSHAgentClient.string(public_key), 

1208 response_code=response_code, 

1209 )