Merge "Measure time taken for negotiation in protocol V2"
diff --git a/org.eclipse.jgit.junit.ssh/src/org/eclipse/jgit/junit/ssh/SshTestHarness.java b/org.eclipse.jgit.junit.ssh/src/org/eclipse/jgit/junit/ssh/SshTestHarness.java
index 7970685..90d981b 100644
--- a/org.eclipse.jgit.junit.ssh/src/org/eclipse/jgit/junit/ssh/SshTestHarness.java
+++ b/org.eclipse.jgit.junit.ssh/src/org/eclipse/jgit/junit/ssh/SshTestHarness.java
@@ -76,6 +76,8 @@
 
 	protected File publicKey1;
 
+	protected File publicKey2;
+
 	protected SshTestGitServer server;
 
 	private SshSessionFactory factory;
@@ -110,7 +112,7 @@
 		privateKey1 = new File(sshDir, "first_key");
 		privateKey2 = new File(sshDir, "second_key");
 		publicKey1 = createKeyPair(generator.generateKeyPair(), privateKey1);
-		createKeyPair(generator.generateKeyPair(), privateKey2);
+		publicKey2 = createKeyPair(generator.generateKeyPair(), privateKey2);
 		// Create a host key
 		KeyPair hostKey = generator.generateKeyPair();
 		// Start a server with our test user and the first key.
diff --git a/org.eclipse.jgit.ssh.apache.test/META-INF/MANIFEST.MF b/org.eclipse.jgit.ssh.apache.test/META-INF/MANIFEST.MF
index 47f0069..60f7d41 100644
--- a/org.eclipse.jgit.ssh.apache.test/META-INF/MANIFEST.MF
+++ b/org.eclipse.jgit.ssh.apache.test/META-INF/MANIFEST.MF
@@ -11,11 +11,13 @@
  org.apache.sshd.common;version="[2.4.0,2.5.0)",
  org.apache.sshd.common.auth;version="[2.4.0,2.5.0)",
  org.apache.sshd.common.config.keys;version="[2.4.0,2.5.0)",
+ org.apache.sshd.common.helpers;version="[2.4.0,2.5.0)",
  org.apache.sshd.common.keyprovider;version="[2.4.0,2.5.0)",
  org.apache.sshd.common.session;version="[2.4.0,2.5.0)",
  org.apache.sshd.common.util.net;version="[2.4.0,2.5.0)",
  org.apache.sshd.common.util.security;version="[2.4.0,2.5.0)",
  org.apache.sshd.server;version="[2.4.0,2.5.0)",
+ org.apache.sshd.server.forward;version="[2.4.0,2.5.0)",
  org.eclipse.jgit.api;version="[5.10.0,5.11.0)",
  org.eclipse.jgit.api.errors;version="[5.10.0,5.11.0)",
  org.eclipse.jgit.internal.transport.sshd.proxy;version="[5.10.0,5.11.0)",
