SshTestBase.java

/*
 * Copyright (C) 2018, Thomas Wolf <thomas.wolf@paranor.ch>
 * and other copyright owners as documented in the project's IP log.
 *
 * This program and the accompanying materials are made available
 * under the terms of the Eclipse Distribution License v1.0 which
 * accompanies this distribution, is reproduced below, and is
 * available at http://www.eclipse.org/org/documents/edl-v10.php
 *
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or
 * without modification, are permitted provided that the following
 * conditions are met:
 *
 * - Redistributions of source code must retain the above copyright
 *   notice, this list of conditions and the following disclaimer.
 *
 * - Redistributions in binary form must reproduce the above
 *   copyright notice, this list of conditions and the following
 *   disclaimer in the documentation and/or other materials provided
 *   with the distribution.
 *
 * - Neither the name of the Eclipse Foundation, Inc. nor the
 *   names of its contributors may be used to endorse or promote
 *   products derived from this software without specific prior
 *   written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
 * CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
 * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
 * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
 * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
 * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
 * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
 * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
 * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
 * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
 * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
 * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */
package org.eclipse.jgit.transport.ssh;

import static java.nio.charset.StandardCharsets.UTF_8;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assume.assumeTrue;

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.util.List;
import java.util.Locale;

import org.eclipse.jgit.api.errors.TransportException;
import org.eclipse.jgit.transport.CredentialItem;
import org.eclipse.jgit.transport.JschConfigSessionFactory;
import org.junit.Test;
import org.junit.experimental.theories.DataPoints;
import org.junit.experimental.theories.Theory;

/**
 * The ssh tests. Concrete subclasses can re-use these tests by implementing the
 * abstract operations from {@link SshTestHarness}. This gives a way to test
 * different ssh clients against a unified test suite.
 */
public abstract class SshTestBase extends SshTestHarness {

	@DataPoints
	public static String[] KEY_RESOURCES = { //
			"id_dsa", //
			"id_rsa_1024", //
			"id_rsa_2048", //
			"id_rsa_3072", //
			"id_rsa_4096", //
			"id_ecdsa_256", //
			"id_ecdsa_384", //
			"id_ecdsa_521", //
			"id_ed25519", //
			// And now encrypted. Passphrase is "testpass".
			"id_dsa_testpass", //
			"id_rsa_1024_testpass", //
			"id_rsa_2048_testpass", //
			"id_rsa_3072_testpass", //
			"id_rsa_4096_testpass", //
			"id_ecdsa_256_testpass", //
			"id_ecdsa_384_testpass", //
			"id_ecdsa_521_testpass" };

	protected File defaultCloneDir;

	@Override
	public void setUp() throws Exception {
		super.setUp();
		defaultCloneDir = new File(getTemporaryDirectory(), "cloned");
	}

	@Test(expected = TransportException.class)
	public void testSshWithoutConfig() throws Exception {
		cloneWith("ssh://" + TEST_USER + "@localhost:" + testPort
				+ "/doesntmatter", defaultCloneDir, null);
	}

	@Test
	public void testSshWithGlobalIdentity() throws Exception {
		cloneWith(
				"ssh://" + TEST_USER + "@localhost:" + testPort
						+ "/doesntmatter",
				defaultCloneDir, null,
				"IdentityFile " + privateKey1.getAbsolutePath());
	}

	@Test
	public void testSshWithDefaultIdentity() throws Exception {
		File idRsa = new File(privateKey1.getParentFile(), "id_rsa");
		Files.copy(privateKey1.toPath(), idRsa.toPath());
		// We expect the session factory to pick up these keys...
		cloneWith("ssh://" + TEST_USER + "@localhost:" + testPort
				+ "/doesntmatter", defaultCloneDir, null);
	}

	@Test
	public void testSshWithConfig() throws Exception {
		cloneWith("ssh://localhost/doesntmatter", defaultCloneDir, null, //
				"Host localhost", //
				"HostName localhost", //
				"Port " + testPort, //
				"User " + TEST_USER, //
				"IdentityFile " + privateKey1.getAbsolutePath());
	}

