]> source.dussan.org Git - jgit.git/commitdiff
Let Jsch know about ~/.ssh/config 67/99067/4
authorThomas Wolf <thomas.wolf@paranor.ch>
Wed, 7 Jun 2017 16:39:19 +0000 (18:39 +0200)
committerMatthias Sohn <matthias.sohn@sap.com>
Fri, 25 Aug 2017 23:41:50 +0000 (01:41 +0200)
Ensure the Jsch instance used knows about ~/.ssh/config. This
enables Jsch to honor more user configurations (see
com.jcraft.jsch.Session.applyConfig()), in particular also the
UserKnownHostsFile configuration, or additional identities given
via multiple IdentityFile entries.

Turn JGit's OpenSshConfig into a full parser that can be a
Jsch-compliant ConfigRepository. This avoids a few bugs
in Jsch's OpenSSHConfig and keeps the JGit-facing interface
unchanged. At the same time we can supply a JGit OpenSshConfig
instance as a ConfigRepository to Jsch. And since they'll both
work from the same object, we can also be sure that the parsing
behavior is identical.

The parser does not handle the "Match" and "Include" keys, and it
doesn't do %-token substitutions (yet).

Note that Jsch doesn't handle multi-valued UserKnownHostFile
entries as known by modern OpenSSH.[1]

[1] http://man.openbsd.org/OpenBSD-current/man5/ssh_config.5

Additional tests for new features are provided in OpenSshConfigTest.

Bug: 490939
Change-Id: Ic683bd412fa8c5632142aebba4a07fad4c64c637
Signed-off-by: Thomas Wolf <thomas.wolf@paranor.ch>
Signed-off-by: Matthias Sohn <matthias.sohn@sap.com>
org.eclipse.jgit.test/META-INF/MANIFEST.MF
org.eclipse.jgit.test/tst/org/eclipse/jgit/transport/OpenSshConfigTest.java
org.eclipse.jgit/src/org/eclipse/jgit/transport/JschConfigSessionFactory.java
org.eclipse.jgit/src/org/eclipse/jgit/transport/OpenSshConfig.java

index ea9b81bb1a2f2124b26fd46d4675f53f99396ca7..67910209d1af4be7f76ebba78e65244a0af6286e 100644 (file)
@@ -8,6 +8,7 @@ Bundle-Vendor: %provider_name
 Bundle-ActivationPolicy: lazy
 Bundle-RequiredExecutionEnvironment: JavaSE-1.8
 Import-Package: com.googlecode.javaewah;version="[1.1.6,2.0.0)",
+ com.jcraft.jsch;version="[0.1.54,0.2.0)",
  org.eclipse.jgit.api;version="[4.9.0,4.10.0)",
  org.eclipse.jgit.api.errors;version="[4.9.0,4.10.0)",
  org.eclipse.jgit.attributes;version="[4.9.0,4.10.0)",
