From 9d2447063de3bdad6f68aa912d31f3934f1cebc5 Mon Sep 17 00:00:00 2001 From: Thomas Wolf Date: Wed, 7 Jun 2017 18:39:19 +0200 Subject: [PATCH] Let Jsch know about ~/.ssh/config 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 Signed-off-by: Matthias Sohn --- org.eclipse.jgit.test/META-INF/MANIFEST.MF | 1 + .../jgit/transport/OpenSshConfigTest.java | 174 ++++- .../transport/JschConfigSessionFactory.java | 6 + .../eclipse/jgit/transport/OpenSshConfig.java | 657 ++++++++++++++---- 4 files changed, 688 insertions(+), 150 deletions(-) diff --git a/org.eclipse.jgit.test/META-INF/MANIFEST.MF b/org.eclipse.jgit.test/META-INF/MANIFEST.MF index ea9b81bb1..67910209d 100644 --- a/org.eclipse.jgit.test/META-INF/MANIFEST.MF +++ b/org.eclipse.jgit.test/META-INF/MANIFEST.MF @@ -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)", diff --git a/org.eclipse.jgit.test/tst/org/eclipse/jgit/transport/OpenSshConfigTest.java b/org.eclipse.jgit.test/tst/org/eclipse/jgit/transport/OpenSshConfigTest.java index fc520ab17..5eccededf 100644 --- a/org.eclipse.jgit.test/tst/org/eclipse/jgit/transport/OpenSshConfigTest.java +++ b/org.eclipse.jgit.test/tst/org/eclipse/jgit/transport/OpenSshConfigTest.java @@ -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 @@ -43,10 +43,13 @@ 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")); + } } diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/transport/JschConfigSessionFactory.java b/org.eclipse.jgit/src/org/eclipse/jgit/transport/JschConfigSessionFactory.java index ce14183a5..242d1c48b 100644 --- a/org.eclipse.jgit/src/org/eclipse/jgit/transport/JschConfigSessionFactory.java +++ b/org.eclipse.jgit/src/org/eclipse/jgit/transport/JschConfigSessionFactory.java @@ -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); diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/transport/OpenSshConfig.java b/org.eclipse.jgit/src/org/eclipse/jgit/transport/OpenSshConfig.java index 8b7b60da3..ad79f3ebe 100644 --- a/org.eclipse.jgit/src/org/eclipse/jgit/transport/OpenSshConfig.java +++ b/org.eclipse.jgit/src/org/eclipse/jgit/transport/OpenSshConfig.java @@ -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. + *

+ * 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: + *

    + *
  • it splits lines of the format "keyword = value" wrongly: you'd end up + * with the value "= value". + *
  • its "Host" keyword is not case insensitive. + *
  • it doesn't handle quoted values. + *
  • JSch's OpenSSHConfig doesn't monitor for config file changes. + *
+ *

+ * 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)}. + *

*

- * 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: + *

+ *
    + *
  • This parser does not handle Match or Include keywords. + *
  • This parser does not do %-substitutions. + *
  • This parser does not do host name canonicalization (Jsch ignores it + * anyway). + *
+ * 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 hosts; + /** + * Encapsulates entries read out of the configuration file, and + * {@link Host}s created from that. + */ + private static class State { + Map entries = new LinkedHashMap<>(); + Map 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 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 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 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 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 parse(final InputStream in) throws IOException { - final Map m = new LinkedHashMap<>(); + private Map parse(final InputStream in) + throws IOException { + final Map m = new LinkedHashMap<>(); final BufferedReader br = new BufferedReader(new InputStreamReader(in)); - final List current = new ArrayList<>(4); + final List 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 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 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 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 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 options; + + private Map> multiOptions; + + private Map> 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 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 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 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 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 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 parseList(String argument) { + List 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 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> 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> item : entry.multiOptions + .entrySet()) { + List 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. *

@@ -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(); } }