	@Test
	public void testSshWithConfigEncryptedUnusedKey() throws Exception {
		// Copy the encrypted test key from the bundle.
		File encryptedKey = new File(sshDir, "id_dsa");
		copyTestResource("id_dsa_testpass", encryptedKey);
		TestCredentialsProvider provider = new TestCredentialsProvider(
				"testpass");
		cloneWith("ssh://localhost/doesntmatter", defaultCloneDir, provider, //
				"Host localhost", //
				"HostName localhost", //
				"Port " + testPort, //
				"User " + TEST_USER, //
				"IdentityFile " + privateKey1.getAbsolutePath());
		assertEquals("CredentialsProvider should not have been called", 0,
				provider.getLog().size());
	}

	@Test
	public void testSshWithConfigEncryptedUnusedKeyInConfigLast()
			throws Exception {
		// Copy the encrypted test key from the bundle.
		File encryptedKey = new File(sshDir, "id_dsa_test_key");
		copyTestResource("id_dsa_testpass", encryptedKey);
		TestCredentialsProvider provider = new TestCredentialsProvider(
				"testpass");
		cloneWith("ssh://localhost/doesntmatter", defaultCloneDir, provider, //
				"Host localhost", //
				"HostName localhost", //
				"Port " + testPort, //
				"User " + TEST_USER, //
				"IdentityFile " + privateKey1.getAbsolutePath(),
				"IdentityFile " + encryptedKey.getAbsolutePath());
		// This test passes with JSch per chance because JSch completely ignores
		// the second IdentityFile
		assertEquals("CredentialsProvider should not have been called", 0,
				provider.getLog().size());
	}

	@Test
	public void testSshWithConfigEncryptedUnusedKeyInConfigFirst()
			throws Exception {
		// Test cannot pass with JSch; it handles only one IdentityFile.
		// assumeTrue(!(getSessionFactory() instanceof
		// JschConfigSessionFactory)); gives in bazel a failure with "Never
		// found parameters that satisfied method assumptions."
		// In maven it's fine!?
		if (getSessionFactory() instanceof JschConfigSessionFactory) {
			return;
		}
		// Copy the encrypted test key from the bundle.
		File encryptedKey = new File(sshDir, "id_dsa_test_key");
		copyTestResource("id_dsa_testpass", encryptedKey);
		TestCredentialsProvider provider = new TestCredentialsProvider(
				"testpass");
		cloneWith("ssh://localhost/doesntmatter", defaultCloneDir, provider, //
				"Host localhost", //
				"HostName localhost", //
				"Port " + testPort, //
				"User " + TEST_USER, //
				"IdentityFile " + encryptedKey.getAbsolutePath(),
				"IdentityFile " + privateKey1.getAbsolutePath());
		assertEquals("CredentialsProvider should have been called once", 1,
				provider.getLog().size());
	}

	@Test
	public void testSshEncryptedUsedKeyCached() throws Exception {
		// Make sure we are asked for the password only once if we do several
		// operations with an encrypted key.
		File encryptedKey = new File(sshDir, "id_dsa_test_key");
		copyTestResource("id_dsa_testpass", encryptedKey);
		File encryptedPublicKey = new File(sshDir, "id_dsa_test_key.pub");
		copyTestResource("id_dsa_testpass.pub", encryptedPublicKey);
		server.setTestUserPublicKey(encryptedPublicKey.toPath());
		TestCredentialsProvider provider = new TestCredentialsProvider(
				"testpass");
		pushTo(provider,
				cloneWith("ssh://localhost/doesntmatter", //
						defaultCloneDir, provider, //
						"Host localhost", //
						"HostName localhost", //
						"Port " + testPort, //
						"User " + TEST_USER, //
						"IdentityFile " + encryptedKey.getAbsolutePath()));
		assertEquals("CredentialsProvider should have been called once", 1,
				provider.getLog().size());
	}

	@Test(expected = TransportException.class)
	public void testSshEncryptedUsedKeyWrongPassword() throws Exception {
		File encryptedKey = new File(sshDir, "id_dsa_test_key");
		copyTestResource("id_dsa_testpass", encryptedKey);
		File encryptedPublicKey = new File(sshDir, "id_dsa_test_key.pub");
		copyTestResource("id_dsa_testpass.pub", encryptedPublicKey);
		server.setTestUserPublicKey(encryptedPublicKey.toPath());
		TestCredentialsProvider provider = new TestCredentialsProvider(
				"wrongpass");
		cloneWith("ssh://localhost/doesntmatter", //
				defaultCloneDir, provider, //
				"Host localhost", //
				"HostName localhost", //
				"Port " + testPort, //
				"User " + TEST_USER, //
				"NumberOfPasswordPrompts 1", //
				"IdentityFile " + encryptedKey.getAbsolutePath());
	}