index fc520ab17fb30a0c6eb271158c3a65a7cde11dae..5eccededf825bd0756106f124ee2130ef6650c11 100644 (file)
@@ -1,5 +1,5 @@
 /*
- * Copyright (C) 2008, 2014 Google Inc.
+ * Copyright (C) 2008, 2017 Google Inc.
  * and other copyright owners as documented in the project's IP log.
  *
  * This program and the accompanying materials are made available
 
 package org.eclipse.jgit.transport;
 
+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.assertNotSame;
 import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
 import static org.junit.Assert.assertTrue;
 
 import java.io.File;
@@ -61,6 +64,8 @@ import org.eclipse.jgit.util.FileUtils;
 import org.junit.Before;
 import org.junit.Test;
 
+import com.jcraft.jsch.ConfigRepository;
+
 public class OpenSshConfigTest extends RepositoryTestCase {
        private File home;
 
@@ -84,10 +89,13 @@ public class OpenSshConfigTest extends RepositoryTestCase {
        }
 
        private void config(final String data) throws IOException {
-               final OutputStreamWriter fw = new OutputStreamWriter(
-                               new FileOutputStream(configFile), "UTF-8");
-               fw.write(data);
-               fw.close();
+               long lastMtime = configFile.lastModified();
+               do {
+                       try (final OutputStreamWriter fw = new OutputStreamWriter(
+                                       new FileOutputStream(configFile), "UTF-8")) {
+                               fw.write(data);
+                       }
+               } while (lastMtime == configFile.lastModified());
        }
 
        @Test
@@ -155,13 +163,18 @@ public class OpenSshConfigTest extends RepositoryTestCase {
 
        @Test
        public void testAlias_DoesNotMatch() throws Exception {
-               config("Host orcz\n" + "\tHostName repo.or.cz\n");
+               config("Host orcz\n" + "Port 29418\n" + "\tHostName repo.or.cz\n");
                final Host h = osc.lookup("repo.or.cz");
                assertNotNull(h);
                assertEquals("repo.or.cz", h.getHostName());
                assertEquals("jex_junit", h.getUser());
                assertEquals(22, h.getPort());
                assertNull(h.getIdentityFile());
+               final Host h2 = osc.lookup("orcz");
+               assertEquals("repo.or.cz", h.getHostName());
+               assertEquals("jex_junit", h.getUser());
+               assertEquals(29418, h2.getPort());
+               assertNull(h.getIdentityFile());
        }
 
        @Test
@@ -282,4 +295,153 @@ public class OpenSshConfigTest extends RepositoryTestCase {
                assertNotNull(h);
                assertEquals(1, h.getConnectionAttempts());
        }
+
+       @Test
+       public void testDefaultBlock() throws Exception {
+               config("ConnectionAttempts 5\n\nHost orcz\nConnectionAttempts 3\n");
+               final Host h = osc.lookup("orcz");
+               assertNotNull(h);
+               assertEquals(5, h.getConnectionAttempts());
+       }
+
+       @Test
+       public void testHostCaseInsensitive() throws Exception {
+               config("hOsT orcz\nConnectionAttempts 3\n");
+               final Host h = osc.lookup("orcz");
+               assertNotNull(h);
+               assertEquals(3, h.getConnectionAttempts());
+       }
+
+       @Test
+       public void testListValueSingle() throws Exception {
+               config("Host orcz\nUserKnownHostsFile /foo/bar\n");
+               final ConfigRepository.Config c = osc.getConfig("orcz");
+               assertNotNull(c);
+               assertEquals("/foo/bar", c.getValue("UserKnownHostsFile"));
+       }
+
+       @Test
+       public void testListValueMultiple() throws Exception {
+               // Tilde expansion doesn't occur within the parser
+               config("Host orcz\nUserKnownHostsFile \"~/foo/ba z\" /foo/bar \n");
+               final ConfigRepository.Config c = osc.getConfig("orcz");
+               assertNotNull(c);
+               assertArrayEquals(new Object[] { "~/foo/ba z", "/foo/bar" },
+                               c.getValues("UserKnownHostsFile"));
+       }
+
+       @Test
+       public void testRepeatedLookups() throws Exception {
+               config("Host orcz\n" + "\tConnectionAttempts 5\n");
+               final Host h1 = osc.lookup("orcz");
+               final Host h2 = osc.lookup("orcz");
+               assertNotNull(h1);
+               assertSame(h1, h2);
+               assertEquals(5, h1.getConnectionAttempts());
+               assertEquals(h1.getConnectionAttempts(), h2.getConnectionAttempts());
+               final ConfigRepository.Config c = osc.getConfig("orcz");
+               assertNotNull(c);
+               assertSame(c, h1.getConfig());
+               assertSame(c, h2.getConfig());
+       }
+
+       @Test
+       public void testRepeatedLookupsWithModification() throws Exception {
+               config("Host orcz\n" + "\tConnectionAttempts -1\n");
+               final Host h1 = osc.lookup("orcz");
+               assertNotNull(h1);
+               assertEquals(1, h1.getConnectionAttempts());
+               config("Host orcz\n" + "\tConnectionAttempts 5\n");
+               final Host h2 = osc.lookup("orcz");
+               assertNotNull(h2);
+               assertNotSame(h1, h2);
+               assertEquals(5, h2.getConnectionAttempts());
+               assertEquals(1, h1.getConnectionAttempts());
+               assertNotSame(h1.getConfig(), h2.getConfig());
+       }
+
+       @Test
+       public void testIdentityFile() throws Exception {
+               config("Host orcz\nIdentityFile \"~/foo/ba z\"\nIdentityFile /foo/bar");
+               final Host h = osc.lookup("orcz");
+               assertNotNull(h);
+               File f = h.getIdentityFile();
+               assertNotNull(f);
+               // Host does tilde replacement
+               assertEquals(new File(home, "foo/ba z"), f);
+               final ConfigRepository.Config c = h.getConfig();
+               // Config doesn't
+               assertArrayEquals(new Object[] { "~/foo/ba z", "/foo/bar" },
+                               c.getValues("IdentityFile"));
+       }
+
+       @Test
+       public void testMultiIdentityFile() throws Exception {
+               config("IdentityFile \"~/foo/ba z\"\nHost orcz\nIdentityFile /foo/bar\nHOST *\nIdentityFile /foo/baz");
+               final Host h = osc.lookup("orcz");
+               assertNotNull(h);
+               File f = h.getIdentityFile();
+               assertNotNull(f);
+               // Host does tilde replacement
+               assertEquals(new File(home, "foo/ba z"), f);
+               final ConfigRepository.Config c = h.getConfig();
+               // Config doesn't
+               assertArrayEquals(new Object[] { "~/foo/ba z", "/foo/bar", "/foo/baz" },
+                               c.getValues("IdentityFile"));
+       }
+
+       @Test
+       public void testNegatedPattern() throws Exception {
+               config("Host repo.or.cz\nIdentityFile ~/foo/bar\nHOST !*.or.cz\nIdentityFile /foo/baz");
+               final Host h = osc.lookup("repo.or.cz");
+               assertNotNull(h);
+               assertEquals(new File(home, "foo/bar"), h.getIdentityFile());
+               assertArrayEquals(new Object[] { "~/foo/bar" },
+                               h.getConfig().getValues("IdentityFile"));
+       }
+
+       @Test
+       public void testPattern() throws Exception {
+               config("Host repo.or.cz\nIdentityFile ~/foo/bar\nHOST *.or.cz\nIdentityFile /foo/baz");
+               final Host h = osc.lookup("repo.or.cz");
+               assertNotNull(h);
+               assertEquals(new File(home, "foo/bar"), h.getIdentityFile());
+               assertArrayEquals(new Object[] { "~/foo/bar", "/foo/baz" },
+                               h.getConfig().getValues("IdentityFile"));
+       }
+
+       @Test
+       public void testMultiHost() throws Exception {
+               config("Host orcz *.or.cz\nIdentityFile ~/foo/bar\nHOST *.or.cz\nIdentityFile /foo/baz");
+               final Host h1 = osc.lookup("repo.or.cz");
+               assertNotNull(h1);
+               assertEquals(new File(home, "foo/bar"), h1.getIdentityFile());
+               assertArrayEquals(new Object[] { "~/foo/bar", "/foo/baz" },
+                               h1.getConfig().getValues("IdentityFile"));
+               final Host h2 = osc.lookup("orcz");
+               assertNotNull(h2);
+               assertEquals(new File(home, "foo/bar"), h2.getIdentityFile());
+               assertArrayEquals(new Object[] { "~/foo/bar" },
+                               h2.getConfig().getValues("IdentityFile"));
+       }
+
+       @Test
+       public void testEqualsSign() throws Exception {
+               config("Host=orcz\n\tConnectionAttempts = 5\n\tUser=\t  foobar\t\n");
+               final Host h = osc.lookup("orcz");
+               assertNotNull(h);
+               assertEquals(5, h.getConnectionAttempts());
+               assertEquals("foobar", h.getUser());
+       }
+
+       @Test
+       public void testMissingArgument() throws Exception {
+               config("Host=orcz\n\tSendEnv\nIdentityFile\t\nForwardX11\n\tUser=\t  foobar\t\n");
+               final Host h = osc.lookup("orcz");
+               assertNotNull(h);
+               assertEquals("foobar", h.getUser());
+               assertArrayEquals(new String[0], h.getConfig().getValues("SendEnv"));
+               assertNull(h.getIdentityFile());
+               assertNull(h.getConfig().getValue("ForwardX11"));
+       }
 }
index ce14183a56f0e937413b88676385370dfca27557..242d1c48b6d766be156ed410660b23511b1fb2be 100644 (file)
@@ -259,6 +259,9 @@ public abstract class JschConfigSessionFactory extends SshSessionFactory {
        protected JSch getJSch(final OpenSshConfig.Host hc, FS fs) throws JSchException {
                if (defaultJSch == null) {
                        defaultJSch = createDefaultJSch(fs);
+                       if (defaultJSch.getConfigRepository() == null) {
+                               defaultJSch.setConfigRepository(config);
+                       }
                        for (Object name : defaultJSch.getIdentityNames())
                                byIdentityFile.put((String) name, defaultJSch);
                }
@@ -272,6 +275,9 @@ public abstract class JschConfigSessionFactory extends SshSessionFactory {
                if (jsch == null) {
                        jsch = new JSch();
                        configureJSch(jsch);
+                       if (jsch.getConfigRepository() == null) {
+                               jsch.setConfigRepository(defaultJSch.getConfigRepository());
+                       }
                        jsch.setHostKeyRepository(defaultJSch.getHostKeyRepository());
                        jsch.addIdentity(identityKey);
                        byIdentityFile.put(identityKey, jsch);
index 8b7b60da377bf863ed472ebf7235fd9d88238502..ad79f3ebeafa5167dee55462ad5518883ee6993a 100644 (file)
@@ -1,5 +1,5 @@
 /*
- * Copyright (C) 2008, 2014, Google Inc.
+ * Copyright (C) 2008, 2017, Google Inc.
  * and other copyright owners as documented in the project's IP log.
  *
  * This program and the accompanying materials are made available
@@ -46,17 +46,19 @@ package org.eclipse.jgit.transport;
 import java.io.BufferedReader;
 import java.io.File;
 import java.io.FileInputStream;
-import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.InputStreamReader;
 import java.security.AccessController;
 import java.security.PrivilegedAction;
 import java.util.ArrayList;
-import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
 import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
+import java.util.Set;
 
 import org.eclipse.jgit.errors.InvalidPatternException;
 import org.eclipse.jgit.fnmatch.FileNameMatcher;
@@ -64,14 +66,46 @@ import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.util.FS;
 import org.eclipse.jgit.util.StringUtils;
 
+import com.jcraft.jsch.ConfigRepository;
+
 /**
- * Simple configuration parser for the OpenSSH ~/.ssh/config file.
+ * Fairly complete configuration parser for the OpenSSH ~/.ssh/config file.
+ * <p>
+ * JSch does have its own config file parser
+ * {@link com.jcraft.jsch.OpenSSHConfig} since version 0.1.50, but it has a
+ * number of problems:
+ * <ul>
+ * <li>it splits lines of the format "keyword = value" wrongly: you'd end up
+ * with the value "= value".
+ * <li>its "Host" keyword is not case insensitive.
+ * <li>it doesn't handle quoted values.
+ * <li>JSch's OpenSSHConfig doesn't monitor for config file changes.
+ * </ul>
+ * <p>
+ * Therefore implement our own parser to read an OpenSSH configuration file. It
+ * makes the critical options available to {@link SshSessionFactory} via
+ * {@link Host} objects returned by {@link #lookup(String)}, and implements a
+ * fully conforming {@link ConfigRepository} providing
+ * {@link com.jcraft.jsch.ConfigRepository.Config}s via
+ * {@link #getConfig(String)}.
+ * </p>
  * <p>
- * Since JSch does not (currently) have the ability to parse an OpenSSH
- * configuration file this is a simple parser to read that file and make the
- * critical options available to {@link SshSessionFactory}.
+ * Limitations compared to the full OpenSSH 7.5 parser:
+ * </p>
+ * <ul>
+ * <li>This parser does not handle Match or Include keywords.
+ * <li>This parser does not do %-substitutions.
+ * <li>This parser does not do host name canonicalization (Jsch ignores it
+ * anyway).
+ * </ul>
+ * Note that OpenSSH's readconf.c is a validating parser; Jsch's
+ * ConfigRepository OTOH treats all option values as plain strings, so any
+ * validation must happen in Jsch outside of the parser. Thus this parser does
+ * not validate option values, except for a few options when constructing a
+ * {@link Host} object.
  */