diff --git a/org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/transport/sshd/ApacheSshTest.java b/org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/transport/sshd/ApacheSshTest.java
index 651ae7d..3427da6 100644
--- a/org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/transport/sshd/ApacheSshTest.java
+++ b/org.eclipse.jgit.ssh.apache.test/tst/org/eclipse/jgit/transport/sshd/ApacheSshTest.java
@@ -1,5 +1,5 @@
 /*
- * Copyright (C) 2018, Thomas Wolf <thomas.wolf@paranor.ch> and others
+ * Copyright (C) 2018, 2020 Thomas Wolf <thomas.wolf@paranor.ch> and others
  *
  * This program and the accompanying materials are made available under the
  * terms of the Eclipse Distribution License v. 1.0 which is available at
@@ -11,19 +11,39 @@
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
 import static org.junit.Assert.assertThrows;
 import static org.junit.Assert.assertTrue;
 
+import java.io.BufferedWriter;
 import java.io.File;
 import java.io.IOException;
 import java.io.UncheckedIOException;
+import java.net.URISyntaxException;
+import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
+import java.nio.file.StandardOpenOption;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.PublicKey;
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.List;
 import java.util.stream.Collectors;
+
 import org.apache.sshd.client.config.hosts.KnownHostEntry;
+import org.apache.sshd.client.config.hosts.KnownHostHashValue;
 import org.apache.sshd.common.PropertyResolverUtils;
+import org.apache.sshd.common.config.keys.AuthorizedKeyEntry;
+import org.apache.sshd.common.config.keys.KeyUtils;
+import org.apache.sshd.common.config.keys.PublicKeyEntry;
+import org.apache.sshd.common.config.keys.PublicKeyEntryResolver;
+import org.apache.sshd.common.session.Session;
+import org.apache.sshd.common.util.net.SshdSocketAddress;
+import org.apache.sshd.server.ServerAuthenticationManager;
 import org.apache.sshd.server.ServerFactoryManager;
+import org.apache.sshd.server.SshServer;
+import org.apache.sshd.server.forward.StaticDecisionForwardingFilter;
 import org.eclipse.jgit.api.Git;
 import org.eclipse.jgit.api.errors.TransportException;
 import org.eclipse.jgit.junit.ssh.SshTestBase;
@@ -211,4 +231,380 @@
 			git.fetch().call();
 		}
 	}
+
+	/**
+	 * Creates a simple proxy server. Accepts only publickey authentication from
+	 * the given user with the given key, allows all forwardings. Adds the
+	 * proxy's host key to {@link #knownHosts}.
+	 *
+	 * @param user
+	 *            to accept
+	 * @param userKey
+	 *            public key of that user at this server
+	 * @param report
+	 *            single-element array to report back the forwarded address.
+	 * @return the started server
+	 * @throws Exception
+	 */
+	private SshServer createProxy(String user, File userKey,
+			SshdSocketAddress[] report) throws Exception {
+		SshServer proxy = SshServer.setUpDefaultServer();
+		// Give the server its own host key
+		KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
+		generator.initialize(2048);
+		KeyPair proxyHostKey = generator.generateKeyPair();
+		proxy.setKeyPairProvider(
+				session -> Collections.singletonList(proxyHostKey));
+		// Allow (only) publickey authentication
+		proxy.setUserAuthFactories(Collections.singletonList(
+				ServerAuthenticationManager.DEFAULT_USER_AUTH_PUBLIC_KEY_FACTORY));
+		// Install the user's public key
+		PublicKey userProxyKey = AuthorizedKeyEntry
+				.readAuthorizedKeys(userKey.toPath()).get(0)
+				.resolvePublicKey(null, PublicKeyEntryResolver.IGNORING);
+		proxy.setPublickeyAuthenticator(
+				(userName, publicKey, session) -> user.equals(userName)
+						&& KeyUtils.compareKeys(userProxyKey, publicKey));
+		// Allow forwarding
+		proxy.setForwardingFilter(new StaticDecisionForwardingFilter(true) {
+
+			@Override
+			protected boolean checkAcceptance(String request, Session session,
+					SshdSocketAddress target) {
+				report[0] = target;
+				return super.checkAcceptance(request, session, target);
+			}
+		});
+		proxy.start();
+		// Add the proxy's host key to knownhosts
+		try (BufferedWriter writer = Files.newBufferedWriter(
+				knownHosts.toPath(), StandardCharsets.US_ASCII,
+				StandardOpenOption.WRITE, StandardOpenOption.APPEND)) {
+			writer.append('\n');
+			KnownHostHashValue.appendHostPattern(writer, "localhost",
+					proxy.getPort());
+			writer.append(',');
+			KnownHostHashValue.appendHostPattern(writer, "127.0.0.1",
+					proxy.getPort());
+			writer.append(' ');
+			PublicKeyEntry.appendPublicKeyEntry(writer,
+					proxyHostKey.getPublic());
+			writer.append('\n');
+		}
+		return proxy;
+	}
+
+	@Test
+	public void testJumpHost() throws Exception {
+		SshdSocketAddress[] forwarded = { null };
+		try (SshServer proxy = createProxy(TEST_USER + 'X', publicKey2,
+				forwarded)) {
+			try {
+				// Now try to clone via the proxy
+				cloneWith("ssh://server/doesntmatter", defaultCloneDir, null, //
+						"Host server", //
+						"HostName localhost", //
+						"Port " + testPort, //
+						"User " + TEST_USER, //
+						"IdentityFile " + privateKey1.getAbsolutePath(), //
+						"ProxyJump " + TEST_USER + "X@proxy:" + proxy.getPort(), //
+						"", //
+						"Host proxy", //
+						"Hostname localhost", //
+						"IdentityFile " + privateKey2.getAbsolutePath());
+				assertNotNull(forwarded[0]);
+				assertEquals(testPort, forwarded[0].getPort());
+			} finally {
+				proxy.stop();
+			}
+		}
+	}
+
+	@Test
+	public void testJumpHostWrongKeyAtProxy() throws Exception {
+		// Test that we find the proxy server's URI in the exception message
+		SshdSocketAddress[] forwarded = { null };
+		try (SshServer proxy = createProxy(TEST_USER + 'X', publicKey2,
+				forwarded)) {
+			try {
+				// Now try to clone via the proxy
+				TransportException e = assertThrows(TransportException.class,
+						() -> cloneWith("ssh://server/doesntmatter",
+								defaultCloneDir, null, //
+								"Host server", //
+								"HostName localhost", //
+								"Port " + testPort, //
+								"User " + TEST_USER, //
+								"IdentityFile " + privateKey1.getAbsolutePath(),
+								"ProxyJump " + TEST_USER + "X@proxy:"
+										+ proxy.getPort(), //
+								"", //
+								"Host proxy", //
+								"Hostname localhost", //
+								"IdentityFile "
+										+ privateKey1.getAbsolutePath()));
+				String message = e.getMessage();
+				assertTrue(message.contains("localhost:" + proxy.getPort()));
+				assertTrue(message.contains("proxy:" + proxy.getPort()));
+			} finally {
+				proxy.stop();
+			}
+		}
+	}
+
+	@Test
+	public void testJumpHostWrongKeyAtServer() throws Exception {
+		// Test that we find the target server's URI in the exception message
+		SshdSocketAddress[] forwarded = { null };
+		try (SshServer proxy = createProxy(TEST_USER + 'X', publicKey2,
+				forwarded)) {
+			try {
+				// Now try to clone via the proxy
+				TransportException e = assertThrows(TransportException.class,
+						() -> cloneWith("ssh://server/doesntmatter",
+								defaultCloneDir, null, //
+								"Host server", //
+								"HostName localhost", //
+								"Port " + testPort, //
+								"User " + TEST_USER, //
+								"IdentityFile " + privateKey2.getAbsolutePath(),
+								"ProxyJump " + TEST_USER + "X@proxy:"
+										+ proxy.getPort(), //
+								"", //
+								"Host proxy", //
+								"Hostname localhost", //
+								"IdentityFile "
+										+ privateKey2.getAbsolutePath()));
+				String message = e.getMessage();
+				assertTrue(message.contains("localhost:" + testPort));
+				assertTrue(message.contains("ssh://server"));
+			} finally {
+				proxy.stop();
+			}
+		}
+	}
+
+	@Test
+	public void testJumpHostNonSsh() throws Exception {
+		SshdSocketAddress[] forwarded = { null };
+		try (SshServer proxy = createProxy(TEST_USER + 'X', publicKey2,
+				forwarded)) {
+			try {
+				TransportException e = assertThrows(TransportException.class,
+						() -> cloneWith("ssh://server/doesntmatter",
+								defaultCloneDir, null, //
+								"Host server", //
+								"HostName localhost", //
+								"Port " + testPort, //
+								"User " + TEST_USER, //
+								"IdentityFile " + privateKey1.getAbsolutePath(), //
+								"ProxyJump http://" + TEST_USER + "X@proxy:"
+										+ proxy.getPort(), //
+								"", //
+								"Host proxy", //
+								"Hostname localhost", //
+								"IdentityFile "
+										+ privateKey2.getAbsolutePath()));
+				// Find the expected message
+				Throwable t = e;
+				while (t != null) {
+					if (t instanceof URISyntaxException) {
+						break;
+					}
+					t = t.getCause();
+				}
+				assertNotNull(t);
+				assertTrue(t.getMessage().contains("Non-ssh"));
+			} finally {
+				proxy.stop();
+			}
+		}
+	}
+
+	@Test
+	public void testJumpHostWithPath() throws Exception {
+		SshdSocketAddress[] forwarded = { null };
+		try (SshServer proxy = createProxy(TEST_USER + 'X', publicKey2,
+				forwarded)) {
+			try {
+				TransportException e = assertThrows(TransportException.class,
+						() -> cloneWith("ssh://server/doesntmatter",
+								defaultCloneDir, null, //
+								"Host server", //
+								"HostName localhost", //
+								"Port " + testPort, //
+								"User " + TEST_USER, //
+								"IdentityFile " + privateKey1.getAbsolutePath(), //
+								"ProxyJump ssh://" + TEST_USER + "X@proxy:"
+										+ proxy.getPort() + "/wrongPath", //
+								"", //
+								"Host proxy", //
+								"Hostname localhost", //
+								"IdentityFile "
+										+ privateKey2.getAbsolutePath()));
+				// Find the expected message
+				Throwable t = e;
+				while (t != null) {
+					if (t instanceof URISyntaxException) {
+						break;
+					}
+					t = t.getCause();
+				}
+				assertNotNull(t);
+				assertTrue(t.getMessage().contains("wrongPath"));
+			} finally {
+				proxy.stop();
+			}
+		}
+	}
+
+	@Test
+	public void testJumpHostWithPathShort() throws Exception {
+		SshdSocketAddress[] forwarded = { null };
+		try (SshServer proxy = createProxy(TEST_USER + 'X', publicKey2,
+				forwarded)) {
+			try {
+				TransportException e = assertThrows(TransportException.class,
+						() -> cloneWith("ssh://server/doesntmatter",
+								defaultCloneDir, null, //
+								"Host server", //
+								"HostName localhost", //
+								"Port " + testPort, //
+								"User " + TEST_USER, //
+								"IdentityFile " + privateKey1.getAbsolutePath(), //
+								"ProxyJump " + TEST_USER + "X@proxy:wrongPath", //
+								"", //
+								"Host proxy", //
+								"Hostname localhost", //
+								"Port " + proxy.getPort(), //
+								"IdentityFile "
+										+ privateKey2.getAbsolutePath()));
+				// Find the expected message
+				Throwable t = e;
+				while (t != null) {
+					if (t instanceof URISyntaxException) {
+						break;
+					}
+					t = t.getCause();
+				}
+				assertNotNull(t);
+				assertTrue(t.getMessage().contains("wrongPath"));
+			} finally {
+				proxy.stop();
+			}
+		}
+	}
+
+	@Test
+	public void testJumpHostChain() throws Exception {
+		SshdSocketAddress[] forwarded1 = { null };
+		SshdSocketAddress[] forwarded2 = { null };
+		try (SshServer proxy1 = createProxy(TEST_USER + 'X', publicKey2,
+				forwarded1);
+				SshServer proxy2 = createProxy("foo", publicKey1, forwarded2)) {
+			try {
+				// Clone proxy1 -> proxy2 -> server
+				cloneWith("ssh://server/doesntmatter", defaultCloneDir, null, //
+						"Host server", //
+						"HostName localhost", //
+						"Port " + testPort, //
+						"User " + TEST_USER, //
+						"IdentityFile " + privateKey1.getAbsolutePath(), //
+						"ProxyJump proxy2," + TEST_USER + "X@proxy:"
+								+ proxy1.getPort(), //
+						"", //
+						"Host proxy", //
+						"Hostname localhost", //
+						"IdentityFile " + privateKey2.getAbsolutePath(), //
+						"", //
+						"Host proxy2", //
+						"Hostname localhost", //
+						"User foo", //
+						"Port " + proxy2.getPort(), //
+						"IdentityFile " + privateKey1.getAbsolutePath());
+				assertNotNull(forwarded1[0]);
+				assertEquals(proxy2.getPort(), forwarded1[0].getPort());
+				assertNotNull(forwarded2[0]);
+				assertEquals(testPort, forwarded2[0].getPort());
+			} finally {
+				proxy1.stop();
+				proxy2.stop();
+			}
+		}
+	}
+
+	@Test
+	public void testJumpHostCascade() throws Exception {
+		SshdSocketAddress[] forwarded1 = { null };
+		SshdSocketAddress[] forwarded2 = { null };
+		try (SshServer proxy1 = createProxy(TEST_USER + 'X', publicKey2,
+				forwarded1);
+				SshServer proxy2 = createProxy("foo", publicKey1, forwarded2)) {
+			try {
+				// Clone proxy2 -> proxy1 -> server
+				cloneWith("ssh://server/doesntmatter", defaultCloneDir, null, //
+						"Host server", //
+						"HostName localhost", //
+						"Port " + testPort, //
+						"User " + TEST_USER, //
+						"IdentityFile " + privateKey1.getAbsolutePath(), //
+						"ProxyJump " + TEST_USER + "X@proxy", //
+						"", //
+						"Host proxy", //
+						"Hostname localhost", //
+						"Port " + proxy1.getPort(), //
+						"ProxyJump ssh://proxy2:" + proxy2.getPort(), //
+						"IdentityFile " + privateKey2.getAbsolutePath(), //
+						"", //
+						"Host proxy2", //
+						"Hostname localhost", //
+						"User foo", //
+						"IdentityFile " + privateKey1.getAbsolutePath());
+				assertNotNull(forwarded1[0]);
+				assertEquals(testPort, forwarded1[0].getPort());
+				assertNotNull(forwarded2[0]);
+				assertEquals(proxy1.getPort(), forwarded2[0].getPort());
+			} finally {
+				proxy1.stop();
+				proxy2.stop();
+			}
+		}
+	}
+
+	@Test
+	public void testJumpHostRecursion() throws Exception {
+		SshdSocketAddress[] forwarded1 = { null };
+		SshdSocketAddress[] forwarded2 = { null };
+		try (SshServer proxy1 = createProxy(TEST_USER + 'X', publicKey2,
+				forwarded1);
+				SshServer proxy2 = createProxy("foo", publicKey1, forwarded2)) {
+			try {
+				TransportException e = assertThrows(TransportException.class,
+						() -> cloneWith(
+						"ssh://server/doesntmatter", defaultCloneDir, null, //
+						"Host server", //
+						"HostName localhost", //
+						"Port " + testPort, //
+						"User " + TEST_USER, //
+						"IdentityFile " + privateKey1.getAbsolutePath(), //
+						"ProxyJump " + TEST_USER + "X@proxy", //
+						"", //
+						"Host proxy", //
+						"Hostname localhost", //
+						"Port " + proxy1.getPort(), //
+						"ProxyJump ssh://proxy2:" + proxy2.getPort(), //
+						"IdentityFile " + privateKey2.getAbsolutePath(), //
+						"", //
+						"Host proxy2", //
+						"Hostname localhost", //
+						"User foo", //
+						"ProxyJump " + TEST_USER + "X@proxy", //
+						"IdentityFile " + privateKey1.getAbsolutePath()));
+				assertTrue(e.getMessage().contains("proxy"));
+			} finally {
+				proxy1.stop();
+				proxy2.stop();
+			}
+		}
+	}
 }
