test_paramiko_vendor.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600
  1. # test_paramiko_vendor.py
  2. #
  3. # SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later
  4. # Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU
  5. # General Public License as published by the Free Software Foundation; version 2.0
  6. # or (at your option) any later version. You can redistribute it and/or
  7. # modify it under the terms of either of these two licenses.
  8. #
  9. # Unless required by applicable law or agreed to in writing, software
  10. # distributed under the License is distributed on an "AS IS" BASIS,
  11. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  12. # See the License for the specific language governing permissions and
  13. # limitations under the License.
  14. #
  15. # You should have received a copy of the licenses; if not, see
  16. # <http://www.gnu.org/licenses/> for a copy of the GNU General Public License
  17. # and <http://www.apache.org/licenses/LICENSE-2.0> for a copy of the Apache
  18. # License, Version 2.0.
  19. #
  20. """Tests for paramiko_vendor."""
  21. import os
  22. import socket
  23. import tempfile
  24. import threading
  25. import time
  26. from io import StringIO
  27. from typing import Optional
  28. from unittest import skipIf
  29. from unittest.mock import patch
  30. from .. import TestCase
  31. try:
  32. import paramiko
  33. except ImportError:
  34. has_paramiko = False
  35. else:
  36. has_paramiko = True
  37. import paramiko.transport
  38. from dulwich.contrib.paramiko_vendor import ParamikoSSHVendor
  39. class Server(paramiko.ServerInterface):
  40. """http://docs.paramiko.org/en/2.4/api/server.html."""
  41. def __init__(self, commands, *args, **kwargs) -> None:
  42. super().__init__(*args, **kwargs)
  43. self.commands = commands
  44. def check_channel_exec_request(self, channel, command) -> bool:
  45. self.commands.append(command)
  46. return True
  47. def check_auth_password(self, username, password):
  48. if username == USER and password == PASSWORD:
  49. return paramiko.AUTH_SUCCESSFUL
  50. return paramiko.AUTH_FAILED
  51. def check_auth_publickey(self, username, key):
  52. pubkey = paramiko.RSAKey.from_private_key(StringIO(CLIENT_KEY))
  53. if username == USER and key == pubkey:
  54. return paramiko.AUTH_SUCCESSFUL
  55. return paramiko.AUTH_FAILED
  56. def check_channel_request(self, kind, chanid):
  57. if kind == "session":
  58. return paramiko.OPEN_SUCCEEDED
  59. return paramiko.OPEN_FAILED_ADMINISTRATIVELY_PROHIBITED
  60. def get_allowed_auths(self, username) -> str:
  61. return "password,publickey"
  62. class SSHServer:
  63. """A real SSH server using Paramiko that listens on a TCP port."""
  64. def __init__(self):
  65. self.commands = []
  66. self.server_socket = None
  67. self.server_thread = None
  68. self.host_key = paramiko.RSAKey.from_private_key(StringIO(SERVER_KEY))
  69. self.running = False
  70. self.connection_threads = []
  71. def start(self):
  72. """Start the SSH server on a random port."""
  73. self.server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  74. self.server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
  75. self.server_socket.bind(("127.0.0.1", 0))
  76. self.port = self.server_socket.getsockname()[1]
  77. self.server_socket.listen(5)
  78. self.running = True
  79. self.server_thread = threading.Thread(target=self._run_server)
  80. self.server_thread.daemon = True
  81. self.server_thread.start()
  82. # Give the server a moment to start up
  83. time.sleep(0.1)
  84. def stop(self):
  85. """Stop the SSH server."""
  86. self.running = False
  87. if self.server_socket:
  88. try:
  89. self.server_socket.close()
  90. except OSError:
  91. pass
  92. # Clean up connection threads
  93. for thread in self.connection_threads:
  94. if thread.is_alive():
  95. thread.join(timeout=1)
  96. if self.server_thread and self.server_thread.is_alive():
  97. self.server_thread.join(timeout=5)
  98. def _run_server(self):
  99. """Main server loop."""
  100. self.server_socket.settimeout(
  101. 1.0
  102. ) # Allow checking self.running periodically
  103. while self.running:
  104. try:
  105. client_socket, addr = self.server_socket.accept()
  106. # Handle each connection in a separate thread
  107. conn_thread = threading.Thread(
  108. target=self._handle_connection, args=(client_socket,)
  109. )
  110. conn_thread.daemon = True
  111. conn_thread.start()
  112. self.connection_threads.append(conn_thread)
  113. except socket.timeout:
  114. # Normal timeout, continue to check if we should keep running
  115. continue
  116. except OSError as e:
  117. # Socket was closed, exit gracefully
  118. if not self.running:
  119. break
  120. # Otherwise re-raise the error
  121. raise e
  122. def _handle_connection(self, client_socket):
  123. """Handle a single SSH connection."""
  124. transport = None
  125. try:
  126. transport = paramiko.Transport(client_socket)
  127. transport.add_server_key(self.host_key)
  128. server = Server(self.commands)
  129. transport.start_server(server=server)
  130. # Wait for channel requests and handle them
  131. while self.running and transport.is_active():
  132. channel = transport.accept(1)
  133. if channel is None:
  134. continue
  135. # Handle channel in a separate thread to allow multiple channels
  136. channel_thread = threading.Thread(
  137. target=self._handle_channel, args=(channel,)
  138. )
  139. channel_thread.daemon = True
  140. channel_thread.start()
  141. except paramiko.SSHException as e:
  142. print(f"SSH error in connection handler: {e}")
  143. except OSError as e:
  144. print(f"Socket error in connection handler: {e}")
  145. finally:
  146. if transport:
  147. transport.close()
  148. if client_socket:
  149. client_socket.close()
  150. def _handle_channel(self, channel):
  151. """Handle a single SSH channel - echo server."""
  152. try:
  153. # Set channel to blocking mode
  154. channel.setblocking(True)
  155. channel.settimeout(10.0)
  156. # Read all available data and echo it back
  157. while True:
  158. try:
  159. data = channel.recv(4096)
  160. if not data:
  161. break
  162. # Echo the data back immediately
  163. channel.send(data)
  164. except socket.timeout:
  165. # No more data available, break
  166. break
  167. except paramiko.SSHException as e:
  168. print(f"SSH error in channel handler: {e}")
  169. except OSError as e:
  170. print(f"Socket error in channel handler: {e}")
  171. finally:
  172. try:
  173. channel.close()
  174. except OSError:
  175. pass
  176. USER = "testuser"
  177. PASSWORD = "test"
  178. SERVER_KEY = """\
  179. -----BEGIN RSA PRIVATE KEY-----
  180. MIIEpAIBAAKCAQEAy/L1sSYAzxsMprtNXW4u/1jGXXkQmQ2xtmKVlR+RlIL3a1BH
  181. bzTpPlZyjltAAwzIP8XRh0iJFKz5y3zSQChhX47ZGN0NvQsVct8R+YwsUonwfAJ+
  182. JN0KBKKvC8fPHlzqBr3gX+ZxqsFH934tQ6wdQPH5eQWtdM8L826lMsH1737uyTGk
  183. +mCSDjL3c6EzY83g7qhkJU2R4qbi6ne01FaWADzG8sOzXnHT+xpxtk8TTT8yCVUY
  184. MmBNsSoA/ka3iWz70ghB+6Xb0WpFJZXWq1oYovviPAfZGZSrxBZMxsWMye70SdLl
  185. TqsBEt0+miIcm9s0fvjWvQuhaHX6mZs5VO4r5QIDAQABAoIBAGYqeYWaYgFdrYLA
  186. hUrubUCg+g3NHdFuGL4iuIgRXl4lFUh+2KoOuWDu8Uf60iA1AQNhV0sLvQ/Mbv3O
  187. s4xMLisuZfaclctDiCUZNenqnDFkxEF7BjH1QJV94W5nU4wEQ3/JEmM4D2zYkfKb
  188. FJW33JeyH6TOgUvohDYYEU1R+J9V8qA243p+ui1uVtNI6Pb0TXJnG5y9Ny4vkSWH
  189. Fi0QoMPR1r9xJ4SEearGzA/crb4SmmDTKhGSoMsT3d5ATieLmwcS66xWz8w4oFGJ
  190. yzDq24s4Fp9ccNjMf/xR8XRiekJv835gjEqwF9IXyvgOaq6XJ1iCqGPFDKa25nui
  191. JnEstOkCgYEA/ZXk7aIanvdeJlTqpX578sJfCnrXLydzE8emk1b7+5mrzGxQ4/pM
  192. PBQs2f8glT3t0O0mRX9NoRqnwrid88/b+cY4NCOICFZeasX336/gYQxyVeRLJS6Z
  193. hnGEQqry8qS7PdKAyeHMNmZFrUh4EiHiObymEfQS+mkRUObn0cGBTw8CgYEAzeQU
  194. D2baec1DawjppKaRynAvWjp+9ry1lZx9unryKVRwjRjkEpw+b3/+hdaF1IvsVSce
  195. cNj+6W2guZ2tyHuPhZ64/4SJVyE2hKDSKD4xTb2nVjsMeN0bLD2UWXC9mwbx8nWa
  196. 2tmtUZ7a/okQb2cSdosJinRewLNqXIsBXamT1csCgYEA0cXb2RCOQQ6U3dTFPx4A
  197. 3vMXuA2iUKmrsqMoEx6T2LBow/Sefdkik1iFOdipVYwjXP+w9zC2QR1Rxez/DR/X
  198. 8ymceNUjxPHdrSoTQQG29dFcC92MpDeGXQcuyA+uZjcLhbrLOzYEvsOfxBb87NMG
  199. 14hNQPDNekTMREafYo9WrtUCgYAREK54+FVzcwf7fymedA/xb4r9N4v+d3W1iNsC
  200. 8d3Qfyc1CrMct8aVB07ZWQaOr2pPRIbJY7L9NhD0UZVt4I/sy1MaGqonhqE2LP4+
  201. R6legDG2e/50ph7yc8gwAaA1kUXMiuLi8Nfkw/3yyvmJwklNegi4aRzRbA2Mzhi2
  202. 4q9WMQKBgQCb0JNyxHG4pvLWCF/j0Sm1FfvrpnqSv5678n1j4GX7Ka/TubOK1Y4K
  203. U+Oib7dKa/zQMWehVFNTayrsq6bKVZ6q7zG+IHiRLw4wjeAxREFH6WUjDrn9vl2l
  204. D48DKbBuBwuVOJWyq3qbfgJXojscgNQklrsPdXVhDwOF0dYxP89HnA==
  205. -----END RSA PRIVATE KEY-----"""
  206. CLIENT_KEY = """\
  207. -----BEGIN RSA PRIVATE KEY-----
  208. MIIEpAIBAAKCAQEAxvREKSElPOm/0z/nPO+j5rk2tjdgGcGc7We1QZ6TRXYLu7nN
  209. GeEFIL4p8N1i6dmB+Eydt7xqCU79MWD6Yy4prFe1+/K1wCDUxIbFMxqQcX5zjJzd
  210. i8j8PbcaUlVhP/OkjtkSxrXaGDO1BzfdV4iEBtTV/2l3zmLKJlt3jnOHLczP24CB
  211. DTQKp3rKshbRefzot9Y+wnaK692RsYgsyo9YEP0GyWKG9topCHk13r46J6vGLeuj
  212. ryUKqmbLJkzbJbIcEqwTDo5iHaCVqaMr5Hrb8BdMucSseqZQJsXSd+9tdRcIblUQ
  213. 38kZjmFMm4SFbruJcpZCNM2wNSZPIRX+3eiwNwIDAQABAoIBAHSacOBSJsr+jIi5
  214. KUOTh9IPtzswVUiDKwARCjB9Sf8p4lKR4N1L/n9kNJyQhApeikgGT2GCMftmqgoo
  215. tlculQoHFgemBlOmak0MV8NNzF5YKEy/GzF0CDH7gJfEpoyetVFrdA+2QS5yD6U9
  216. XqKQxiBi2VEqdScmyyeT8AwzNYTnPeH/DOEcnbdRjqiy/CD79F49CQ1lX1Fuqm0K
  217. I7BivBH1xo/rVnUP4F+IzocDqoga+Pjdj0LTXIgJlHQDSbhsQqWujWQDDuKb+MAw
  218. sNK4Zf8ErV3j1PyA7f/M5LLq6zgstkW4qikDHo4SpZX8kFOO8tjqb7kujj7XqeaB
  219. CxqrOTECgYEA73uWkrohcmDJ4KqbuL3tbExSCOUiaIV+sT1eGPNi7GCmXD4eW5Z4
  220. 75v2IHymW83lORSu/DrQ6sKr1nkuRpqr2iBzRmQpl/H+wahIhBXlnJ25uUjDsuPO
  221. 1Pq2LcmyD+jTxVnmbSe/q7O09gZQw3I6H4+BMHmpbf8tC97lqimzpJ0CgYEA1K0W
  222. ZL70Xtn9quyHvbtae/BW07NZnxvUg4UaVIAL9Zu34JyplJzyzbIjrmlDbv6aRogH
  223. /KtuG9tfbf55K/jjqNORiuRtzt1hUN1ye4dyW7tHx2/7lXdlqtyK40rQl8P0kqf8
  224. zaS6BqjnobgSdSpg32rWoL/pcBHPdJCJEgQ8zeMCgYEA0/PK8TOhNIzrP1dgGSKn
  225. hkkJ9etuB5nW5mEM7gJDFDf6JPupfJ/xiwe6z0fjKK9S57EhqgUYMB55XYnE5iIw
  226. ZQ6BV9SAZ4V7VsRs4dJLdNC3tn/rDGHJBgCaym2PlbsX6rvFT+h1IC8dwv0V79Ui
  227. Ehq9WTzkMoE8yhvNokvkPZUCgYEAgBAFxv5xGdh79ftdtXLmhnDvZ6S8l6Fjcxqo
  228. Ay/jg66Tp43OU226iv/0mmZKM8Dd1xC8dnon4GBVc19jSYYiWBulrRPlx0Xo/o+K
  229. CzZBN1lrXH1i6dqufpc0jq8TMf/N+q1q/c1uMupsKCY1/xVYpc+ok71b7J7c49zQ
  230. nOeuUW8CgYA9Infooy65FTgbzca0c9kbCUBmcAPQ2ItH3JcPKWPQTDuV62HcT00o
  231. fZdIV47Nez1W5Clk191RMy8TXuqI54kocciUWpThc6j44hz49oUueb8U4bLcEHzA
  232. WxtWBWHwxfSmqgTXilEA3ALJp0kNolLnEttnhENwJpZHlqtes0ZA4w==
  233. -----END RSA PRIVATE KEY-----"""
  234. @skipIf(not has_paramiko, "paramiko is not installed")
  235. class ParamikoSSHVendorTests(TestCase):
  236. def setUp(self) -> None:
  237. self.commands = []
  238. socket.setdefaulttimeout(10)
  239. self.addCleanup(socket.setdefaulttimeout, None)
  240. self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  241. self.socket.bind(("127.0.0.1", 0))
  242. self.socket.listen(5)
  243. self.addCleanup(self.socket.close)
  244. self.port = self.socket.getsockname()[1]
  245. self.thread = threading.Thread(target=self._run)
  246. self.thread.start()
  247. def tearDown(self) -> None:
  248. self.thread.join()
  249. def _run(self) -> Optional[bool]:
  250. try:
  251. conn, addr = self.socket.accept()
  252. except OSError:
  253. return False
  254. self.transport = paramiko.Transport(conn)
  255. self.addCleanup(self.transport.close)
  256. host_key = paramiko.RSAKey.from_private_key(StringIO(SERVER_KEY))
  257. self.transport.add_server_key(host_key)
  258. server = Server(self.commands)
  259. self.transport.start_server(server=server)
  260. def test_run_command_password(self) -> None:
  261. vendor = ParamikoSSHVendor(
  262. allow_agent=False,
  263. look_for_keys=False,
  264. )
  265. vendor.run_command(
  266. "127.0.0.1",
  267. "test_run_command_password",
  268. username=USER,
  269. port=self.port,
  270. password=PASSWORD,
  271. )
  272. self.assertIn(b"test_run_command_password", self.commands)
  273. def test_run_command_with_privkey(self) -> None:
  274. key = paramiko.RSAKey.from_private_key(StringIO(CLIENT_KEY))
  275. vendor = ParamikoSSHVendor(
  276. allow_agent=False,
  277. look_for_keys=False,
  278. )
  279. vendor.run_command(
  280. "127.0.0.1",
  281. "test_run_command_with_privkey",
  282. username=USER,
  283. port=self.port,
  284. pkey=key,
  285. )
  286. self.assertIn(b"test_run_command_with_privkey", self.commands)
  287. def test_run_command_data_transfer(self) -> None:
  288. vendor = ParamikoSSHVendor(
  289. allow_agent=False,
  290. look_for_keys=False,
  291. )
  292. con = vendor.run_command(
  293. "127.0.0.1",
  294. "test_run_command_data_transfer",
  295. username=USER,
  296. port=self.port,
  297. password=PASSWORD,
  298. )
  299. self.assertIn(b"test_run_command_data_transfer", self.commands)
  300. channel = self.transport.accept(5)
  301. channel.send(b"stdout\n")
  302. channel.send_stderr(b"stderr\n")
  303. channel.close()
  304. # Fixme: it's return false
  305. # self.assertTrue(con.can_read())
  306. self.assertEqual(b"stdout\n", con.read(4096))
  307. # Fixme: it's return empty string
  308. # self.assertEqual(b'stderr\n', con.read_stderr(4096))
  309. def test_ssh_config_parsing(self) -> None:
  310. """Test that SSH config is properly parsed and used by ParamikoSSHVendor."""
  311. # Create a temporary SSH config file
  312. with tempfile.NamedTemporaryFile(mode="w", suffix=".config", delete=False) as f:
  313. f.write(
  314. f"""
  315. Host testserver
  316. HostName 127.0.0.1
  317. User testuser
  318. Port {self.port}
  319. IdentityFile /path/to/key
  320. """
  321. )
  322. config_path = f.name
  323. try:
  324. # Mock the config path
  325. with patch(
  326. "dulwich.contrib.paramiko_vendor.os.path.expanduser"
  327. ) as mock_expanduser:
  328. def side_effect(path):
  329. if path == "~/.ssh/config":
  330. return config_path
  331. return path
  332. mock_expanduser.side_effect = side_effect
  333. vendor = ParamikoSSHVendor(
  334. allow_agent=False,
  335. look_for_keys=False,
  336. )
  337. # Test that SSH config values are loaded
  338. host_config = vendor.ssh_config.lookup("testserver")
  339. self.assertEqual(host_config["hostname"], "127.0.0.1")
  340. self.assertEqual(host_config["user"], "testuser")
  341. self.assertEqual(host_config["port"], str(self.port))
  342. self.assertIn("/path/to/key", host_config["identityfile"])
  343. finally:
  344. os.unlink(config_path)
  345. @skipIf(not has_paramiko, "paramiko is not installed")
  346. class ParamikoSSHVendorRealServerTests(TestCase):
  347. """Tests for ParamikoSSHVendor using a real SSH server listening on TCP."""
  348. def setUp(self) -> None:
  349. self.ssh_server = SSHServer()
  350. self.ssh_server.start()
  351. socket.setdefaulttimeout(10)
  352. self.addCleanup(socket.setdefaulttimeout, None)
  353. self.addCleanup(self.ssh_server.stop)
  354. def _run_command(self, command, **kwargs):
  355. """Helper to run a command with default vendor settings."""
  356. vendor = ParamikoSSHVendor(allow_agent=False, look_for_keys=False)
  357. kwargs.setdefault("port", self.ssh_server.port)
  358. kwargs.setdefault("username", USER)
  359. return vendor.run_command("127.0.0.1", command, **kwargs)
  360. def _test_echo(self, con, data):
  361. """Helper to test echo functionality."""
  362. con.write(data)
  363. response = con.read(len(data))
  364. self.assertEqual(data, response)
  365. def test_password_authentication_success(self) -> None:
  366. """Test successful password authentication."""
  367. con = self._run_command("test_password_auth", password=PASSWORD)
  368. self.assertIn(b"test_password_auth", self.ssh_server.commands)
  369. self._test_echo(con, b"hello\n")
  370. con.close()
  371. def test_key_authentication_success(self) -> None:
  372. """Test successful key authentication."""
  373. key = paramiko.RSAKey.from_private_key(StringIO(CLIENT_KEY))
  374. con = self._run_command("test_key_auth", pkey=key)
  375. self.assertIn(b"test_key_auth", self.ssh_server.commands)
  376. self._test_echo(con, b"key_test\n")
  377. con.close()
  378. def test_authentication_failures(self) -> None:
  379. """Test authentication failures."""
  380. # Wrong password
  381. with self.assertRaises(paramiko.AuthenticationException):
  382. self._run_command("should_fail", password="wrong_password")
  383. # Wrong key
  384. wrong_key = paramiko.RSAKey.generate(2048)
  385. with self.assertRaises(paramiko.AuthenticationException):
  386. self._run_command("should_fail", pkey=wrong_key)
  387. def test_connection_errors(self) -> None:
  388. """Test various connection errors."""
  389. vendor = ParamikoSSHVendor(allow_agent=False, look_for_keys=False)
  390. # Non-existent port
  391. with self.assertRaises((OSError, ConnectionRefusedError)):
  392. vendor.run_command(
  393. "127.0.0.1", "fail", username=USER, port=65432, password=PASSWORD
  394. )
  395. # Invalid hostname
  396. with self.assertRaises((socket.gaierror, OSError)):
  397. vendor.run_command(
  398. "invalid.hostname.example.com", "fail", username=USER, password=PASSWORD
  399. )
  400. def test_data_transfer(self) -> None:
  401. """Test various data transfer scenarios."""
  402. con = self._run_command("test_data", password=PASSWORD)
  403. # Large data (10KB)
  404. large_data = b"X" * 10240
  405. self._test_echo(con, large_data)
  406. # Binary data with all byte values
  407. binary_data = bytes(range(256))
  408. self._test_echo(con, binary_data)
  409. con.close()
  410. def test_multiple_connections(self) -> None:
  411. """Test multiple sequential connections."""
  412. # First connection
  413. con1 = self._run_command("test_connection_1", password=PASSWORD)
  414. self._test_echo(con1, b"first\n")
  415. con1.close()
  416. # Second connection
  417. con2 = self._run_command("test_connection_2", password=PASSWORD)
  418. self._test_echo(con2, b"second\n")
  419. con2.close()
  420. # Verify both commands were recorded
  421. self.assertIn(b"test_connection_1", self.ssh_server.commands)
  422. self.assertIn(b"test_connection_2", self.ssh_server.commands)
  423. def test_key_from_file(self) -> None:
  424. """Test authentication using key file."""
  425. with tempfile.NamedTemporaryFile(mode="w", suffix=".key", delete=False) as f:
  426. f.write(CLIENT_KEY)
  427. key_path = f.name
  428. try:
  429. con = self._run_command("test_key_from_file", key_filename=key_path)
  430. self.assertIn(b"test_key_from_file", self.ssh_server.commands)
  431. self._test_echo(con, b"file_key_test\n")
  432. con.close()
  433. finally:
  434. os.unlink(key_path)
  435. def test_protocol_versions(self) -> None:
  436. """Test protocol version handling."""
  437. # Protocol version 2 (default)
  438. con = self._run_command(
  439. "test_protocol_v2", password=PASSWORD, protocol_version=2
  440. )
  441. self.assertIn(b"test_protocol_v2", self.ssh_server.commands)
  442. con.close()
  443. # Protocol version 1
  444. con = self._run_command(
  445. "test_protocol_v1", password=PASSWORD, protocol_version=1
  446. )
  447. self.assertIn(b"test_protocol_v1", self.ssh_server.commands)
  448. con.close()
  449. def test_vendor_options(self) -> None:
  450. """Test vendor initialization options."""
  451. # Test with timeout
  452. vendor = ParamikoSSHVendor(allow_agent=False, look_for_keys=False, timeout=1)
  453. con = vendor.run_command(
  454. "127.0.0.1",
  455. "test_timeout",
  456. username=USER,
  457. port=self.ssh_server.port,
  458. password=PASSWORD,
  459. )
  460. self.assertIn(b"test_timeout", self.ssh_server.commands)
  461. con.close()
  462. def test_can_read(self) -> None:
  463. """Test can_read functionality."""
  464. con = self._run_command("test_can_read", password=PASSWORD)
  465. # Check can_read returns bool
  466. self.assertIsInstance(con.can_read(), bool)
  467. # Send data and verify echo
  468. self._test_echo(con, b"test_data\n")
  469. con.close()
  470. def test_partial_reads(self) -> None:
  471. """Test reading data in small chunks."""
  472. con = self._run_command("test_partial", password=PASSWORD)
  473. test_data = b"0123456789" * 10 # 100 bytes
  474. con.write(test_data)
  475. # Read in 10-byte chunks
  476. received_data = b""
  477. while len(received_data) < len(test_data):
  478. chunk = con.read(10)
  479. if not chunk:
  480. break
  481. received_data += chunk
  482. self.assertEqual(test_data, received_data)
  483. con.close()
  484. def test_ssh_config_integration(self) -> None:
  485. """Test SSH config integration."""
  486. with tempfile.NamedTemporaryFile(mode="w", suffix=".config", delete=False) as f:
  487. f.write(f"""
  488. Host testserver
  489. HostName 127.0.0.1
  490. User {USER}
  491. Port {self.ssh_server.port}
  492. """)
  493. config_path = f.name
  494. try:
  495. with patch(
  496. "dulwich.contrib.paramiko_vendor.os.path.expanduser"
  497. ) as mock_expanduser:
  498. mock_expanduser.side_effect = (
  499. lambda p: config_path if p == "~/.ssh/config" else p
  500. )
  501. vendor = ParamikoSSHVendor(allow_agent=False, look_for_keys=False)
  502. con = vendor.run_command(
  503. "testserver", "test_ssh_config", password=PASSWORD
  504. )
  505. self.assertIn(b"test_ssh_config", self.ssh_server.commands)
  506. self._test_echo(con, b"config_test\n")
  507. con.close()
  508. finally:
  509. os.unlink(config_path)