	@Test
	public void testSshEncryptedUsedKeySeveralPassword() throws Exception {
		File encryptedKey = new File(sshDir, "id_dsa_test_key");
		copyTestResource("id_dsa_testpass", encryptedKey);
		File encryptedPublicKey = new File(sshDir, "id_dsa_test_key.pub");
		copyTestResource("id_dsa_testpass.pub", encryptedPublicKey);
		server.setTestUserPublicKey(encryptedPublicKey.toPath());
		TestCredentialsProvider provider = new TestCredentialsProvider(
				"wrongpass", "wrongpass2", "testpass");
		cloneWith("ssh://localhost/doesntmatter", //
				defaultCloneDir, provider, //
				"Host localhost", //
				"HostName localhost", //
				"Port " + testPort, //
				"User " + TEST_USER, //
				"IdentityFile " + encryptedKey.getAbsolutePath());
		assertEquals("CredentialsProvider should have been called 3 times", 3,
				provider.getLog().size());
	}

	@Test(expected = TransportException.class)
	public void testSshWithoutKnownHosts() throws Exception {
		assertTrue("Could not delete known_hosts", knownHosts.delete());
		cloneWith("ssh://localhost/doesntmatter", defaultCloneDir, null, //
				"Host localhost", //
				"HostName localhost", //
				"Port " + testPort, //
				"User " + TEST_USER, //
				"IdentityFile " + privateKey1.getAbsolutePath());
	}

	@Test
	public void testSshWithoutKnownHostsWithProviderAsk()
			throws Exception {
		File copiedHosts = new File(knownHosts.getParentFile(),
				"copiedKnownHosts");
		assertTrue("Failed to rename known_hosts",
				knownHosts.renameTo(copiedHosts));
		// The provider will answer "yes" to all questions, so we should be able
		// to connect and end up with a new known_hosts file with the host key.
		TestCredentialsProvider provider = new TestCredentialsProvider();
		cloneWith("ssh://localhost/doesntmatter", defaultCloneDir, provider, //
				"Host localhost", //
				"HostName localhost", //
				"Port " + testPort, //
				"User " + TEST_USER, //
				"IdentityFile " + privateKey1.getAbsolutePath());
		List<LogEntry> messages = provider.getLog();
		assertFalse("Expected user interaction", messages.isEmpty());
		if (getSessionFactory() instanceof JschConfigSessionFactory) {
			// JSch doesn't create a non-existing file.
			assertEquals("Expected to be asked about the key", 1,
					messages.size());
			return;
		}
		assertEquals(
				"Expected to be asked about the key, and the file creation",
				2, messages.size());
		assertTrue("~/.ssh/known_hosts should exist now", knownHosts.exists());
		// Instead of checking the file contents, let's just clone again
		// without provider. If it works, the server host key was written
		// correctly.
		File clonedAgain = new File(getTemporaryDirectory(), "cloned2");
		cloneWith("ssh://localhost/doesntmatter", clonedAgain, provider, //
				"Host localhost", //
				"HostName localhost", //
				"Port " + testPort, //
				"User " + TEST_USER, //
				"IdentityFile " + privateKey1.getAbsolutePath());
	}