diff --git a/org.eclipse.jgit.ssh.apache/META-INF/MANIFEST.MF b/org.eclipse.jgit.ssh.apache/META-INF/MANIFEST.MF
index e6ccbec..c5c64fc 100644
--- a/org.eclipse.jgit.ssh.apache/META-INF/MANIFEST.MF
+++ b/org.eclipse.jgit.ssh.apache/META-INF/MANIFEST.MF
@@ -45,6 +45,7 @@
  org.apache.sshd.client.future;version="[2.4.0,2.5.0)",
  org.apache.sshd.client.keyverifier;version="[2.4.0,2.5.0)",
  org.apache.sshd.client.session;version="[2.4.0,2.5.0)",
+ org.apache.sshd.client.session.forward;version="[2.4.0,2.5.0)",
  org.apache.sshd.client.subsystem.sftp;version="[2.4.0,2.5.0)",
  org.apache.sshd.common;version="[2.4.0,2.5.0)",
  org.apache.sshd.common.auth;version="[2.4.0,2.5.0)",
diff --git a/org.eclipse.jgit.ssh.apache/resources/org/eclipse/jgit/internal/transport/sshd/SshdText.properties b/org.eclipse.jgit.ssh.apache/resources/org/eclipse/jgit/internal/transport/sshd/SshdText.properties
index b89bc60..504e600 100644
--- a/org.eclipse.jgit.ssh.apache/resources/org/eclipse/jgit/internal/transport/sshd/SshdText.properties
+++ b/org.eclipse.jgit.ssh.apache/resources/org/eclipse/jgit/internal/transport/sshd/SshdText.properties
@@ -4,8 +4,11 @@
 configInvalidPath=Invalid path in ssh config key {0}: {1}
 configInvalidPattern=Invalid pattern in ssh config key {0}: {1}
 configInvalidPositive=Ssh config entry {0} must be a strictly positive number but is ''{1}''