-public class OpenSshConfig {
+public class OpenSshConfig implements ConfigRepository {
+
        /** IANA assigned port number for SSH. */
        static final int SSH_PORT = 22;
 
@@ -105,16 +139,25 @@ public class OpenSshConfig {
        /** The .ssh/config file we read and monitor for updates. */
        private final File configFile;
 
-       /** Modification time of {@link #configFile} when {@link #hosts} loaded. */
+       /** Modification time of {@link #configFile} when it was last loaded. */
        private long lastModified;
 
-       /** Cached entries read out of the configuration file. */
-       private Map<String, Host> hosts;
+       /**
+        * Encapsulates entries read out of the configuration file, and
+        * {@link Host}s created from that.
+        */
+       private static class State {
+               Map<String, HostEntry> entries = new LinkedHashMap<>();
+               Map<String, Host> hosts = new HashMap<>();
+       }
+
+       /** State read from the config file, plus {@link Host}s created from it. */
+       private State state;
 
        OpenSshConfig(final File h, final File cfg) {
                home = h;
                configFile = cfg;
-               hosts = Collections.emptyMap();
+               state = new State();
        }
 
        /**
@@ -127,75 +170,80 @@ public class OpenSshConfig {
         * @return r configuration for the requested name. Never null.
         */
        public Host lookup(final String hostName) {
-               final Map<String, Host> cache = refresh();
-               Host h = cache.get(hostName);
-               if (h == null)
-                       h = new Host();
-               if (h.patternsApplied)
+               final State cache = refresh();
+               Host h = cache.hosts.get(hostName);
+               if (h != null) {
                        return h;
-
-               for (final Map.Entry<String, Host> e : cache.entrySet()) {
-                       if (!isHostPattern(e.getKey()))
-                               continue;
-                       if (!isHostMatch(e.getKey(), hostName))
-                               continue;
-                       h.copyFrom(e.getValue());
-               }
-
-               if (h.hostName == null)
-                       h.hostName = hostName;
-               if (h.user == null)
-                       h.user = OpenSshConfig.userName();
-               if (h.port == 0)
-                       h.port = OpenSshConfig.SSH_PORT;
-               if (h.connectionAttempts == 0)
-                       h.connectionAttempts = 1;
-               h.patternsApplied = true;
+               }
+               HostEntry fullConfig = new HostEntry();
+               // Initialize with default entries at the top of the file, before the
+               // first Host block.
+               fullConfig.merge(cache.entries.get(HostEntry.DEFAULT_NAME));
+               for (final Map.Entry<String, HostEntry> e : cache.entries.entrySet()) {
+                       String key = e.getKey();
+                       if (isHostMatch(key, hostName)) {
+                               fullConfig.merge(e.getValue());
+                       }
+               }
+               h = new Host(fullConfig, hostName, home);
+               cache.hosts.put(hostName, h);
                return h;
        }
 
-       private synchronized Map<String, Host> refresh() {
+       private synchronized State refresh() {
                final long mtime = configFile.lastModified();
                if (mtime != lastModified) {
-                       try {
-                               final FileInputStream in = new FileInputStream(configFile);
-                               try {
-                                       hosts = parse(in);
-                               } finally {
-                                       in.close();
-                               }
-                       } catch (FileNotFoundException none) {
-                               hosts = Collections.emptyMap();
-                       } catch (IOException err) {
-                               hosts = Collections.emptyMap();
+                       State newState = new State();
+                       try (FileInputStream in = new FileInputStream(configFile)) {
+                               newState.entries = parse(in);
+                       } catch (IOException none) {
+                               // Ignore -- we'll set and return an empty state
                        }
                        lastModified = mtime;
+                       state = newState;
                }
-               return hosts;
+               return state;
        }
 
-       private Map<String, Host> parse(final InputStream in) throws IOException {
-               final Map<String, Host> m = new LinkedHashMap<>();
+       private Map<String, HostEntry> parse(final InputStream in)
+                       throws IOException {
+               final Map<String, HostEntry> m = new LinkedHashMap<>();
                final BufferedReader br = new BufferedReader(new InputStreamReader(in));
-               final List<Host> current = new ArrayList<>(4);
+               final List<HostEntry> current = new ArrayList<>(4);
                String line;
 
+               // The man page doesn't say so, but the OpenSSH parser (readconf.c)
+               // starts out in active mode and thus always applies any lines that
+               // occur before the first host block. We gather those options in a
+               // HostEntry for DEFAULT_NAME.
+               HostEntry defaults = new HostEntry();
+               current.add(defaults);
+               m.put(HostEntry.DEFAULT_NAME, defaults);
+
                while ((line = br.readLine()) != null) {
                        line = line.trim();
-                       if (line.length() == 0 || line.startsWith("#")) //$NON-NLS-1$
+                       if (line.isEmpty() || line.startsWith("#")) { //$NON-NLS-1$
                                continue;
-
-                       final String[] parts = line.split("[ \t]*[= \t]", 2); //$NON-NLS-1$
-                       final String keyword = parts[0].trim();
-                       final String argValue = parts[1].trim();
+                       }
+                       String[] parts = line.split("[ \t]*[= \t]", 2); //$NON-NLS-1$
+                       // Although the ssh-config man page doesn't say so, the OpenSSH
+                       // parser does allow quoted keywords.
+                       String keyword = dequote(parts[0].trim());
+                       // man 5 ssh-config says lines had the format "keyword arguments",
+                       // with no indication that arguments were optional. However, let's
+                       // not crap out on missing arguments. See bug 444319.
+                       String argValue = parts.length > 1 ? parts[1].trim() : ""; //$NON-NLS-1$
 
                        if (StringUtils.equalsIgnoreCase("Host", keyword)) { //$NON-NLS-1$
                                current.clear();
-                               for (final String pattern : argValue.split("[ \t]")) { //$NON-NLS-1$
-                                       final String name = dequote(pattern);
-                                       Host c = m.get(name);
+                               for (String name : HostEntry.parseList(argValue)) {
+                                       if (name == null || name.isEmpty()) {
+                                               // null should not occur, but better be safe than sorry.
+                                               continue;
+                                       }
+                                       HostEntry c = m.get(name);
                                        if (c == null) {
-                                               c = new Host();
+                                               c = new HostEntry();
                                                m.put(name, c);
                                        }
                                        current.add(c);
@@ -206,57 +254,18 @@ public class OpenSshConfig {
                        if (current.isEmpty()) {
                                // We received an option outside of a Host block. We
                                // don't know who this should match against, so skip.
-                               //
                                continue;
                        }
 
-                       if (StringUtils.equalsIgnoreCase("HostName", keyword)) { //$NON-NLS-1$
-                               for (final Host c : current)
-                                       if (c.hostName == null)
-                                               c.hostName = dequote(argValue);
-                       } else if (StringUtils.equalsIgnoreCase("User", keyword)) { //$NON-NLS-1$
-                               for (final Host c : current)
-                                       if (c.user == null)
-                                               c.user = dequote(argValue);
-                       } else if (StringUtils.equalsIgnoreCase("Port", keyword)) { //$NON-NLS-1$
-                               try {
-                                       final int port = Integer.parseInt(dequote(argValue));
-                                       for (final Host c : current)
-                                               if (c.port == 0)
-                                                       c.port = port;
-                               } catch (NumberFormatException nfe) {
-                                       // Bad port number. Don't set it.
+                       if (HostEntry.isListKey(keyword)) {
+                               List<String> args = HostEntry.parseList(argValue);
+                               for (HostEntry entry : current) {
+                                       entry.setValue(keyword, args);
                                }
-                       } else if (StringUtils.equalsIgnoreCase("IdentityFile", keyword)) { //$NON-NLS-1$
-                               for (final Host c : current)
-                                       if (c.identityFile == null)
-                                               c.identityFile = toFile(dequote(argValue));
-                       } else if (StringUtils.equalsIgnoreCase(
-                                       "PreferredAuthentications", keyword)) { //$NON-NLS-1$
-                               for (final Host c : current)
-                                       if (c.preferredAuthentications == null)
-                                               c.preferredAuthentications = nows(dequote(argValue));
-                       } else if (StringUtils.equalsIgnoreCase("BatchMode", keyword)) { //$NON-NLS-1$
-                               for (final Host c : current)
-                                       if (c.batchMode == null)
-                                               c.batchMode = yesno(dequote(argValue));
-                       } else if (StringUtils.equalsIgnoreCase(
-                                       "StrictHostKeyChecking", keyword)) { //$NON-NLS-1$
-                               String value = dequote(argValue);
-                               for (final Host c : current)
-                                       if (c.strictHostKeyChecking == null)
-                                               c.strictHostKeyChecking = value;
-                       } else if (StringUtils.equalsIgnoreCase(
-                                       "ConnectionAttempts", keyword)) { //$NON-NLS-1$
-                               try {
-                                       final int connectionAttempts = Integer.parseInt(dequote(argValue));
-                                       if (connectionAttempts > 0) {
-                                               for (final Host c : current)
-                                                       if (c.connectionAttempts == 0)
-                                                               c.connectionAttempts = connectionAttempts;
-                                       }
-                               } catch (NumberFormatException nfe) {
-                                       // ignore bad values
+                       } else if (!argValue.isEmpty()) {
+                               argValue = dequote(argValue);
+                               for (HostEntry entry : current) {
+                                       entry.setValue(keyword, argValue);
                                }
                        }
                }
@@ -264,23 +273,35 @@ public class OpenSshConfig {
                return m;
        }
 
-       private static boolean isHostPattern(final String s) {
-               return s.indexOf('*') >= 0 || s.indexOf('?') >= 0;
+       private static boolean isHostMatch(final String pattern,
+                       final String name) {
+               if (pattern.startsWith("!")) { //$NON-NLS-1$
+                       return !patternMatchesHost(pattern.substring(1), name);
+               } else {
+                       return patternMatchesHost(pattern, name);
+               }
        }
 
-       private static boolean isHostMatch(final String pattern, final String name) {
-               final FileNameMatcher fn;
-               try {
-                       fn = new FileNameMatcher(pattern, null);
-               } catch (InvalidPatternException e) {
-                       return false;
+       private static boolean patternMatchesHost(final String pattern,
+                       final String name) {
+               if (pattern.indexOf('*') >= 0 || pattern.indexOf('?') >= 0) {
+                       final FileNameMatcher fn;
+                       try {
+                               fn = new FileNameMatcher(pattern, null);
+                       } catch (InvalidPatternException e) {
+                               return false;
+                       }
+                       fn.append(name);
+                       return fn.isMatch();
+               } else {
+                       // Not a pattern but a full host name
+                       return pattern.equals(name);
                }
-               fn.append(name);
-               return fn.isMatch();
        }
 
        private static String dequote(final String value) {
-               if (value.startsWith("\"") && value.endsWith("\"")) //$NON-NLS-1$ //$NON-NLS-2$
+               if (value.startsWith("\"") && value.endsWith("\"") //$NON-NLS-1$ //$NON-NLS-2$
+                               && value.length() > 1)
                        return value.substring(1, value.length() - 1);
                return value;
        }
@@ -300,13 +321,15 @@ public class OpenSshConfig {
                return Boolean.FALSE;
        }
 
-       private File toFile(final String path) {
-               if (path.startsWith("~/")) //$NON-NLS-1$
-                       return new File(home, path.substring(2));
-               File ret = new File(path);
-               if (ret.isAbsolute())
-                       return ret;
-               return new File(home, path);
+       private static int positive(final String value) {
+               if (value != null) {
+                       try {
+                               return Integer.parseUnsignedInt(value);
+                       } catch (NumberFormatException e) {
+                               // Ignore
+                       }
+               }
+               return -1;
        }
 
        static String userName() {
@@ -318,6 +341,293 @@ public class OpenSshConfig {
                });
        }
 
+       private static class HostEntry implements ConfigRepository.Config {
+
+               /**
+                * "Host name" of the HostEntry for the default options before the first
+                * host block in a config file.
+                */
+               public static final String DEFAULT_NAME = ""; //$NON-NLS-1$
+
+               // See com.jcraft.jsch.OpenSSHConfig. Translates some command-line keys
+               // to ssh-config keys.
+               private static final Map<String, String> KEY_MAP = new HashMap<>();
+
+               static {
+                       KEY_MAP.put("kex", "KexAlgorithms"); //$NON-NLS-1$//$NON-NLS-2$
+                       KEY_MAP.put("server_host_key", "HostKeyAlgorithms"); //$NON-NLS-1$ //$NON-NLS-2$
+                       KEY_MAP.put("cipher.c2s", "Ciphers"); //$NON-NLS-1$ //$NON-NLS-2$
+                       KEY_MAP.put("cipher.s2c", "Ciphers"); //$NON-NLS-1$ //$NON-NLS-2$
+                       KEY_MAP.put("mac.c2s", "Macs"); //$NON-NLS-1$ //$NON-NLS-2$
+                       KEY_MAP.put("mac.s2c", "Macs"); //$NON-NLS-1$ //$NON-NLS-2$
+                       KEY_MAP.put("compression.s2c", "Compression"); //$NON-NLS-1$ //$NON-NLS-2$
+                       KEY_MAP.put("compression.c2s", "Compression"); //$NON-NLS-1$ //$NON-NLS-2$
+                       KEY_MAP.put("compression_level", "CompressionLevel"); //$NON-NLS-1$ //$NON-NLS-2$
+                       KEY_MAP.put("MaxAuthTries", "NumberOfPasswordPrompts"); //$NON-NLS-1$ //$NON-NLS-2$
+               }
+
+               /**
+                * Keys that can be specified multiple times, building up a list. (I.e.,
+                * those are the keys that do not follow the general rule of "first
+                * occurrence wins".)
+                */
+               private static final Set<String> MULTI_KEYS = new HashSet<>();
+
+               static {
+                       MULTI_KEYS.add("CERTIFICATEFILE"); //$NON-NLS-1$
+                       MULTI_KEYS.add("IDENTITYFILE"); //$NON-NLS-1$
+                       MULTI_KEYS.add("LOCALFORWARD"); //$NON-NLS-1$
+                       MULTI_KEYS.add("REMOTEFORWARD"); //$NON-NLS-1$
+                       MULTI_KEYS.add("SENDENV"); //$NON-NLS-1$
+               }
+
+               /**
+                * Keys that take a whitespace-separated list of elements as argument.
+                * Because the dequote-handling is different, we must handle those in
+                * the parser. There are a few other keys that take comma-separated
+                * lists as arguments, but for the parser those are single arguments
+                * that must be quoted if they contain whitespace, and taking them apart
+                * is the responsibility of the user of those keys.
+                */
+               private static final Set<String> LIST_KEYS = new HashSet<>();
+
+               static {
+                       LIST_KEYS.add("CANONICALDOMAINS"); //$NON-NLS-1$
+                       LIST_KEYS.add("GLOBALKNOWNHOSTSFILE"); //$NON-NLS-1$
+                       LIST_KEYS.add("SENDENV"); //$NON-NLS-1$
+                       LIST_KEYS.add("USERKNOWNHOSTSFILE"); //$NON-NLS-1$
+               }
+
+               private Map<String, String> options;
+
+               private Map<String, List<String>> multiOptions;
+
+               private Map<String, List<String>> listOptions;
+
+               @Override
+               public String getHostname() {
+                       return getValue("HOSTNAME"); //$NON-NLS-1$
+               }
+
+               @Override
+               public String getUser() {
+                       return getValue("USER"); //$NON-NLS-1$
+               }
+
+               @Override
+               public int getPort() {
+                       return positive(getValue("PORT")); //$NON-NLS-1$
+               }
+
+               private static String mapKey(String key) {
+                       String k = KEY_MAP.get(key);
+                       if (k == null) {
+                               k = key;
+                       }
+                       return k.toUpperCase(Locale.ROOT);
+               }
+
+               private String findValue(String key) {
+                       String k = mapKey(key);
+                       String result = options != null ? options.get(k) : null;
+                       if (result == null) {
+                               // Also check the list and multi options. Modern OpenSSH treats
+                               // UserKnownHostsFile and GlobalKnownHostsFile as list-valued,
+                               // and so does this parser. Jsch 0.1.54 in general doesn't know
+                               // about list-valued options (it _does_ know multi-valued
+                               // options, though), and will ask for a single value for such
+                               // options.
+                               //
+                               // Let's be lenient and return at least the first value from
+                               // a list-valued or multi-valued key for which Jsch asks for a
+                               // single value.
+                               List<String> values = listOptions != null ? listOptions.get(k)
+                                               : null;
+                               if (values == null) {
+                                       values = multiOptions != null ? multiOptions.get(k) : null;
+                               }
+                               if (values != null && !values.isEmpty()) {
+                                       result = values.get(0);
+                               }
+                       }
+                       return result;
+               }
+
+               @Override
+               public String getValue(String key) {
+                       // See com.jcraft.jsch.OpenSSHConfig.MyConfig.getValue() for this
+                       // special case.
+                       if (key.equals("compression.s2c") //$NON-NLS-1$
+                                       || key.equals("compression.c2s")) { //$NON-NLS-1$
+                               String foo = findValue(key);
+                               if (foo == null || foo.equals("no")) { //$NON-NLS-1$
+                                       return "none,zlib@openssh.com,zlib"; //$NON-NLS-1$
+                               }
+                               return "zlib@openssh.com,zlib,none"; //$NON-NLS-1$
+                       }
+                       return findValue(key);
+               }
+
+               @Override
+               public String[] getValues(String key) {
+                       String k = mapKey(key);
+                       List<String> values = listOptions != null ? listOptions.get(k)
+                                       : null;
+                       if (values == null) {
+                               values = multiOptions != null ? multiOptions.get(k) : null;
+                       }
+                       if (values == null || values.isEmpty()) {
+                               return new String[0];
+                       }
+                       return values.toArray(new String[values.size()]);
+               }
+
+               public void setValue(String key, String value) {
+                       String k = key.toUpperCase(Locale.ROOT);
+                       if (MULTI_KEYS.contains(k)) {
+                               if (multiOptions == null) {
+                                       multiOptions = new HashMap<>();
+                               }
+                               List<String> values = multiOptions.get(k);
+                               if (values == null) {
+                                       values = new ArrayList<>(4);
+                                       multiOptions.put(k, values);
+                               }
+                               values.add(value);
+                       } else {
+                               if (options == null) {
+                                       options = new HashMap<>();
+                               }
+                               if (!options.containsKey(k)) {
+                                       options.put(k, value);
+                               }
+                       }
+               }
+
+               public void setValue(String key, List<String> values) {
+                       if (values.isEmpty()) {
+                               // Can occur only on a missing argument: ignore.
+                               return;
+                       }
+                       String k = key.toUpperCase(Locale.ROOT);
+                       // Check multi-valued keys first; because of the replacement
+                       // strategy, they must take precedence over list-valued keys
+                       // which always follow the "first occurrence wins" strategy.
+                       //
+                       // Note that SendEnv is a multi-valued list-valued key. (It's
+                       // rather immaterial for JGit, though.)
+                       if (MULTI_KEYS.contains(k)) {
+                               if (multiOptions == null) {
+                                       multiOptions = new HashMap<>(2 * MULTI_KEYS.size());
+                               }
+                               List<String> items = multiOptions.get(k);
+                               if (items == null) {
+                                       items = new ArrayList<>(values);
+                                       multiOptions.put(k, items);
+                               } else {
+                                       items.addAll(values);
+                               }
+                       } else {
+                               if (listOptions == null) {
+                                       listOptions = new HashMap<>(2 * LIST_KEYS.size());
+                               }
+                               if (!listOptions.containsKey(k)) {
+                                       listOptions.put(k, values);
+                               }
+                       }
+               }
+
+               public static boolean isListKey(String key) {
+                       return LIST_KEYS.contains(key.toUpperCase(Locale.ROOT));
+               }
+
+               /**
+                * Splits the argument into a list of whitespace-separated elements.
+                * Elements containing whitespace must be quoted and will be de-quoted.
+                *
+                * @param argument
+                *            argument part of the configuration line as read from the
+                *            config file
+                * @return a {@link List} of elements, possibly empty and possibly
+                *         containing empty elements
+                */
+               public static List<String> parseList(String argument) {
+                       List<String> result = new ArrayList<>(4);
+                       int start = 0;
+                       int length = argument.length();
+                       while (start < length) {
+                               // Skip whitespace
+                               if (Character.isSpaceChar(argument.charAt(start))) {
+                                       start++;
+                                       continue;
+                               }
+                               if (argument.charAt(start) == '"') {
+                                       int stop = argument.indexOf('"', start + 1);
+                                       if (stop <= start) {
+                                               // No closing double quote: skip
+                                               break;
+                                       }
+                                       result.add(argument.substring(start + 1, stop));
+                                       start = stop + 1;
+                               } else {
+                                       int stop = start + 1;
+                                       while (stop < length
+                                                       && !Character.isSpaceChar(argument.charAt(stop))) {
+                                               stop++;
+                                       }
+                                       result.add(argument.substring(start, stop));
+                                       start = stop + 1;
+                               }
+                       }
+                       return result;
+               }
+
+               protected void merge(HostEntry entry) {
+                       if (entry == null) {
+                               // Can occur if we could not read the config file
+                               return;
+                       }
+                       if (entry.options != null) {
+                               if (options == null) {
+                                       options = new HashMap<>();
+                               }
+                               for (Map.Entry<String, String> item : entry.options
+                                               .entrySet()) {
+                                       if (!options.containsKey(item.getKey())) {
+                                               options.put(item.getKey(), item.getValue());
+                                       }
+                               }
+                       }
+                       if (entry.listOptions != null) {
+                               if (listOptions == null) {
+                                       listOptions = new HashMap<>(2 * LIST_KEYS.size());
+                               }
+                               for (Map.Entry<String, List<String>> item : entry.listOptions
+                                               .entrySet()) {
+                                       if (!listOptions.containsKey(item.getKey())) {
+                                               listOptions.put(item.getKey(), item.getValue());
+                                       }
+                               }
+
+                       }
+                       if (entry.multiOptions != null) {
+                               if (multiOptions == null) {
+                                       multiOptions = new HashMap<>(2 * MULTI_KEYS.size());
+                               }
+                               for (Map.Entry<String, List<String>> item : entry.multiOptions
+                                               .entrySet()) {
+                                       List<String> values = multiOptions.get(item.getKey());
+                                       if (values == null) {
+                                               values = new ArrayList<>(item.getValue());
+                                               multiOptions.put(item.getKey(), values);
+                                       } else {
+                                               values.addAll(item.getValue());
+                                       }
+                               }
+                       }
+               }
+       }
+
        /**
         * Configuration of one "Host" block in the configuration file.
         * <p>
@@ -330,8 +640,6 @@ public class OpenSshConfig {
         * already merged into this block.
         */
        public static class Host {
-               boolean patternsApplied;
-
                String hostName;
 
                int port;
@@ -348,23 +656,18 @@ public class OpenSshConfig {
 
                int connectionAttempts;
 
-               void copyFrom(final Host src) {
-                       if (hostName == null)
-                               hostName = src.hostName;
-                       if (port == 0)
-                               port = src.port;
-                       if (identityFile == null)
-                               identityFile = src.identityFile;
-                       if (user == null)
-                               user = src.user;
-                       if (preferredAuthentications == null)
-                               preferredAuthentications = src.preferredAuthentications;
-                       if (batchMode == null)
-                               batchMode = src.batchMode;
-                       if (strictHostKeyChecking == null)
-                               strictHostKeyChecking = src.strictHostKeyChecking;
-                       if (connectionAttempts == 0)
-                               connectionAttempts = src.connectionAttempts;
+               private Config config;
+
+               /**
+                * Creates a new uninitialized {@link Host}.
+                */
+               public Host() {
+                       // For API backwards compatibility with pre-4.9 JGit
+               }
+
+               Host(Config config, String hostName, File homeDir) {
+                       this.config = config;
+                       complete(hostName, homeDir);
                }
 
                /**
@@ -432,5 +735,71 @@ public class OpenSshConfig {
                public int getConnectionAttempts() {
                        return connectionAttempts;
                }
+
+
+               private void complete(String initialHostName, File homeDir) {
+                       // Try to set values from the options.
+                       hostName = config.getHostname();
+                       user = config.getUser();
+                       port = config.getPort();
+                       connectionAttempts = positive(
+                                       config.getValue("ConnectionAttempts")); //$NON-NLS-1$
+                       strictHostKeyChecking = config.getValue("StrictHostKeyChecking"); //$NON-NLS-1$
+                       String value = config.getValue("BatchMode"); //$NON-NLS-1$
+                       if (value != null) {
+                               batchMode = yesno(value);
+                       }
+                       value = config.getValue("PreferredAuthentications"); //$NON-NLS-1$
+                       if (value != null) {
+                               preferredAuthentications = nows(value);
+                       }
+                       // Fill in defaults if still not set
+                       if (hostName == null) {
+                               hostName = initialHostName;
+                       }
+                       if (user == null) {
+                               user = OpenSshConfig.userName();
+                       }
+                       if (port <= 0) {
+                               port = OpenSshConfig.SSH_PORT;
+                       }
+                       if (connectionAttempts <= 0) {
+                               connectionAttempts = 1;
+                       }
+                       String[] identityFiles = config.getValues("IdentityFile"); //$NON-NLS-1$
+                       if (identityFiles != null && identityFiles.length > 0) {
+                               identityFile = toFile(identityFiles[0], homeDir);
+                       }
+               }
+
+               private File toFile(String path, File home) {
+                       if (path.startsWith("~/")) { //$NON-NLS-1$
+                               return new File(home, path.substring(2));
+                       }
+                       File ret = new File(path);
+                       if (ret.isAbsolute()) {
+                               return ret;
+                       }
+                       return new File(home, path);
+               }
+
+               Config getConfig() {
+                       return config;
+               }
+       }
+
+       /**
+        * Retrieves the full {@link com.jcraft.jsch.ConfigRepository.Config Config}
+        * for the given host name.
+        *
+        * @param hostName
+        *            to get the config for
+        * @return the configuration for the host
+        * @since 4.9
+        */
+       @Override
+       public Config getConfig(String hostName) {
+               Host host = lookup(hostName);
+               return host.getConfig();
        }
 }