	@Test
	public void testSshWithoutKnownHostsWithProviderAcceptNew()
			throws Exception {
		File copiedHosts = new File(knownHosts.getParentFile(),
				"copiedKnownHosts");
		assertTrue("Failed to rename known_hosts",
				knownHosts.renameTo(copiedHosts));
		TestCredentialsProvider provider = new TestCredentialsProvider();
		cloneWith("ssh://localhost/doesntmatter", defaultCloneDir, provider, //
				"Host localhost", //
				"HostName localhost", //
				"Port " + testPort, //
				"User " + TEST_USER, //
				"StrictHostKeyChecking accept-new", //
				"IdentityFile " + privateKey1.getAbsolutePath());
		if (getSessionFactory() instanceof JschConfigSessionFactory) {
			// JSch doesn't create new files.
			assertTrue("CredentialsProvider not called",
					provider.getLog().isEmpty());
			return;
		}
		assertEquals("Expected to be asked about the file creation", 1,
				provider.getLog().size());
		assertTrue("~/.ssh/known_hosts should exist now", knownHosts.exists());
		// Instead of checking the file contents, let's just clone again
		// without provider. If it works, the server host key was written
		// correctly.
		File clonedAgain = new File(getTemporaryDirectory(), "cloned2");
		cloneWith("ssh://localhost/doesntmatter", clonedAgain, null, //
				"Host localhost", //
				"HostName localhost", //
				"Port " + testPort, //
				"User " + TEST_USER, //
				"IdentityFile " + privateKey1.getAbsolutePath());
	}

	@Test(expected = TransportException.class)
	public void testSshWithoutKnownHostsDeny() throws Exception {
		File copiedHosts = new File(knownHosts.getParentFile(),
				"copiedKnownHosts");
		assertTrue("Failed to rename known_hosts",
				knownHosts.renameTo(copiedHosts));
		cloneWith("ssh://localhost/doesntmatter", defaultCloneDir, null, //
				"Host localhost", //
				"HostName localhost", //
				"Port " + testPort, //
				"User " + TEST_USER, //
				"StrictHostKeyChecking yes", //
				"IdentityFile " + privateKey1.getAbsolutePath());
	}

	@Test(expected = TransportException.class)
	public void testSshModifiedHostKeyDeny()
			throws Exception {
		File copiedHosts = new File(knownHosts.getParentFile(),
				"copiedKnownHosts");
		assertTrue("Failed to rename known_hosts",
				knownHosts.renameTo(copiedHosts));
		// Now produce a new known_hosts file containing some other key.
		createKnownHostsFile(knownHosts, "localhost", testPort, publicKey1);
		cloneWith("ssh://localhost/doesntmatter", defaultCloneDir, null, //
				"Host localhost", //
				"HostName localhost", //
				"Port " + testPort, //
				"User " + TEST_USER, //
				"StrictHostKeyChecking yes", //
				"IdentityFile " + privateKey1.getAbsolutePath());
	}

	@Test(expected = TransportException.class)
	public void testSshModifiedHostKeyWithProviderDeny() throws Exception {
		File copiedHosts = new File(knownHosts.getParentFile(),
				"copiedKnownHosts");
		assertTrue("Failed to rename known_hosts",
				knownHosts.renameTo(copiedHosts));
		// Now produce a new known_hosts file containing some other key.
		createKnownHostsFile(knownHosts, "localhost", testPort, publicKey1);
		TestCredentialsProvider provider = new TestCredentialsProvider();
		try {
			cloneWith("ssh://localhost/doesntmatter", defaultCloneDir, provider, //
					"Host localhost", //
					"HostName localhost", //
					"Port " + testPort, //
					"User " + TEST_USER, //
					"StrictHostKeyChecking yes", //
					"IdentityFile " + privateKey1.getAbsolutePath());
		} catch (Exception e) {
			assertEquals("Expected to be told about the modified key", 1,
					provider.getLog().size());
			assertTrue("Only messages expected", provider.getLog().stream()
					.flatMap(l -> l.getItems().stream()).allMatch(
							c -> c instanceof CredentialItem.InformationalMessage));
			throw e;
		}
	}

	private void checkKnownHostsModifiedHostKey(File backup, File newFile,
			String wrongKey) throws IOException {
		List<String> oldLines = Files.readAllLines(backup.toPath(), UTF_8);
		// Find the original entry. We should have that again in known_hosts.
		String oldKeyPart = null;
		for (String oldLine : oldLines) {
			if (oldLine.contains("[localhost]:")) {
				String[] parts = oldLine.split("\\s+");
				if (parts.length > 2) {
					oldKeyPart = parts[parts.length - 2] + ' '
							+ parts[parts.length - 1];
					break;
				}
			}
		}
		assertNotNull("Old key not found", oldKeyPart);
		List<String> newLines = Files.readAllLines(newFile.toPath(), UTF_8);
		assertFalse("Old host key still found in known_hosts file" + newFile,
				hasHostKey("localhost", testPort, wrongKey, newLines));
		assertTrue("New host key not found in known_hosts file" + newFile,
				hasHostKey("localhost", testPort, oldKeyPart, newLines));

	}