+configInvalidProxyJump=Ssh config, host ''{0}'': Cannot parse ProxyJump ''{1}''
 configNoKnownHostKeyAlgorithms=No implementations for any of the algorithms ''{0}'' given in HostKeyAlgorithms in the ssh config; using the default.
 configNoRemainingHostKeyAlgorithms=Ssh config removed all host key algorithms: HostKeyAlgorithms ''{0}''
+configProxyJumpNotSsh=Non-ssh URI in ProxyJump ssh config
+configProxyJumpWithPath=ProxyJump ssh config: jump host specification must not have a path
 ftpCloseFailed=Closing the SFTP channel failed
 gssapiFailure=GSS-API error for mechanism OID {0}
 gssapiInitFailure=GSS-API initialization failure for mechanism {0}
@@ -46,12 +49,14 @@
 knownHostsUnknownKeyType=Cannot read server key from known hosts file {0}; line {1}
 knownHostsUserAskCreationMsg=File {0} does not exist.
 knownHostsUserAskCreationPrompt=Create file {0} ?
+loginDenied=Log-in denied at {0}:{1}
 passwordPrompt=Password
 proxyCannotAuthenticate=Cannot authenticate to proxy {0}
 proxyHttpFailure=HTTP Proxy connection to {0} failed with code {1}: {2}
 proxyHttpInvalidUserName=HTTP proxy connection {0} with invalid user name; must not contain colons: {1}
 proxyHttpUnexpectedReply=Unexpected HTTP proxy response from {0}: {1}
 proxyHttpUnspecifiedFailureReason=unspecified reason