	@Test
	public void testSshModifiedHostKeyAllow() throws Exception {
		assertTrue("Failed to delete known_hosts", knownHosts.delete());
		createKnownHostsFile(knownHosts, "localhost", testPort, publicKey1);
		File backup = new File(getTemporaryDirectory(), "backupKnownHosts");
		Files.copy(knownHosts.toPath(), backup.toPath());
		cloneWith("ssh://localhost/doesntmatter", defaultCloneDir, null, //
				"Host localhost", //
				"HostName localhost", //
				"Port " + testPort, //
				"User " + TEST_USER, //
				"StrictHostKeyChecking no", //
				"IdentityFile " + privateKey1.getAbsolutePath());
		// File should not have been updated!
		String[] oldLines = Files
				.readAllLines(backup.toPath(), UTF_8)
				.toArray(new String[0]);
		String[] newLines = Files
				.readAllLines(knownHosts.toPath(), UTF_8)
				.toArray(new String[0]);
		assertArrayEquals("Known hosts file should not be modified", oldLines,
				newLines);
	}

	@Test
	public void testSshModifiedHostKeyAsk() throws Exception {
		File copiedHosts = new File(knownHosts.getParentFile(),
				"copiedKnownHosts");
		assertTrue("Failed to rename known_hosts",
				knownHosts.renameTo(copiedHosts));
		String wrongKeyPart = createKnownHostsFile(knownHosts, "localhost",
				testPort, publicKey1);
		TestCredentialsProvider provider = new TestCredentialsProvider();
		cloneWith("ssh://localhost/doesntmatter", defaultCloneDir, provider, //
				"Host localhost", //
				"HostName localhost", //
				"Port " + testPort, //
				"User " + TEST_USER, //
				"IdentityFile " + privateKey1.getAbsolutePath());
		checkKnownHostsModifiedHostKey(copiedHosts, knownHosts, wrongKeyPart);
		assertEquals("Expected to be asked about the modified key", 1,
				provider.getLog().size());
	}

	@Test
	public void testSshCloneWithConfigAndPush() throws Exception {
		pushTo(cloneWith("ssh://localhost/doesntmatter", defaultCloneDir, null, //
				"Host localhost", //
				"HostName localhost", //
				"Port " + testPort, //
				"User " + TEST_USER, //
				"IdentityFile " + privateKey1.getAbsolutePath()));
	}

	@Test
	public void testSftpWithConfig() throws Exception {
		cloneWith("sftp://localhost/.git", defaultCloneDir, null, //
				"Host localhost", //
				"HostName localhost", //
				"Port " + testPort, //
				"User " + TEST_USER, //
				"IdentityFile " + privateKey1.getAbsolutePath());
	}

	@Test
	public void testSftpCloneWithConfigAndPush() throws Exception {
		pushTo(cloneWith("sftp://localhost/.git", defaultCloneDir, null, //
				"Host localhost", //
				"HostName localhost", //
				"Port " + testPort, //
				"User " + TEST_USER, //
				"IdentityFile " + privateKey1.getAbsolutePath()));
	}

	@Test(expected = TransportException.class)
	public void testSshWithConfigWrongKey() throws Exception {
		cloneWith("ssh://localhost/doesntmatter", defaultCloneDir, null, //
				"Host localhost", //
				"HostName localhost", //
				"Port " + testPort, //
				"User " + TEST_USER, //
				"IdentityFile " + privateKey2.getAbsolutePath());
	}

	@Test
	public void testSshWithWrongUserNameInConfig() throws Exception {
		// Bug 526778
		cloneWith(
				"ssh://" + TEST_USER + "@localhost:" + testPort
						+ "/doesntmatter",
				defaultCloneDir, null, //
				"Host localhost", //
				"HostName localhost", //
				"User sombody_else", //
				"IdentityFile " + privateKey1.getAbsolutePath());
	}