+proxyJumpAbort=ProxyJump chain too long at {0}
 proxyPasswordPrompt=Proxy password
 proxySocksAuthenticationFailed=Authentication to SOCKS5 proxy {0} failed
 proxySocksFailureForbidden=SOCKS5 proxy {0}: connection to {1} not allowed by ruleset
@@ -80,4 +85,5 @@
 sshClosingDown=Apache MINA sshd session factory is closing down; cannot create new ssh sessions on this factory
 sshCommandTimeout={0} timed out after {1} seconds while opening the channel
 sshProcessStillRunning={0} is not yet completed, cannot get exit code
+sshProxySessionCloseFailed=Error while closing proxy session {0}
 unknownProxyProtocol=Ignoring unknown proxy protocol {0}
\ No newline at end of file
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitSshClient.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitSshClient.java
index 1825fb3..beaaeca 100644
--- a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitSshClient.java
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/JGitSshClient.java
@@ -49,6 +49,7 @@
 import org.apache.sshd.common.session.SessionContext;
 import org.apache.sshd.common.session.helpers.AbstractSession;
 import org.apache.sshd.common.util.ValidateUtils;
+import org.apache.sshd.common.util.net.SshdSocketAddress;
 import org.eclipse.jgit.internal.transport.sshd.JGitClientSession.ChainingAttributes;
 import org.eclipse.jgit.internal.transport.sshd.JGitClientSession.SessionAttributes;
 import org.eclipse.jgit.internal.transport.sshd.proxy.HttpClientConnector;
@@ -82,6 +83,16 @@
 	 */
 	public static final AttributeKey<String> PREFERRED_AUTHENTICATIONS = new AttributeKey<>();
 
+	/**
+	 * An attribute key for storing an alternate local address to connect to if
+	 * a local forward from a ProxyJump ssh config is present. If set,
+	 * {@link #connect(HostConfigEntry, AttributeRepository, SocketAddress)}
+	 * will not connect to the address obtained from the {@link HostConfigEntry}
+	 * but to the address stored in this key (which is assumed to forward the
+	 * {@code HostConfigEntry} address).
+	 */
+	public static final AttributeKey<SshdSocketAddress> LOCAL_FORWARD_ADDRESS = new AttributeKey<>();
+
 	private KeyCache keyCache;
 
 	private CredentialsProvider credentialsProvider;
@@ -102,25 +113,37 @@
 			throw new IllegalStateException("SshClient not started."); //$NON-NLS-1$
 		}
 		Objects.requireNonNull(hostConfig, "No host configuration"); //$NON-NLS-1$