	@Test
	public void testSshWithWrongPortInConfig() throws Exception {
		// Bug 526778
		cloneWith(
				"ssh://" + TEST_USER + "@localhost:" + testPort
						+ "/doesntmatter",
				defaultCloneDir, null, //
				"Host localhost", //
				"HostName localhost", //
				"Port 22", //
				"User " + TEST_USER, //
				"IdentityFile " + privateKey1.getAbsolutePath());
	}

	@Test
	public void testSshWithAliasInConfig() throws Exception {
		// Bug 531118
		cloneWith("ssh://git/doesntmatter", defaultCloneDir, null, //
				"Host git", //
				"HostName localhost", //
				"Port " + testPort, //
				"User " + TEST_USER, //
				"IdentityFile " + privateKey1.getAbsolutePath(), "", //
				"Host localhost", //
				"HostName localhost", //
				"Port 22", //
				"User someone_else", //
				"IdentityFile " + privateKey2.getAbsolutePath());
	}

	@Test
	public void testSshWithUnknownCiphersInConfig() throws Exception {
		// Bug 535672
		cloneWith("ssh://git/doesntmatter", defaultCloneDir, null, //
				"Host git", //
				"HostName localhost", //
				"Port " + testPort, //
				"User " + TEST_USER, //
				"IdentityFile " + privateKey1.getAbsolutePath(), //
				"Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com,aes256-ctr,aes192-ctr,aes128-ctr");
	}

	@Test
	public void testSshWithUnknownHostKeyAlgorithmsInConfig()
			throws Exception {
		// Bug 535672
		cloneWith("ssh://git/doesntmatter", defaultCloneDir, null, //
				"Host git", //
				"HostName localhost", //
				"Port " + testPort, //
				"User " + TEST_USER, //
				"IdentityFile " + privateKey1.getAbsolutePath(), //
				"HostKeyAlgorithms foobar,ssh-rsa,ssh-dss");
	}

	@Test
	public void testSshWithUnknownKexAlgorithmsInConfig()
			throws Exception {
		// Bug 535672
		cloneWith("ssh://git/doesntmatter", defaultCloneDir, null, //
				"Host git", //
				"HostName localhost", //
				"Port " + testPort, //
				"User " + TEST_USER, //
				"IdentityFile " + privateKey1.getAbsolutePath(), //
				"KexAlgorithms foobar,diffie-hellman-group14-sha1,ecdh-sha2-nistp256,ecdh-sha2-nistp384,ecdh-sha2-nistp521");
	}

	@Test
	public void testSshWithMinimalHostKeyAlgorithmsInConfig()
			throws Exception {
		// Bug 537790
		cloneWith("ssh://git/doesntmatter", defaultCloneDir, null, //
				"Host git", //
				"HostName localhost", //
				"Port " + testPort, //
				"User " + TEST_USER, //
				"IdentityFile " + privateKey1.getAbsolutePath(), //
				"HostKeyAlgorithms ssh-rsa,ssh-dss");
	}

	@Test
	public void testSshWithUnknownAuthInConfig() throws Exception {
		cloneWith("ssh://git/doesntmatter", defaultCloneDir, null, //
				"Host git", //
				"HostName localhost", //
				"Port " + testPort, //
				"User " + TEST_USER, //
				"IdentityFile " + privateKey1.getAbsolutePath(), //
				"PreferredAuthentications gssapi-with-mic,hostbased,publickey,keyboard-interactive,password");
	}

	@Test(expected = TransportException.class)
	public void testSshWithNoMatchingAuthInConfig() throws Exception {
		// Server doesn't do password, and anyway we set no password.
		cloneWith("ssh://git/doesntmatter", defaultCloneDir, null, //
				"Host git", //
				"HostName localhost", //
				"Port " + testPort, //
				"User " + TEST_USER, //
				"IdentityFile " + privateKey1.getAbsolutePath(), //
				"PreferredAuthentications password");
	}

	@Test
	public void testRsaHostKeySecond() throws Exception {
		// See https://git.eclipse.org/r/#/c/130402/ : server has EcDSA
		// (preferred), RSA, we have RSA in known_hosts: client and server
		// should agree on RSA.
		File newHostKey = new File(getTemporaryDirectory(), "newhostkey");
		copyTestResource("id_ecdsa_256", newHostKey);
		server.addHostKey(newHostKey.toPath(), true);
		cloneWith("ssh://git/doesntmatter", defaultCloneDir, null, //
				"Host git", //
				"HostName localhost", //
				"Port " + testPort, //
				"User " + TEST_USER, //
				"IdentityFile " + privateKey1.getAbsolutePath());
	}

	@Test
	public void testEcDsaHostKey() throws Exception {
		// See https://git.eclipse.org/r/#/c/130402/ : server has RSA
		// (preferred), EcDSA, we have EcDSA in known_hosts: client and server
		// should agree on EcDSA.
		File newHostKey = new File(getTemporaryDirectory(), "newhostkey");
		copyTestResource("id_ecdsa_256", newHostKey);
		server.addHostKey(newHostKey.toPath(), false);
		File newHostKeyPub = new File(getTemporaryDirectory(),
				"newhostkey.pub");
		copyTestResource("id_ecdsa_256.pub", newHostKeyPub);
		createKnownHostsFile(knownHosts, "localhost", testPort, newHostKeyPub);
		cloneWith("ssh://git/doesntmatter", defaultCloneDir, null, //
				"Host git", //
				"HostName localhost", //
				"Port " + testPort, //
				"User " + TEST_USER, //
				"IdentityFile " + privateKey1.getAbsolutePath());
	}

	@Test
	public void testPasswordAuth() throws Exception {
		server.enablePasswordAuthentication();
		TestCredentialsProvider provider = new TestCredentialsProvider(
				TEST_USER.toUpperCase(Locale.ROOT));
		cloneWith("ssh://git/doesntmatter", defaultCloneDir, provider, //
				"Host git", //
				"HostName localhost", //
				"Port " + testPort, //
				"User " + TEST_USER, //
				"PreferredAuthentications password");
	}

	@Test
	public void testPasswordAuthSeveralTimes() throws Exception {
		server.enablePasswordAuthentication();
		TestCredentialsProvider provider = new TestCredentialsProvider(
				"wrongpass", "wrongpass", TEST_USER.toUpperCase(Locale.ROOT));
		cloneWith("ssh://git/doesntmatter", defaultCloneDir, provider, //
				"Host git", //
				"HostName localhost", //
				"Port " + testPort, //
				"User " + TEST_USER, //
				"PreferredAuthentications password");
	}

	@Test(expected = TransportException.class)
	public void testPasswordAuthWrongPassword() throws Exception {
		server.enablePasswordAuthentication();
		TestCredentialsProvider provider = new TestCredentialsProvider(
				"wrongpass");
		cloneWith("ssh://git/doesntmatter", defaultCloneDir, provider, //
				"Host git", //
				"HostName localhost", //
				"Port " + testPort, //
				"User " + TEST_USER, //
				"PreferredAuthentications password");
	}

	@Test(expected = TransportException.class)
	public void testPasswordAuthNoPassword() throws Exception {
		server.enablePasswordAuthentication();
		TestCredentialsProvider provider = new TestCredentialsProvider();
		cloneWith("ssh://git/doesntmatter", defaultCloneDir, provider, //
				"Host git", //
				"HostName localhost", //
				"Port " + testPort, //
				"User " + TEST_USER, //
				"PreferredAuthentications password");
	}

	@Test(expected = TransportException.class)
	public void testPasswordAuthCorrectPasswordTooLate() throws Exception {
		server.enablePasswordAuthentication();
		TestCredentialsProvider provider = new TestCredentialsProvider(
				"wrongpass", "wrongpass", "wrongpass",
				TEST_USER.toUpperCase(Locale.ROOT));
		cloneWith("ssh://git/doesntmatter", defaultCloneDir, provider, //
				"Host git", //
				"HostName localhost", //
				"Port " + testPort, //
				"User " + TEST_USER, //
				"PreferredAuthentications password");
	}

	@Test
	public void testKeyboardInteractiveAuth() throws Exception {
		server.enableKeyboardInteractiveAuthentication();
		TestCredentialsProvider provider = new TestCredentialsProvider(
				TEST_USER.toUpperCase(Locale.ROOT));
		cloneWith("ssh://git/doesntmatter", defaultCloneDir, provider, //
				"Host git", //
				"HostName localhost", //
				"Port " + testPort, //
				"User " + TEST_USER, //
				"PreferredAuthentications keyboard-interactive");
	}