-		String host = ValidateUtils.checkNotNullAndNotEmpty(
+		String originalHost = ValidateUtils.checkNotNullAndNotEmpty(
 				hostConfig.getHostName(), "No target host"); //$NON-NLS-1$
-		int port = hostConfig.getPort();
-		ValidateUtils.checkTrue(port > 0, "Invalid port: %d", port); //$NON-NLS-1$
+		int originalPort = hostConfig.getPort();
+		ValidateUtils.checkTrue(originalPort > 0, "Invalid port: %d", //$NON-NLS-1$
+				originalPort);
+		InetSocketAddress originalAddress = new InetSocketAddress(originalHost,
+				originalPort);
+		InetSocketAddress targetAddress = originalAddress;
 		String userName = hostConfig.getUsername();
+		String id = userName + '@' + originalAddress;
 		AttributeRepository attributes = chain(context, this);
-		InetSocketAddress address = new InetSocketAddress(host, port);
-		ConnectFuture connectFuture = new DefaultConnectFuture(
-				userName + '@' + address, null);
-		SshFutureListener<IoConnectFuture> listener = createConnectCompletionListener(
-				connectFuture, userName, address, hostConfig);
-		attributes = sessionAttributes(attributes, hostConfig, address);
-		// Proxy support
-		ProxyData proxy = getProxyData(address);
-		if (proxy != null) {
-			address = configureProxy(proxy, address);
-			proxy.clearPassword();
+		SshdSocketAddress localForward = attributes
+				.resolveAttribute(LOCAL_FORWARD_ADDRESS);
+		if (localForward != null) {
+			targetAddress = new InetSocketAddress(localForward.getHostName(),
+					localForward.getPort());
+			id += '/' + targetAddress.toString();
 		}
-		connector.connect(address, attributes, localAddress)
+		ConnectFuture connectFuture = new DefaultConnectFuture(id, null);
+		SshFutureListener<IoConnectFuture> listener = createConnectCompletionListener(
+				connectFuture, userName, originalAddress, hostConfig);
+		attributes = sessionAttributes(attributes, hostConfig, originalAddress);
+		// Proxy support
+		if (localForward == null) {
+			ProxyData proxy = getProxyData(targetAddress);
+			if (proxy != null) {
+				targetAddress = configureProxy(proxy, targetAddress);
+				proxy.clearPassword();
+			}
+		}
+		connector.connect(targetAddress, attributes, localAddress)
 				.addListener(listener);
 		return connectFuture;
 	}
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/SshdText.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/SshdText.java
index 22966f9..13bb3eb 100644
--- a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/SshdText.java
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/internal/transport/sshd/SshdText.java
@@ -24,8 +24,11 @@
 	/***/ public String configInvalidPath;
 	/***/ public String configInvalidPattern;
 	/***/ public String configInvalidPositive;
+	/***/ public String configInvalidProxyJump;
 	/***/ public String configNoKnownHostKeyAlgorithms;
 	/***/ public String configNoRemainingHostKeyAlgorithms;
+	/***/ public String configProxyJumpNotSsh;
+	/***/ public String configProxyJumpWithPath;
 	/***/ public String ftpCloseFailed;
 	/***/ public String gssapiFailure;
 	/***/ public String gssapiInitFailure;
@@ -58,12 +61,14 @@
 	/***/ public String knownHostsUnknownKeyType;
 	/***/ public String knownHostsUserAskCreationMsg;
 	/***/ public String knownHostsUserAskCreationPrompt;
+	/***/ public String loginDenied;
 	/***/ public String passwordPrompt;
 	/***/ public String proxyCannotAuthenticate;
 	/***/ public String proxyHttpFailure;
 	/***/ public String proxyHttpInvalidUserName;
 	/***/ public String proxyHttpUnexpectedReply;
 	/***/ public String proxyHttpUnspecifiedFailureReason;
+	/***/ public String proxyJumpAbort;
 	/***/ public String proxyPasswordPrompt;
 	/***/ public String proxySocksAuthenticationFailed;
 	/***/ public String proxySocksFailureForbidden;
@@ -92,6 +97,7 @@
 	/***/ public String sshClosingDown;
 	/***/ public String sshCommandTimeout;
 	/***/ public String sshProcessStillRunning;
+	/***/ public String sshProxySessionCloseFailed;
 	/***/ public String unknownProxyProtocol;
 
 }
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSession.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSession.java
index dfd7cca..0fb0610 100644
--- a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSession.java
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSession.java
@@ -1,5 +1,5 @@
 /*
- * Copyright (C) 2018, Thomas Wolf <thomas.wolf@paranor.ch> and others
+ * Copyright (C) 2018, 2020 Thomas Wolf <thomas.wolf@paranor.ch> and others
  *
  * This program and the accompanying materials are made available under the
  * terms of the Eclipse Distribution License v. 1.0 which is available at
@@ -10,36 +10,53 @@
 package org.eclipse.jgit.transport.sshd;
 
 import static java.text.MessageFormat.format;
+import static org.apache.sshd.common.SshConstants.SSH2_DISCONNECT_NO_MORE_AUTH_METHODS_AVAILABLE;
 
+import java.io.Closeable;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
+import java.net.URISyntaxException;
 import java.time.Duration;
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.EnumSet;
+import java.util.LinkedList;
 import java.util.List;
 import java.util.concurrent.CopyOnWriteArrayList;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicReference;
 import java.util.function.Supplier;
+import java.util.regex.Pattern;
 
 import org.apache.sshd.client.SshClient;
 import org.apache.sshd.client.channel.ChannelExec;
 import org.apache.sshd.client.channel.ClientChannelEvent;
+import org.apache.sshd.client.config.hosts.HostConfigEntry;
+import org.apache.sshd.client.future.ConnectFuture;
 import org.apache.sshd.client.session.ClientSession;
+import org.apache.sshd.client.session.forward.PortForwardingTracker;
 import org.apache.sshd.client.subsystem.sftp.SftpClient;
 import org.apache.sshd.client.subsystem.sftp.SftpClient.CloseableHandle;
 import org.apache.sshd.client.subsystem.sftp.SftpClient.CopyMode;
 import org.apache.sshd.client.subsystem.sftp.SftpClientFactory;
-import org.apache.sshd.common.session.Session;
-import org.apache.sshd.common.session.SessionListener;
+import org.apache.sshd.common.AttributeRepository;
+import org.apache.sshd.common.SshException;
+import org.apache.sshd.common.future.CloseFuture;
+import org.apache.sshd.common.future.SshFutureListener;
 import org.apache.sshd.common.subsystem.sftp.SftpException;
+import org.apache.sshd.common.util.io.IoUtils;
+import org.apache.sshd.common.util.net.SshdSocketAddress;
 import org.eclipse.jgit.annotations.NonNull;
+import org.eclipse.jgit.errors.TransportException;
+import org.eclipse.jgit.internal.transport.sshd.JGitSshClient;
 import org.eclipse.jgit.internal.transport.sshd.SshdText;
 import org.eclipse.jgit.transport.FtpChannel;
 import org.eclipse.jgit.transport.RemoteSession;
+import org.eclipse.jgit.transport.SshConstants;
 import org.eclipse.jgit.transport.URIish;
+import org.eclipse.jgit.util.StringUtils;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -53,6 +70,11 @@
 	private static final Logger LOG = LoggerFactory
 			.getLogger(SshdSession.class);
 
+	private static final Pattern SHORT_SSH_FORMAT = Pattern
+			.compile("[-\\w.]+(?:@[-\\w.]+)?(?::\\d+)?"); //$NON-NLS-1$
+
+	private static final int MAX_DEPTH = 10;
+
 	private final CopyOnWriteArrayList<SessionCloseListener> listeners = new CopyOnWriteArrayList<>();
 
 	private final URIish uri;
@@ -71,32 +93,169 @@
 			client.start();
 		}
 		try {
-			String username = uri.getUser();
-			String host = uri.getHost();
-			int port = uri.getPort();
-			long t = timeout.toMillis();
-			if (t <= 0) {
-				session = client.connect(username, host, port).verify()
-						.getSession();
-			} else {
-				session = client.connect(username, host, port)
-						.verify(timeout.toMillis()).getSession();
-			}
-			session.addSessionListener(new SessionListener() {
-
-				@Override
-				public void sessionClosed(Session s) {
-					notifyCloseListeners();
-				}
-			});
-			// Authentication timeout is by default 2 minutes.
-			session.auth().verify(session.getAuthTimeout());
+			session = connect(uri, Collections.emptyList(),
+					future -> notifyCloseListeners(), timeout, MAX_DEPTH);
 		} catch (IOException e) {
 			disconnect(e);
 			throw e;
 		}
 	}
 
+	private ClientSession connect(URIish target, List<URIish> jumps,
+			SshFutureListener<CloseFuture> listener, Duration timeout,
+			int depth) throws IOException {
+		if (--depth < 0) {
+			throw new IOException(
+					format(SshdText.get().proxyJumpAbort, target));
+		}
+		HostConfigEntry hostConfig = getHostConfig(target.getUser(),
+				target.getHost(), target.getPort());
+		String host = hostConfig.getHostName();
+		int port = hostConfig.getPort();
+		List<URIish> hops = determineHops(jumps, hostConfig, target.getHost());
+		ClientSession resultSession = null;
+		ClientSession proxySession = null;
+		PortForwardingTracker portForward = null;
+		try {
+			if (!hops.isEmpty()) {
+				URIish hop = hops.remove(0);
+				if (LOG.isDebugEnabled()) {
+					LOG.debug("Connecting to jump host {}", hop); //$NON-NLS-1$
+				}
+				proxySession = connect(hop, hops, null, timeout, depth);
+			}
+			AttributeRepository context = null;
+			if (proxySession != null) {
+				SshdSocketAddress remoteAddress = new SshdSocketAddress(host,
+						port);
+				portForward = proxySession.createLocalPortForwardingTracker(
+						SshdSocketAddress.LOCALHOST_ADDRESS, remoteAddress);
+				// We must connect to the locally bound address, not the one
+				// from the host config.
+				context = AttributeRepository.ofKeyValuePair(
+						JGitSshClient.LOCAL_FORWARD_ADDRESS,
+						portForward.getBoundAddress());
+			}
+			resultSession = connect(hostConfig, context, timeout);
+			if (proxySession != null) {
+				final PortForwardingTracker tracker = portForward;
+				final ClientSession pSession = proxySession;
+				resultSession.addCloseFutureListener(future -> {
+					IoUtils.closeQuietly(tracker);
+					String sessionName = pSession.toString();
+					try {
+						pSession.close();
+					} catch (IOException e) {
+						LOG.error(format(
+								SshdText.get().sshProxySessionCloseFailed,
+								sessionName), e);
+					}
+				});
+				portForward = null;
+				proxySession = null;
+			}
+			if (listener != null) {
+				resultSession.addCloseFutureListener(listener);
+			}
+			// Authentication timeout is by default 2 minutes.
+			resultSession.auth().verify(resultSession.getAuthTimeout());
+			return resultSession;
+		} catch (IOException e) {
+			close(portForward, e);
+			close(proxySession, e);
+			close(resultSession, e);
+			if (e instanceof SshException && ((SshException) e)
+					.getDisconnectCode() == SSH2_DISCONNECT_NO_MORE_AUTH_METHODS_AVAILABLE) {
+				// Ensure the user gets to know on which URI the authentication
+				// was denied.
+				throw new TransportException(target,
+						format(SshdText.get().loginDenied, host,
+								Integer.toString(port)),
+						e);
+			}
+			throw e;
+		}
+	}
+
+	private ClientSession connect(HostConfigEntry config,
+			AttributeRepository context, Duration timeout)
+			throws IOException {
+		ConnectFuture connected = client.connect(config, context, null);
+		long timeoutMillis = timeout.toMillis();
+		if (timeoutMillis <= 0) {
+			connected = connected.verify();
+		} else {
+			connected = connected.verify(timeoutMillis);
+		}
+		return connected.getSession();
+	}
+
+	private void close(Closeable toClose, Throwable error) {
+		if (toClose != null) {
+			try {
+				toClose.close();
+			} catch (IOException e) {
+				error.addSuppressed(e);
+			}
+		}
+	}
+
+	private HostConfigEntry getHostConfig(String username, String host,
+			int port) throws IOException {
+		HostConfigEntry entry = client.getHostConfigEntryResolver()
+				.resolveEffectiveHost(host, port, null, username, null);
+		if (entry == null) {
+			if (SshdSocketAddress.isIPv6Address(host)) {
+				return new HostConfigEntry("", host, port, username); //$NON-NLS-1$
+			}
+			return new HostConfigEntry(host, host, port, username);
+		}
+		return entry;
+	}
+
+	private List<URIish> determineHops(List<URIish> currentHops,
+			HostConfigEntry hostConfig, String host) throws IOException {
+		if (currentHops.isEmpty()) {
+			String jumpHosts = hostConfig.getProperty(SshConstants.PROXY_JUMP);
+			if (!StringUtils.isEmptyOrNull(jumpHosts)) {
+				try {
+					return parseProxyJump(jumpHosts);
+				} catch (URISyntaxException e) {
+					throw new IOException(
+							format(SshdText.get().configInvalidProxyJump, host,
+									jumpHosts),
+							e);
+				}
+			}
+		}
+		return currentHops;
+	}
+
+	private List<URIish> parseProxyJump(String proxyJump)
+			throws URISyntaxException {
+		String[] hops = proxyJump.split(","); //$NON-NLS-1$
+		List<URIish> result = new LinkedList<>();
+		for (String hop : hops) {
+			// There shouldn't be any whitespace, but let's be lenient
+			hop = hop.trim();
+			if (SHORT_SSH_FORMAT.matcher(hop).matches()) {
+				// URIish doesn't understand the short SSH format
+				// user@host:port, only user@host:path
+				hop = SshConstants.SSH_SCHEME + "://" + hop; //$NON-NLS-1$
+			}
+			URIish to = new URIish(hop);
+			if (!SshConstants.SSH_SCHEME.equalsIgnoreCase(to.getScheme())) {
+				throw new URISyntaxException(hop,
+						SshdText.get().configProxyJumpNotSsh);
+			} else if (!StringUtils.isEmptyOrNull(to.getPath())) {
+				throw new URISyntaxException(hop,
+						SshdText.get().configProxyJumpWithPath);
+			}
+			result.add(to);
+		}
+		return result;
+	}
+
 	/**
 	 * Adds a {@link SessionCloseListener} to this session. Has no effect if the
 	 * given {@code listener} is already registered with this session.
diff --git a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSessionFactory.java b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSessionFactory.java
index 0f7ab84..4ad3c4a 100644
--- a/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSessionFactory.java
+++ b/org.eclipse.jgit.ssh.apache/src/org/eclipse/jgit/transport/sshd/SshdSessionFactory.java
@@ -230,6 +230,9 @@
 			return session;
 		} catch (Exception e) {
 			unregister(session);
+			if (e instanceof TransportException) {
+				throw (TransportException) e;
+			}
 			throw new TransportException(uri, e.getMessage(), e);
 		}
 	}
diff --git a/org.eclipse.jgit.test/tst/org/eclipse/jgit/api/StatusCommandTest.java b/org.eclipse.jgit.test/tst/org/eclipse/jgit/api/StatusCommandTest.java
index 7e0de82..5311edb 100644
--- a/org.eclipse.jgit.test/tst/org/eclipse/jgit/api/StatusCommandTest.java
+++ b/org.eclipse.jgit.test/tst/org/eclipse/jgit/api/StatusCommandTest.java
@@ -160,4 +160,25 @@
 			assertTrue("Expected no differences", status.isClean());
 		}
 	}
+
+	@Test
+	public void testFolderPrefix() throws Exception {
+		// "audio" is a prefix of "audio-new" and "audio.new".
+		try (Git git = new Git(db)) {
+			// Order here is the git order, but that doesn't really matter.
+			// They are processed by StatusCommand in this order even if written
+			// in a different order. Bug 566799 would, when having processed
+			// audio/foo, remove previously recorded untracked folders that have
+			// "audio" as a prefix: audio-new and audio.new.
+			writeTrashFile("audi", "foo", "foo");
+			writeTrashFile("audio-new", "foo", "foo");
+			writeTrashFile("audio.new", "foo", "foo");
+			writeTrashFile("audio", "foo", "foo");
+			writeTrashFile("audio_new", "foo", "foo");
+			Status stat = git.status().call();
+			assertEquals(Sets.of("audi", "audio-new", "audio.new", "audio",
+					"audio_new"), stat.getUntrackedFolders());
+		}
+	}
+
 }
diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/transport/SshConstants.java b/org.eclipse.jgit/src/org/eclipse/jgit/transport/SshConstants.java
index b1fac2c..fff2938 100644
--- a/org.eclipse.jgit/src/org/eclipse/jgit/transport/SshConstants.java
+++ b/org.eclipse.jgit/src/org/eclipse/jgit/transport/SshConstants.java
@@ -1,5 +1,5 @@
 /*
- * Copyright (C) 2018 Thomas Wolf <thomas.wolf@paranor.ch> and others
+ * Copyright (C) 2018, 2020 Thomas Wolf <thomas.wolf@paranor.ch> and others
  *
  * This program and the accompanying materials are made available under the
  * terms of the Eclipse Distribution License v. 1.0 which is available at
@@ -117,6 +117,34 @@
 	/** Key in an ssh config file. */
 	public static final String PROXY_COMMAND = "ProxyCommand";
 
+	/**
+	 * Comma-separated list of jump hosts, defining a jump host chain <em>in
+	 * reverse order</em>. Each jump host is a SSH URI or "[user@]host[:port]".
+	 * <p>
+	 * Reverse order means: to connect A->B->target, one can do in
+	 * {@code ~/.ssh/config} either of:
+	 * </p>
+	 *
+	 * <pre>
+	 * Host target
+	 *   ProxyJump B,A
+	 * </pre>
+	 * <p>
+	 * <em>or</em>
+	 * </p>
+	 *
+	 * <pre>
+	 * Host target
+	 *   ProxyJump B
+	 *
+	 * Host B
+	 *   ProxyJump A
+	 * </pre>
+	 *
+	 * @since 5.10
+	 */
+	public static final String PROXY_JUMP = "ProxyJump";
+
 	/** Key in an ssh config file. */
 	public static final String REMOTE_COMMAND = "RemoteCommand";
 
diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/treewalk/filter/IndexDiffFilter.java b/org.eclipse.jgit/src/org/eclipse/jgit/treewalk/filter/IndexDiffFilter.java
index 19cda42..4731f34 100644
--- a/org.eclipse.jgit/src/org/eclipse/jgit/treewalk/filter/IndexDiffFilter.java
+++ b/org.eclipse.jgit/src/org/eclipse/jgit/treewalk/filter/IndexDiffFilter.java
@@ -217,14 +217,15 @@
 	 */
 	private void copyUntrackedFolders(String currentPath) {
 		String pathToBeSaved = null;
-		while (!untrackedParentFolders.isEmpty()
-				&& !currentPath.startsWith(untrackedParentFolders.getFirst()
-						+ "/")) //$NON-NLS-1$
+		while (!untrackedParentFolders.isEmpty() && !currentPath
+				.startsWith(untrackedParentFolders.getFirst() + '/')) {
 			pathToBeSaved = untrackedParentFolders.removeFirst();
+		}
 		if (pathToBeSaved != null) {
-			while (!untrackedFolders.isEmpty()
-					&& untrackedFolders.getLast().startsWith(pathToBeSaved))
+			while (!untrackedFolders.isEmpty() && untrackedFolders.getLast()
+					.startsWith(pathToBeSaved + '/')) {
 				untrackedFolders.removeLast();
+			}
 			untrackedFolders.addLast(pathToBeSaved);
 		}
 	}
diff --git a/pom.xml b/pom.xml
index b5be478..f5a5626 100644
--- a/pom.xml
+++ b/pom.xml
@@ -337,7 +337,7 @@
         <plugin>
           <groupId>org.jacoco</groupId>
           <artifactId>jacoco-maven-plugin</artifactId>
-          <version>0.8.5</version>
+          <version>0.8.6</version>
         </plugin>
         <plugin>
           <groupId>org.apache.maven.plugins</groupId>