	@Test
	public void testKeyboardInteractiveAuthSeveralTimes() throws Exception {
		server.enableKeyboardInteractiveAuthentication();
		TestCredentialsProvider provider = new TestCredentialsProvider(
				"wrongpass", "wrongpass", TEST_USER.toUpperCase(Locale.ROOT));
		cloneWith("ssh://git/doesntmatter", defaultCloneDir, provider, //
				"Host git", //
				"HostName localhost", //
				"Port " + testPort, //
				"User " + TEST_USER, //
				"PreferredAuthentications keyboard-interactive");
	}

	@Test(expected = TransportException.class)
	public void testKeyboardInteractiveAuthWrongPassword() throws Exception {
		server.enableKeyboardInteractiveAuthentication();
		TestCredentialsProvider provider = new TestCredentialsProvider(
				"wrongpass");
		cloneWith("ssh://git/doesntmatter", defaultCloneDir, provider, //
				"Host git", //
				"HostName localhost", //
				"Port " + testPort, //
				"User " + TEST_USER, //
				"PreferredAuthentications keyboard-interactive");
	}

	@Test(expected = TransportException.class)
	public void testKeyboardInteractiveAuthNoPassword() throws Exception {
		server.enableKeyboardInteractiveAuthentication();
		TestCredentialsProvider provider = new TestCredentialsProvider();
		cloneWith("ssh://git/doesntmatter", defaultCloneDir, provider, //
				"Host git", //
				"HostName localhost", //
				"Port " + testPort, //
				"User " + TEST_USER, //
				"PreferredAuthentications keyboard-interactive");
	}

	@Test(expected = TransportException.class)
	public void testKeyboardInteractiveAuthCorrectPasswordTooLate()
			throws Exception {
		server.enableKeyboardInteractiveAuthentication();
		TestCredentialsProvider provider = new TestCredentialsProvider(
				"wrongpass", "wrongpass", "wrongpass",
				TEST_USER.toUpperCase(Locale.ROOT));
		cloneWith("ssh://git/doesntmatter", defaultCloneDir, provider, //
				"Host git", //
				"HostName localhost", //
				"Port " + testPort, //
				"User " + TEST_USER, //
				"PreferredAuthentications keyboard-interactive");
	}

	@Theory
	public void testSshKeys(String keyName) throws Exception {
		// JSch fails on ECDSA 384/521 keys. Compare
		// https://sourceforge.net/p/jsch/patches/10/
		assumeTrue(!(getSessionFactory() instanceof JschConfigSessionFactory
				&& (keyName.contains("ed25519")
						|| keyName.startsWith("id_ecdsa_384")
						|| keyName.startsWith("id_ecdsa_521"))));
		File cloned = new File(getTemporaryDirectory(), "cloned");
		String keyFileName = keyName + "_key";
		File privateKey = new File(sshDir, keyFileName);
		copyTestResource(keyName, privateKey);
		File publicKey = new File(sshDir, keyFileName + ".pub");
		copyTestResource(keyName + ".pub", publicKey);
		server.setTestUserPublicKey(publicKey.toPath());
		TestCredentialsProvider provider = new TestCredentialsProvider(
				"testpass");
		pushTo(provider,
				cloneWith("ssh://localhost/doesntmatter", //
						cloned, provider, //
						"Host localhost", //
						"HostName localhost", //
						"Port " + testPort, //
						"User " + TEST_USER, //
						"IdentityFile " + privateKey.getAbsolutePath()));
		int expectedCalls = keyName.endsWith("testpass") ? 1 : 0;
		assertEquals("Unexpected calls to CredentialsProvider", expectedCalls,
				provider.getLog().size());
		// Should now also work without credentials provider, even if the key
		// was encrypted.
		cloned = new File(getTemporaryDirectory(), "cloned2");
		pushTo(null,
				cloneWith("ssh://localhost/doesntmatter", //
						cloned, null, //
						"Host localhost", //
						"HostName localhost", //
						"Port " + testPort, //
						"User " + TEST_USER, //
						"IdentityFile " + privateKey.getAbsolutePath()));
	}
}