From cc000f93a84b22e692a9c234486978703fdb8f30 Mon Sep 17 00:00:00 2001 From: Thomas Wolf Date: Fri, 21 Sep 2018 22:43:34 +0200 Subject: [PATCH] Factor out a JSch-independent ssh config parser Move the bulk of the basic parsing and host entry handling into a new class OpenSshConfigFile that has no dependencies on any concrete ssh implementation. Make the existing OpenSshConfig use the new parser. Introduce a new class SshConstants collecting all the various ssh- related string literals. Also use TreeMaps with a case-insensitive key comparator instead of converting keys to uppercase. Add a test to verify that keys are matched case-insensitively. Most of the parsing code was simply moved, except that the new parser supports looking up entries given host name, port, and user name, and can thus handle more %-substitutions correctly. This feature is not yet used and cannot be used with JSch since JSch only has a ConfigRepository.getConfig(String) interface. The split is still worth the trouble as it opens the way to using another ssh client altogether. Apache MINA sshd, for instance, resolves host entries giving host name, port, and user name. (Apache MINA has a built-in ssh config handling, but that has problems, too: its pattern matching is case-insensitive, and its merging of host entries if several match is not the same as in OpenSsh. But with this refactoring, it will be possible to plug in OpenSshConfigFile into an Apache MINA sshd client without dragging along JSch.) One test case that doesn't make sense anymore has been removed. It tested that repeatedly querying for a host entry returned the same object. That is no longer true since the caching has been moved to a deeper level. Bug: 520927 Change-Id: I6381d52b29099595e6eaf8b05c786aeeaefbf9cc Signed-off-by: Thomas Wolf --- .../jgit/transport/OpenSshConfigTest.java | 16 - org.eclipse.jgit/META-INF/MANIFEST.MF | 1 + .../transport/ssh/OpenSshConfigFile.java | 922 ++++++++++++++++++ .../transport/JschConfigSessionFactory.java | 3 +- .../eclipse/jgit/transport/OpenSshConfig.java | 832 +++------------- .../eclipse/jgit/transport/SshConstants.java | 202 ++++ .../jgit/transport/SshSessionFactory.java | 30 +- 7 files changed, 1263 insertions(+), 743 deletions(-) create mode 100644 org.eclipse.jgit/src/org/eclipse/jgit/internal/transport/ssh/OpenSshConfigFile.java create mode 100644 org.eclipse.jgit/src/org/eclipse/jgit/transport/SshConstants.java 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 0760585761..1a22e10f4c 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 @@ -50,7 +50,6 @@ 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; @@ -348,21 +347,6 @@ public class OpenSshConfigTest extends RepositoryTestCase { 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"); diff --git a/org.eclipse.jgit/META-INF/MANIFEST.MF b/org.eclipse.jgit/META-INF/MANIFEST.MF index 1a10ce78ab..aec1dd890e 100644 --- a/org.eclipse.jgit/META-INF/MANIFEST.MF +++ b/org.eclipse.jgit/META-INF/MANIFEST.MF @@ -83,6 +83,7 @@ Export-Package: org.eclipse.jgit.annotations;version="5.2.0", org.eclipse.jgit.internal.storage.reftree;version="5.2.0";x-friends:="org.eclipse.jgit.junit,org.eclipse.jgit.test,org.eclipse.jgit.pgm", org.eclipse.jgit.internal.submodule;version="5.2.0";x-internal:=true, org.eclipse.jgit.internal.transport.parser;version="5.2.0";x-friends:="org.eclipse.jgit.test", + org.eclipse.jgit.internal.transport.ssh;version="5.2.0";x-internal:=true, org.eclipse.jgit.lib;version="5.2.0"; uses:="org.eclipse.jgit.revwalk, org.eclipse.jgit.treewalk.filter, diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/internal/transport/ssh/OpenSshConfigFile.java b/org.eclipse.jgit/src/org/eclipse/jgit/internal/transport/ssh/OpenSshConfigFile.java new file mode 100644 index 0000000000..e8a6ba7308 --- /dev/null +++ b/org.eclipse.jgit/src/org/eclipse/jgit/internal/transport/ssh/OpenSshConfigFile.java @@ -0,0 +1,922 @@ +/* + * Copyright (C) 2008, 2017, Google Inc. + * Copyright (C) 2017, 2018, Thomas Wolf + * and other copyright owners as documented in the project's IP log. + * + * This program and the accompanying materials are made available + * under the terms of the Eclipse Distribution License v1.0 which + * accompanies this distribution, is reproduced below, and is + * available at http://www.eclipse.org/org/documents/edl-v10.php + * + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or + * without modification, are permitted provided that the following + * conditions are met: + * + * - Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * - Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following + * disclaimer in the documentation and/or other materials provided + * with the distribution. + * + * - Neither the name of the Eclipse Foundation, Inc. nor the + * names of its contributors may be used to endorse or promote + * products derived from this software without specific prior + * written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND + * CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, + * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT + * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF + * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package org.eclipse.jgit.internal.transport.ssh; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; + +import org.eclipse.jgit.annotations.NonNull; +import org.eclipse.jgit.errors.InvalidPatternException; +import org.eclipse.jgit.fnmatch.FileNameMatcher; +import org.eclipse.jgit.transport.SshConstants; +import org.eclipse.jgit.util.StringUtils; +import org.eclipse.jgit.util.SystemReader; + +/** + * Fairly complete configuration parser for the openssh ~/.ssh/config file. + *

+ * Both JSch 0.1.54 and Apache MINA sshd 2.1.0 have parsers for this, but both + * are buggy. Therefore we implement our own parser to read an openssh + * configuration file. + *

+ *

+ * Limitations compared to the full openssh 7.5 parser: + *

+ *
    + *
  • This parser does not handle Match or Include keywords. + *
  • This parser does not do host name canonicalization. + *
+ *

+ * Note that openssh's readconf.c is a validating parser; this parser does not + * validate entries. + *

+ *

+ * This config does %-substitutions for the following tokens: + *

+ *
    + *
  • %% - single % + *
  • %C - short-hand for %l%h%p%r. + *
  • %d - home directory path + *
  • %h - remote host name + *
  • %L - local host name without domain + *
  • %l - FQDN of the local host + *
  • %n - host name as specified in {@link #lookup(String, int, String)} + *
  • %p - port number; if not given in {@link #lookup(String, int, String)} + * replaced only if set in the config + *
  • %r - remote user name; if not given in + * {@link #lookup(String, int, String)} replaced only if set in the config + *
  • %u - local user name + *
+ *

+ * %i is not handled; Java has no concept of a "user ID". %T is always replaced + * by NONE. + *

+ * + * @see man + * ssh-config + */ +public class OpenSshConfigFile { + + /** + * "Host" name of the HostEntry for the default options before the first + * host block in a config file. + */ + private static final String DEFAULT_NAME = ""; //$NON-NLS-1$ + + /** The user's home directory, as key files may be relative to here. */ + private final File home; + + /** The .ssh/config file we read and monitor for updates. */ + private final File configFile; + + /** User name of the user on the host OS. */ + private final String localUserName; + + /** Modification time of {@link #configFile} when it was last loaded. */ + private long lastModified; + + /** + * Encapsulates entries read out of the configuration file, and a cache of + * fully resolved entries created from that. + */ + private static class State { + // Keyed by pattern; if a "Host" line has multiple patterns, we generate + // duplicate HostEntry objects + Map entries = new LinkedHashMap<>(); + + // Keyed by user@hostname:port + Map hosts = new HashMap<>(); + + @Override + @SuppressWarnings("nls") + public String toString() { + return "State [entries=" + entries + ", hosts=" + hosts + "]"; + } + } + + /** State read from the config file, plus the cache. */ + private State state; + + /** + * Creates a new {@link OpenSshConfigFile} that will read the config from + * file {@code config} use the given file {@code home} as "home" directory. + * + * @param home + * user's home directory for the purpose of ~ replacement + * @param config + * file to load. + * @param localUserName + * user name of the current user on the local host OS + */ + public OpenSshConfigFile(@NonNull File home, @NonNull File config, + @NonNull String localUserName) { + this.home = home; + this.configFile = config; + this.localUserName = localUserName; + state = new State(); + } + + /** + * Locate the configuration for a specific host request. + * + * @param hostName + * the name the user has supplied to the SSH tool. This may be a + * real host name, or it may just be a "Host" block in the + * configuration file. + * @param port + * the user supplied; <= 0 if none + * @param userName + * the user supplied, may be {@code null} or empty if none given + * @return r configuration for the requested name. + */ + @NonNull + public HostEntry lookup(@NonNull String hostName, int port, + String userName) { + final State cache = refresh(); + String cacheKey = toCacheKey(hostName, port, userName); + HostEntry h = cache.hosts.get(cacheKey); + if (h != null) { + return h; + } + HostEntry fullConfig = new HostEntry(); + // Initialize with default entries at the top of the file, before the + // first Host block. + fullConfig.merge(cache.entries.get(DEFAULT_NAME)); + for (Map.Entry e : cache.entries.entrySet()) { + String pattern = e.getKey(); + if (isHostMatch(pattern, hostName)) { + fullConfig.merge(e.getValue()); + } + } + fullConfig.substitute(hostName, port, userName, localUserName, home); + cache.hosts.put(cacheKey, fullConfig); + return fullConfig; + } + + @NonNull + private String toCacheKey(@NonNull String hostName, int port, + String userName) { + String key = hostName; + if (port > 0) { + key = key + ':' + Integer.toString(port); + } + if (userName != null && !userName.isEmpty()) { + key = userName + '@' + key; + } + return key; + } + + private synchronized State refresh() { + final long mtime = configFile.lastModified(); + if (mtime != lastModified) { + State newState = new State(); + try (BufferedReader br = Files + .newBufferedReader(configFile.toPath(), UTF_8)) { + newState.entries = parse(br); + } catch (IOException | RuntimeException none) { + // Ignore -- we'll set and return an empty state + } + lastModified = mtime; + state = newState; + } + return state; + } + + private Map parse(BufferedReader reader) + throws IOException { + final Map entries = new LinkedHashMap<>(); + 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); + entries.put(DEFAULT_NAME, defaults); + + while ((line = reader.readLine()) != null) { + line = line.trim(); + if (line.isEmpty() || line.startsWith("#")) { //$NON-NLS-1$ + continue; + } + 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(SshConstants.HOST, keyword)) { + current.clear(); + for (String name : parseList(argValue)) { + if (name == null || name.isEmpty()) { + // null should not occur, but better be safe than sorry. + continue; + } + HostEntry c = entries.get(name); + if (c == null) { + c = new HostEntry(); + entries.put(name, c); + } + current.add(c); + } + continue; + } + + 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 (HostEntry.isListKey(keyword)) { + List args = validate(keyword, parseList(argValue)); + for (HostEntry entry : current) { + entry.setValue(keyword, args); + } + } else if (!argValue.isEmpty()) { + argValue = validate(keyword, dequote(argValue)); + for (HostEntry entry : current) { + entry.setValue(keyword, argValue); + } + } + } + + return entries; + } + + /** + * 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, but not containing {@code null} + */ + private 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); + if (stop < start) { + // No closing double quote: skip + break; + } + result.add(argument.substring(start, 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; + } + + /** + * Hook to perform validation on a single value, or to sanitize it. If this + * throws an (unchecked) exception, parsing of the file is abandoned. + * + * @param key + * of the entry + * @param value + * as read from the config file + * @return the validated and possibly sanitized value + */ + protected String validate(String key, String value) { + if (String.CASE_INSENSITIVE_ORDER.compare(key, + SshConstants.PREFERRED_AUTHENTICATIONS) == 0) { + return stripWhitespace(value); + } + return value; + } + + /** + * Hook to perform validation on values, or to sanitize them. If this throws + * an (unchecked) exception, parsing of the file is abandoned. + * + * @param key + * of the entry + * @param value + * list of arguments as read from the config file + * @return a {@link List} of values, possibly empty and possibly containing + * empty elements, but not containing {@code null} + */ + protected List validate(String key, List value) { + return value; + } + + private static boolean isHostMatch(String pattern, String name) { + if (pattern.startsWith("!")) { //$NON-NLS-1$ + return !patternMatchesHost(pattern.substring(1), name); + } else { + return patternMatchesHost(pattern, name); + } + } + + private static boolean patternMatchesHost(String pattern, 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); + } + } + + private static String dequote(String value) { + if (value.startsWith("\"") && value.endsWith("\"") //$NON-NLS-1$ //$NON-NLS-2$ + && value.length() > 1) + return value.substring(1, value.length() - 1); + return value; + } + + private static String stripWhitespace(String value) { + final StringBuilder b = new StringBuilder(); + for (int i = 0; i < value.length(); i++) { + if (!Character.isSpaceChar(value.charAt(i))) + b.append(value.charAt(i)); + } + return b.toString(); + } + + private static File toFile(String path, File home) { + if (path.startsWith("~/") || path.startsWith("~" + File.separator)) { //$NON-NLS-1$ //$NON-NLS-2$ + return new File(home, path.substring(2)); + } + File ret = new File(path); + if (ret.isAbsolute()) { + return ret; + } + return new File(home, path); + } + + /** + * Converts a positive value into an {@code int}. + * + * @param value + * to convert + * @return the value, or -1 if it wasn't a positive integral value + */ + public static int positive(String value) { + if (value != null) { + try { + return Integer.parseUnsignedInt(value); + } catch (NumberFormatException e) { + // Ignore + } + } + return -1; + } + + /** + * Converts a ssh config flag value (yes/true/on - no/false/off) into an + * {@code boolean}. + * + * @param value + * to convert + * @return {@code true} if {@code value} is "yes", "on", or "true"; + * {@code false} otherwise + */ + public static boolean flag(String value) { + if (value == null) { + return false; + } + return SshConstants.YES.equals(value) || SshConstants.ON.equals(value) + || SshConstants.TRUE.equals(value); + } + + /** + * Retrieves the local user name as given in the constructor. + * + * @return the user name + */ + public String getLocalUserName() { + return localUserName; + } + + /** + * A host entry from the ssh config file. Any merging of global values and + * of several matching host entries, %-substitutions, and ~ replacement have + * all been done. + */ + public static class HostEntry { + + /** + * 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 TreeSet<>( + String.CASE_INSENSITIVE_ORDER); + + static { + MULTI_KEYS.add(SshConstants.CERTIFICATE_FILE); + MULTI_KEYS.add(SshConstants.IDENTITY_FILE); + MULTI_KEYS.add(SshConstants.LOCAL_FORWARD); + MULTI_KEYS.add(SshConstants.REMOTE_FORWARD); + MULTI_KEYS.add(SshConstants.SEND_ENV); + } + + /** + * 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 TreeSet<>( + String.CASE_INSENSITIVE_ORDER); + + static { + LIST_KEYS.add(SshConstants.CANONICAL_DOMAINS); + LIST_KEYS.add(SshConstants.GLOBAL_KNOWN_HOSTS_FILE); + LIST_KEYS.add(SshConstants.SEND_ENV); + LIST_KEYS.add(SshConstants.USER_KNOWN_HOSTS_FILE); + } + + private Map options; + + private Map> multiOptions; + + private Map> listOptions; + + /** + * Retrieves the value of a single-valued key, or the first is the key + * has multiple values. Keys are case-insensitive, so + * {@code getValue("HostName") == getValue("HOSTNAME")}. + * + * @param key + * to get the value of + * @return the value, or {@code null} if none + */ + public String getValue(String key) { + String result = options != null ? options.get(key) : null; + if (result == null) { + // Let's be lenient and return at least the first value from + // a list-valued or multi-valued key. + List values = listOptions != null ? listOptions.get(key) + : null; + if (values == null) { + values = multiOptions != null ? multiOptions.get(key) + : null; + } + if (values != null && !values.isEmpty()) { + result = values.get(0); + } + } + return result; + } + + /** + * Retrieves the values of a multi or list-valued key. Keys are + * case-insensitive, so + * {@code getValue("HostName") == getValue("HOSTNAME")}. + * + * @param key + * to get the values of + * @return a possibly empty list of values + */ + public List getValues(String key) { + List values = listOptions != null ? listOptions.get(key) + : null; + if (values == null) { + values = multiOptions != null ? multiOptions.get(key) : null; + } + if (values == null || values.isEmpty()) { + return new ArrayList<>(); + } + return new ArrayList<>(values); + } + + /** + * Sets the value of a single-valued key if it not set yet, or adds a + * value to a multi-valued key. If the value is {@code null}, the key is + * removed altogether, whether it is single-, list-, or multi-valued. + * + * @param key + * to modify + * @param value + * to set or add + */ + public void setValue(String key, String value) { + if (value == null) { + if (multiOptions != null) { + multiOptions.remove(key); + } + if (listOptions != null) { + listOptions.remove(key); + } + if (options != null) { + options.remove(key); + } + return; + } + if (MULTI_KEYS.contains(key)) { + if (multiOptions == null) { + multiOptions = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + } + List values = multiOptions.get(key); + if (values == null) { + values = new ArrayList<>(4); + multiOptions.put(key, values); + } + values.add(value); + } else { + if (options == null) { + options = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + } + if (!options.containsKey(key)) { + options.put(key, value); + } + } + } + + /** + * Sets the values of a multi- or list-valued key. + * + * @param key + * to set + * @param values + * a non-empty list of values + */ + public void setValue(String key, List values) { + if (values.isEmpty()) { + return; + } + // 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(key)) { + if (multiOptions == null) { + multiOptions = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + } + List items = multiOptions.get(key); + if (items == null) { + items = new ArrayList<>(values); + multiOptions.put(key, items); + } else { + items.addAll(values); + } + } else { + if (listOptions == null) { + listOptions = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + } + if (!listOptions.containsKey(key)) { + listOptions.put(key, values); + } + } + } + + /** + * Does the key take a whitespace-separated list of values? + * + * @param key + * to check + * @return {@code true} if the key is a list-valued key. + */ + public static boolean isListKey(String key) { + return LIST_KEYS.contains(key.toUpperCase(Locale.ROOT)); + } + + 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 TreeMap<>(String.CASE_INSENSITIVE_ORDER); + } + 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 TreeMap<>(String.CASE_INSENSITIVE_ORDER); + } + 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 TreeMap<>(String.CASE_INSENSITIVE_ORDER); + } + 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()); + } + } + } + } + + private List substitute(List values, String allowed, + Replacer r) { + List result = new ArrayList<>(values.size()); + for (String value : values) { + result.add(r.substitute(value, allowed)); + } + return result; + } + + private List replaceTilde(List values, File home) { + List result = new ArrayList<>(values.size()); + for (String value : values) { + result.add(toFile(value, home).getPath()); + } + return result; + } + + void substitute(String originalHostName, int port, String userName, + String localUserName, File home) { + int p = port >= 0 ? port : positive(getValue(SshConstants.PORT)); + if (p < 0) { + p = SshConstants.SSH_DEFAULT_PORT; + } + String u = userName != null && !userName.isEmpty() ? userName + : getValue(SshConstants.USER); + if (u == null || u.isEmpty()) { + u = localUserName; + } + Replacer r = new Replacer(originalHostName, p, u, localUserName, + home); + if (options != null) { + // HOSTNAME first + String hostName = options.get(SshConstants.HOST_NAME); + if (hostName == null || hostName.isEmpty()) { + options.put(SshConstants.HOST_NAME, originalHostName); + } else { + hostName = r.substitute(hostName, "h"); //$NON-NLS-1$ + options.put(SshConstants.HOST_NAME, hostName); + r.update('h', hostName); + } + } + if (multiOptions != null) { + List values = multiOptions + .get(SshConstants.IDENTITY_FILE); + if (values != null) { + values = substitute(values, "dhlru", r); //$NON-NLS-1$ + values = replaceTilde(values, home); + multiOptions.put(SshConstants.IDENTITY_FILE, values); + } + values = multiOptions.get(SshConstants.CERTIFICATE_FILE); + if (values != null) { + values = substitute(values, "dhlru", r); //$NON-NLS-1$ + values = replaceTilde(values, home); + multiOptions.put(SshConstants.CERTIFICATE_FILE, values); + } + } + if (listOptions != null) { + List values = listOptions + .get(SshConstants.USER_KNOWN_HOSTS_FILE); + if (values != null) { + values = replaceTilde(values, home); + listOptions.put(SshConstants.USER_KNOWN_HOSTS_FILE, values); + } + } + if (options != null) { + // HOSTNAME already done above + String value = options.get(SshConstants.IDENTITY_AGENT); + if (value != null) { + value = r.substitute(value, "dhlru"); //$NON-NLS-1$ + value = toFile(value, home).getPath(); + options.put(SshConstants.IDENTITY_AGENT, value); + } + value = options.get(SshConstants.CONTROL_PATH); + if (value != null) { + value = r.substitute(value, "ChLlnpru"); //$NON-NLS-1$ + value = toFile(value, home).getPath(); + options.put(SshConstants.CONTROL_PATH, value); + } + value = options.get(SshConstants.LOCAL_COMMAND); + if (value != null) { + value = r.substitute(value, "CdhlnprTu"); //$NON-NLS-1$ + options.put(SshConstants.LOCAL_COMMAND, value); + } + value = options.get(SshConstants.REMOTE_COMMAND); + if (value != null) { + value = r.substitute(value, "Cdhlnpru"); //$NON-NLS-1$ + options.put(SshConstants.REMOTE_COMMAND, value); + } + value = options.get(SshConstants.PROXY_COMMAND); + if (value != null) { + value = r.substitute(value, "hpr"); //$NON-NLS-1$ + options.put(SshConstants.PROXY_COMMAND, value); + } + } + // Match is not implemented and would need to be done elsewhere + // anyway. + } + + /** + * Retrieves an unmodifiable map of all single-valued options, with + * case-insensitive lookup by keys. + * + * @return all single-valued options + */ + @NonNull + public Map getOptions() { + if (options == null) { + return Collections.emptyMap(); + } + return Collections.unmodifiableMap(options); + } + + /** + * Retrieves an unmodifiable map of all multi-valued options, with + * case-insensitive lookup by keys. + * + * @return all multi-valued options + */ + @NonNull + public Map> getMultiValuedOptions() { + if (listOptions == null && multiOptions == null) { + return Collections.emptyMap(); + } + Map> allValues = new TreeMap<>( + String.CASE_INSENSITIVE_ORDER); + if (multiOptions != null) { + allValues.putAll(multiOptions); + } + if (listOptions != null) { + allValues.putAll(listOptions); + } + return Collections.unmodifiableMap(allValues); + } + + @Override + @SuppressWarnings("nls") + public String toString() { + return "HostEntry [options=" + options + ", multiOptions=" + + multiOptions + ", listOptions=" + listOptions + "]"; + } + } + + private static class Replacer { + private final Map replacements = new HashMap<>(); + + public Replacer(String host, int port, String user, + String localUserName, File home) { + replacements.put(Character.valueOf('%'), "%"); //$NON-NLS-1$ + replacements.put(Character.valueOf('d'), home.getPath()); + replacements.put(Character.valueOf('h'), host); + String localhost = SystemReader.getInstance().getHostname(); + replacements.put(Character.valueOf('l'), localhost); + int period = localhost.indexOf('.'); + if (period > 0) { + localhost = localhost.substring(0, period); + } + replacements.put(Character.valueOf('L'), localhost); + replacements.put(Character.valueOf('n'), host); + replacements.put(Character.valueOf('p'), Integer.toString(port)); + replacements.put(Character.valueOf('r'), user == null ? "" : user); //$NON-NLS-1$ + replacements.put(Character.valueOf('u'), localUserName); + replacements.put(Character.valueOf('C'), + substitute("%l%h%p%r", "hlpr")); //$NON-NLS-1$ //$NON-NLS-2$ + replacements.put(Character.valueOf('T'), "NONE"); //$NON-NLS-1$ + } + + public void update(char key, String value) { + replacements.put(Character.valueOf(key), value); + if ("lhpr".indexOf(key) >= 0) { //$NON-NLS-1$ + replacements.put(Character.valueOf('C'), + substitute("%l%h%p%r", "hlpr")); //$NON-NLS-1$ //$NON-NLS-2$ + } + } + + public String substitute(String input, String allowed) { + if (input == null || input.length() <= 1 + || input.indexOf('%') < 0) { + return input; + } + StringBuilder builder = new StringBuilder(); + int start = 0; + int length = input.length(); + while (start < length) { + int percent = input.indexOf('%', start); + if (percent < 0 || percent + 1 >= length) { + builder.append(input.substring(start)); + break; + } + String replacement = null; + char ch = input.charAt(percent + 1); + if (ch == '%' || allowed.indexOf(ch) >= 0) { + replacement = replacements.get(Character.valueOf(ch)); + } + if (replacement == null) { + builder.append(input.substring(start, percent + 2)); + } else { + builder.append(input.substring(start, percent)) + .append(replacement); + } + start = percent + 2; + } + return builder.toString(); + } + } + + /** {@inheritDoc} */ + @Override + @SuppressWarnings("nls") + public String toString() { + return "OpenSshConfig [home=" + home + ", configFile=" + configFile + + ", lastModified=" + lastModified + ", state=" + state + "]"; + } +} 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 7924ec8c23..0bdd6ba812 100644 --- a/org.eclipse.jgit/src/org/eclipse/jgit/transport/JschConfigSessionFactory.java +++ b/org.eclipse.jgit/src/org/eclipse/jgit/transport/JschConfigSessionFactory.java @@ -52,7 +52,6 @@ package org.eclipse.jgit.transport; import static java.util.stream.Collectors.joining; import static java.util.stream.Collectors.toList; -import static org.eclipse.jgit.transport.OpenSshConfig.SSH_PORT; import java.io.File; import java.io.FileInputStream; @@ -275,7 +274,7 @@ public abstract class JschConfigSessionFactory extends SshSessionFactory { } private static String hostName(Session s) { - if (s.getPort() == SSH_PORT) { + if (s.getPort() == SshConstants.SSH_DEFAULT_PORT) { return s.getHost(); } return String.format("[%s]:%d", s.getHost(), //$NON-NLS-1$ 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 a5fa3fee35..32e1dff234 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, 2017, Google Inc. + * Copyright (C) 2008, 2018, Google Inc. * and other copyright owners as documented in the project's IP log. * * This program and the accompanying materials are made available @@ -43,31 +43,16 @@ package org.eclipse.jgit.transport; -import static java.nio.charset.StandardCharsets.UTF_8; +import static org.eclipse.jgit.internal.transport.ssh.OpenSshConfigFile.positive; -import java.io.BufferedReader; import java.io.File; -import java.io.FileInputStream; -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.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 java.util.TreeMap; -import org.eclipse.jgit.errors.InvalidPatternException; -import org.eclipse.jgit.fnmatch.FileNameMatcher; -import org.eclipse.jgit.lib.Constants; +import org.eclipse.jgit.internal.transport.ssh.OpenSshConfigFile; +import org.eclipse.jgit.internal.transport.ssh.OpenSshConfigFile.HostEntry; import org.eclipse.jgit.util.FS; -import org.eclipse.jgit.util.StringUtils; -import org.eclipse.jgit.util.SystemReader; import com.jcraft.jsch.ConfigRepository; @@ -85,8 +70,7 @@ import com.jcraft.jsch.ConfigRepository; *
  • 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 + * This parser makes the critical options available to * {@link org.eclipse.jgit.transport.SshSessionFactory} via * {@link org.eclipse.jgit.transport.OpenSshConfig.Host} objects returned by * {@link #lookup(String)}, and implements a fully conforming @@ -94,49 +78,11 @@ import com.jcraft.jsch.ConfigRepository; * {@link com.jcraft.jsch.ConfigRepository.Config}s via * {@link #getConfig(String)}. *

    - *

    - * Limitations compared to the full OpenSSH 7.5 parser: - *

    - *
      - *
    • This parser does not handle Match or Include keywords. - *
    • 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 org.eclipse.jgit.transport.OpenSshConfig.Host} object. - *

    - *

    - * This config does %-substitutions for the following tokens: - *

    - *
      - *
    • %% - single % - *
    • %C - short-hand for %l%h%p%r. See %p and %r below; the replacement may be - * done partially only and may leave %p or %r or both unreplaced. - *
    • %d - home directory path - *
    • %h - remote host name - *
    • %L - local host name without domain - *
    • %l - FQDN of the local host - *
    • %n - host name as specified in {@link #lookup(String)} - *
    • %p - port number; replaced only if set in the config - *
    • %r - remote user name; replaced only if set in the config - *
    • %u - local user name - *
    - *

    - * If the config doesn't set the port or the remote user name, %p and %r remain - * un-substituted. It's the caller's responsibility to replace them with values - * obtained from the connection URI. %i is not handled; Java has no concept of a - * "user ID". - *

    + * + * @see OpenSshConfigFile */ public class OpenSshConfig implements ConfigRepository { - /** IANA assigned port number for SSH. */ - static final int SSH_PORT = 22; - /** * Obtain the user's configuration data. *

    @@ -155,43 +101,17 @@ public class OpenSshConfig implements ConfigRepository { if (home == null) home = new File(".").getAbsoluteFile(); //$NON-NLS-1$ - final File config = new File(new File(home, ".ssh"), Constants.CONFIG); //$NON-NLS-1$ - final OpenSshConfig osc = new OpenSshConfig(home, config); - osc.refresh(); - return osc; - } - - /** The user's home directory, as key files may be relative to here. */ - private final File home; - - /** The .ssh/config file we read and monitor for updates. */ - private final File configFile; - - /** Modification time of {@link #configFile} when it was last loaded. */ - private long lastModified; - - /** - * 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<>(); - - @Override - @SuppressWarnings("nls") - public String toString() { - return "State [entries=" + entries + ", hosts=" + hosts + "]"; - } + final File config = new File(new File(home, SshConstants.SSH_DIR), + SshConstants.CONFIG); + return new OpenSshConfig(home, config); } - /** State read from the config file, plus {@link Host}s created from it. */ - private State state; + /** The base file. */ + private OpenSshConfigFile configFile; OpenSshConfig(File h, File cfg) { - home = h; - configFile = cfg; - state = new State(); + configFile = new OpenSshConfigFile(h, cfg, + SshSessionFactory.getLocalUserName()); } /** @@ -204,604 +124,8 @@ public class OpenSshConfig implements ConfigRepository { * @return r configuration for the requested name. Never null. */ public Host lookup(String hostName) { - final State cache = refresh(); - Host h = cache.hosts.get(hostName); - if (h != null) { - return h; - } - 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 (Map.Entry e : cache.entries.entrySet()) { - String key = e.getKey(); - if (isHostMatch(key, hostName)) { - fullConfig.merge(e.getValue()); - } - } - fullConfig.substitute(hostName, home); - h = new Host(fullConfig, hostName, home); - cache.hosts.put(hostName, h); - return h; - } - - private synchronized State refresh() { - final long mtime = configFile.lastModified(); - if (mtime != lastModified) { - 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 state; - } - - private Map parse(InputStream in) - throws IOException { - final Map m = new LinkedHashMap<>(); - final BufferedReader br = new BufferedReader( - new InputStreamReader(in, UTF_8)); - 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.isEmpty() || line.startsWith("#")) { //$NON-NLS-1$ - continue; - } - 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 (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 HostEntry(); - m.put(name, c); - } - current.add(c); - } - continue; - } - - 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 (HostEntry.isListKey(keyword)) { - List args = HostEntry.parseList(argValue); - for (HostEntry entry : current) { - entry.setValue(keyword, args); - } - } else if (!argValue.isEmpty()) { - argValue = dequote(argValue); - for (HostEntry entry : current) { - entry.setValue(keyword, argValue); - } - } - } - - return m; - } - - 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 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); - } - } - - private static String dequote(String value) { - if (value.startsWith("\"") && value.endsWith("\"") //$NON-NLS-1$ //$NON-NLS-2$ - && value.length() > 1) - return value.substring(1, value.length() - 1); - return value; - } - - private static String nows(String value) { - final StringBuilder b = new StringBuilder(); - for (int i = 0; i < value.length(); i++) { - if (!Character.isSpaceChar(value.charAt(i))) - b.append(value.charAt(i)); - } - return b.toString(); - } - - private static Boolean yesno(String value) { - if (StringUtils.equalsIgnoreCase("yes", value)) //$NON-NLS-1$ - return Boolean.TRUE; - return Boolean.FALSE; - } - - private static 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); - } - - private static int positive(String value) { - if (value != null) { - try { - return Integer.parseUnsignedInt(value); - } catch (NumberFormatException e) { - // Ignore - } - } - return -1; - } - - static String userName() { - return AccessController.doPrivileged(new PrivilegedAction() { - @Override - public String run() { - return SystemReader.getInstance() - .getProperty(Constants.OS_USER_NAME_KEY); - } - }); - } - - 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[0]); - } - - 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); - if (stop < start) { - // No closing double quote: skip - break; - } - result.add(argument.substring(start, 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()); - } - } - } - } - - private class Replacer { - private final Map replacements = new HashMap<>(); - - public Replacer(String originalHostName, File home) { - replacements.put(Character.valueOf('%'), "%"); //$NON-NLS-1$ - replacements.put(Character.valueOf('d'), home.getPath()); - // Needs special treatment... - String host = getValue("HOSTNAME"); //$NON-NLS-1$ - replacements.put(Character.valueOf('h'), originalHostName); - if (host != null && host.indexOf('%') >= 0) { - host = substitute(host, "h"); //$NON-NLS-1$ - options.put("HOSTNAME", host); //$NON-NLS-1$ - } - if (host != null) { - replacements.put(Character.valueOf('h'), host); - } - String localhost = SystemReader.getInstance().getHostname(); - replacements.put(Character.valueOf('l'), localhost); - int period = localhost.indexOf('.'); - if (period > 0) { - localhost = localhost.substring(0, period); - } - replacements.put(Character.valueOf('L'), localhost); - replacements.put(Character.valueOf('n'), originalHostName); - replacements.put(Character.valueOf('p'), getValue("PORT")); //$NON-NLS-1$ - replacements.put(Character.valueOf('r'), getValue("USER")); //$NON-NLS-1$ - replacements.put(Character.valueOf('u'), userName()); - replacements.put(Character.valueOf('C'), - substitute("%l%h%p%r", "hlpr")); //$NON-NLS-1$ //$NON-NLS-2$ - } - - public String substitute(String input, String allowed) { - if (input == null || input.length() <= 1 - || input.indexOf('%') < 0) { - return input; - } - StringBuilder builder = new StringBuilder(); - int start = 0; - int length = input.length(); - while (start < length) { - int percent = input.indexOf('%', start); - if (percent < 0 || percent + 1 >= length) { - builder.append(input.substring(start)); - break; - } - String replacement = null; - char ch = input.charAt(percent + 1); - if (ch == '%' || allowed.indexOf(ch) >= 0) { - replacement = replacements.get(Character.valueOf(ch)); - } - if (replacement == null) { - builder.append(input.substring(start, percent + 2)); - } else { - builder.append(input.substring(start, percent)) - .append(replacement); - } - start = percent + 2; - } - return builder.toString(); - } - } - - private List substitute(List values, String allowed, - Replacer r) { - List result = new ArrayList<>(values.size()); - for (String value : values) { - result.add(r.substitute(value, allowed)); - } - return result; - } - - private List replaceTilde(List values, File home) { - List result = new ArrayList<>(values.size()); - for (String value : values) { - result.add(toFile(value, home).getPath()); - } - return result; - } - - protected void substitute(String originalHostName, File home) { - Replacer r = new Replacer(originalHostName, home); - if (multiOptions != null) { - List values = multiOptions.get("IDENTITYFILE"); //$NON-NLS-1$ - if (values != null) { - values = substitute(values, "dhlru", r); //$NON-NLS-1$ - values = replaceTilde(values, home); - multiOptions.put("IDENTITYFILE", values); //$NON-NLS-1$ - } - values = multiOptions.get("CERTIFICATEFILE"); //$NON-NLS-1$ - if (values != null) { - values = substitute(values, "dhlru", r); //$NON-NLS-1$ - values = replaceTilde(values, home); - multiOptions.put("CERTIFICATEFILE", values); //$NON-NLS-1$ - } - } - if (listOptions != null) { - List values = listOptions.get("GLOBALKNOWNHOSTSFILE"); //$NON-NLS-1$ - if (values != null) { - values = replaceTilde(values, home); - listOptions.put("GLOBALKNOWNHOSTSFILE", values); //$NON-NLS-1$ - } - values = listOptions.get("USERKNOWNHOSTSFILE"); //$NON-NLS-1$ - if (values != null) { - values = replaceTilde(values, home); - listOptions.put("USERKNOWNHOSTSFILE", values); //$NON-NLS-1$ - } - } - if (options != null) { - // HOSTNAME already done in Replacer constructor - String value = options.get("IDENTITYAGENT"); //$NON-NLS-1$ - if (value != null) { - value = r.substitute(value, "dhlru"); //$NON-NLS-1$ - value = toFile(value, home).getPath(); - options.put("IDENTITYAGENT", value); //$NON-NLS-1$ - } - } - // Match is not implemented and would need to be done elsewhere - // anyway. ControlPath, LocalCommand, ProxyCommand, and - // RemoteCommand are not used by Jsch. - } - - @Override - @SuppressWarnings("nls") - public String toString() { - return "HostEntry [options=" + options + ", multiOptions=" - + multiOptions + ", listOptions=" + listOptions + "]"; - } + HostEntry entry = configFile.lookup(hostName, -1, null); + return new Host(entry, hostName, configFile.getLocalUserName()); } /** @@ -832,8 +156,34 @@ public class OpenSshConfig implements ConfigRepository { int connectionAttempts; + private HostEntry entry; + private Config config; + // See com.jcraft.jsch.OpenSSHConfig. Translates some command-line keys + // to ssh-config keys. + private static final Map KEY_MAP = new TreeMap<>( + String.CASE_INSENSITIVE_ORDER); + + static { + KEY_MAP.put("kex", SshConstants.KEX_ALGORITHMS); //$NON-NLS-1$ + KEY_MAP.put("server_host_key", SshConstants.HOST_KEY_ALGORITHMS); //$NON-NLS-1$ + KEY_MAP.put("cipher.c2s", SshConstants.CIPHERS); //$NON-NLS-1$ + KEY_MAP.put("cipher.s2c", SshConstants.CIPHERS); //$NON-NLS-1$ + KEY_MAP.put("mac.c2s", SshConstants.MACS); //$NON-NLS-1$ + KEY_MAP.put("mac.s2c", SshConstants.MACS); //$NON-NLS-1$ + KEY_MAP.put("compression.s2c", SshConstants.COMPRESSION); //$NON-NLS-1$ + KEY_MAP.put("compression.c2s", SshConstants.COMPRESSION); //$NON-NLS-1$ + KEY_MAP.put("compression_level", "CompressionLevel"); //$NON-NLS-1$ //$NON-NLS-2$ + KEY_MAP.put("MaxAuthTries", //$NON-NLS-1$ + SshConstants.NUMBER_OF_PASSWORD_PROMPTS); + } + + private static String mapKey(String key) { + String k = KEY_MAP.get(key); + return k != null ? k : key; + } + /** * Creates a new uninitialized {@link Host}. */ @@ -841,9 +191,9 @@ public class OpenSshConfig implements ConfigRepository { // For API backwards compatibility with pre-4.9 JGit } - Host(Config config, String hostName, File homeDir) { - this.config = config; - complete(hostName, homeDir); + Host(HostEntry entry, String hostName, String localUserName) { + this.entry = entry; + complete(hostName, localUserName); } /** @@ -913,42 +263,84 @@ public class OpenSshConfig implements ConfigRepository { } - private void complete(String initialHostName, File homeDir) { + private void complete(String initialHostName, String localUserName) { // Try to set values from the options. - hostName = config.getHostname(); - user = config.getUser(); - port = config.getPort(); + hostName = entry.getValue(SshConstants.HOST_NAME); + user = entry.getValue(SshConstants.USER); + port = positive(entry.getValue(SshConstants.PORT)); 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); - } + entry.getValue(SshConstants.CONNECTION_ATTEMPTS)); + strictHostKeyChecking = entry + .getValue(SshConstants.STRICT_HOST_KEY_CHECKING); + batchMode = Boolean.valueOf(OpenSshConfigFile + .flag(entry.getValue(SshConstants.BATCH_MODE))); + preferredAuthentications = entry + .getValue(SshConstants.PREFERRED_AUTHENTICATIONS); // Fill in defaults if still not set - if (hostName == null) { + if (hostName == null || hostName.isEmpty()) { hostName = initialHostName; } - if (user == null) { - user = OpenSshConfig.userName(); + if (user == null || user.isEmpty()) { + user = localUserName; } if (port <= 0) { - port = OpenSshConfig.SSH_PORT; + port = SshConstants.SSH_DEFAULT_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); + List identityFiles = entry + .getValues(SshConstants.IDENTITY_FILE); + if (identityFiles != null && !identityFiles.isEmpty()) { + identityFile = new File(identityFiles.get(0)); } } Config getConfig() { + if (config == null) { + config = new Config() { + + @Override + public String getHostname() { + return Host.this.getHostName(); + } + + @Override + public String getUser() { + return Host.this.getUser(); + } + + @Override + public int getPort() { + return Host.this.getPort(); + } + + @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$ + if (!OpenSshConfigFile.flag( + Host.this.entry.getValue(mapKey(key)))) { + return "none,zlib@openssh.com,zlib"; //$NON-NLS-1$ + } + return "zlib@openssh.com,zlib,none"; //$NON-NLS-1$ + } + return Host.this.entry.getValue(mapKey(key)); + } + + @Override + public String[] getValues(String key) { + List values = Host.this.entry + .getValues(mapKey(key)); + if (values == null) { + return new String[0]; + } + return values.toArray(new String[0]); + } + }; + } return config; } @@ -960,7 +352,7 @@ public class OpenSshConfig implements ConfigRepository { + ", preferredAuthentications=" + preferredAuthentications + ", batchMode=" + batchMode + ", strictHostKeyChecking=" + strictHostKeyChecking + ", connectionAttempts=" - + connectionAttempts + ", config=" + config + "]"; + + connectionAttempts + ", entry=" + entry + "]"; } } @@ -980,9 +372,7 @@ public class OpenSshConfig implements ConfigRepository { /** {@inheritDoc} */ @Override - @SuppressWarnings("nls") public String toString() { - return "OpenSshConfig [home=" + home + ", configFile=" + configFile - + ", lastModified=" + lastModified + ", state=" + state + "]"; + return "OpenSshConfig [configFile=" + configFile + ']'; //$NON-NLS-1$ } } diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/transport/SshConstants.java b/org.eclipse.jgit/src/org/eclipse/jgit/transport/SshConstants.java new file mode 100644 index 0000000000..fd6301bb46 --- /dev/null +++ b/org.eclipse.jgit/src/org/eclipse/jgit/transport/SshConstants.java @@ -0,0 +1,202 @@ +/* + * Copyright (C) 2018 Thomas Wolf + * and other copyright owners as documented in the project's IP log. + * + * This program and the accompanying materials are made available + * under the terms of the Eclipse Distribution License v1.0 which + * accompanies this distribution, is reproduced below, and is + * available at http://www.eclipse.org/org/documents/edl-v10.php + * + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or + * without modification, are permitted provided that the following + * conditions are met: + * + * - Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * + * - Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following + * disclaimer in the documentation and/or other materials provided + * with the distribution. + * + * - Neither the name of the Eclipse Foundation, Inc. nor the + * names of its contributors may be used to endorse or promote + * products derived from this software without specific prior + * written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND + * CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, + * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR + * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT + * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, + * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF + * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package org.eclipse.jgit.transport; + +import org.eclipse.jgit.lib.Constants; + +/** + * Constants relating to ssh. + * + * @since 5.2 + */ +@SuppressWarnings("nls") +public final class SshConstants { + + private SshConstants() { + // No instances, please. + } + + /** IANA assigned port number for ssh. */ + public static final int SSH_DEFAULT_PORT = 22; + + /** URI scheme for ssh. */ + public static final String SSH_SCHEME = "ssh"; + + /** URI scheme for sftp. */ + public static final String SFTP_SCHEME = "sftp"; + + /** Default name for a ssh directory. */ + public static final String SSH_DIR = ".ssh"; + + /** Name of the ssh config file. */ + public static final String CONFIG = Constants.CONFIG; + + /** Default name of the user "known hosts" file. */ + public static final String KNOWN_HOSTS = "known_hosts"; + + // Config file keys + + /** Key in an ssh config file. */ + public static final String BATCH_MODE = "BatchMode"; + + /** Key in an ssh config file. */ + public static final String CANONICAL_DOMAINS = "CanonicalDomains"; + + /** Key in an ssh config file. */ + public static final String CERTIFICATE_FILE = "CertificateFile"; + + /** Key in an ssh config file. */ + public static final String CIPHERS = "Ciphers"; + + /** Key in an ssh config file. */ + public static final String COMPRESSION = "Compression"; + + /** Key in an ssh config file. */ + public static final String CONNECTION_ATTEMPTS = "ConnectionAttempts"; + + /** Key in an ssh config file. */ + public static final String CONTROL_PATH = "ControlPath"; + + /** Key in an ssh config file. */ + public static final String GLOBAL_KNOWN_HOSTS_FILE = "GlobalKnownHostsFile"; + + /** Key in an ssh config file. */ + public static final String HOST = "Host"; + + /** Key in an ssh config file. */ + public static final String HOST_KEY_ALGORITHMS = "HostKeyAlgorithms"; + + /** Key in an ssh config file. */ + public static final String HOST_NAME = "HostName"; + + /** Key in an ssh config file. */ + public static final String IDENTITIES_ONLY = "IdentitiesOnly"; + + /** Key in an ssh config file. */ + public static final String IDENTITY_AGENT = "IdentityAgent"; + + /** Key in an ssh config file. */ + public static final String IDENTITY_FILE = "IdentityFile"; + + /** Key in an ssh config file. */ + public static final String KEX_ALGORITHMS = "KexAlgorithms"; + + /** Key in an ssh config file. */ + public static final String LOCAL_COMMAND = "LocalCommand"; + + /** Key in an ssh config file. */ + public static final String LOCAL_FORWARD = "LocalForward"; + + /** Key in an ssh config file. */ + public static final String MACS = "MACs"; + + /** Key in an ssh config file. */ + public static final String NUMBER_OF_PASSWORD_PROMPTS = "NumberOfPasswordPrompts"; + + /** Key in an ssh config file. */ + public static final String PORT = "Port"; + + /** Key in an ssh config file. */ + public static final String PREFERRED_AUTHENTICATIONS = "PreferredAuthentications"; + + /** Key in an ssh config file. */ + public static final String PROXY_COMMAND = "ProxyCommand"; + + /** Key in an ssh config file. */ + public static final String REMOTE_COMMAND = "RemoteCommand"; + + /** Key in an ssh config file. */ + public static final String REMOTE_FORWARD = "RemoteForward"; + + /** Key in an ssh config file. */ + public static final String SEND_ENV = "SendEnv"; + + /** Key in an ssh config file. */ + public static final String STRICT_HOST_KEY_CHECKING = "StrictHostKeyChecking"; + + /** Key in an ssh config file. */ + public static final String USER = "User"; + + /** Key in an ssh config file. */ + public static final String USER_KNOWN_HOSTS_FILE = "UserKnownHostsFile"; + + // Values + + /** Flag value. */ + public static final String YES = "yes"; + + /** Flag value. */ + public static final String ON = "on"; + + /** Flag value. */ + public static final String TRUE = "true"; + + /** Flag value. */ + public static final String NO = "no"; + + /** Flag value. */ + public static final String OFF = "off"; + + /** Flag value. */ + public static final String FALSE = "false"; + + // Default identity file names + + /** Name of the default RSA private identity file. */ + public static final String ID_RSA = "id_rsa"; + + /** Name of the default DSA private identity file. */ + public static final String ID_DSA = "id_dsa"; + + /** Name of the default ECDSA private identity file. */ + public static final String ID_ECDSA = "id_ecdsa"; + + /** Name of the default ECDSA private identity file. */ + public static final String ID_ED25519 = "id_ed25519"; + + /** All known default identity file names. */ + public static final String[] DEFAULT_IDENTITIES = { // + ID_RSA, ID_DSA, ID_ECDSA // , ID_ED25519 // not yet... + }; +} diff --git a/org.eclipse.jgit/src/org/eclipse/jgit/transport/SshSessionFactory.java b/org.eclipse.jgit/src/org/eclipse/jgit/transport/SshSessionFactory.java index ae357dfb75..005a0c2d0e 100644 --- a/org.eclipse.jgit/src/org/eclipse/jgit/transport/SshSessionFactory.java +++ b/org.eclipse.jgit/src/org/eclipse/jgit/transport/SshSessionFactory.java @@ -44,8 +44,13 @@ package org.eclipse.jgit.transport; +import java.security.AccessController; +import java.security.PrivilegedAction; + import org.eclipse.jgit.errors.TransportException; +import org.eclipse.jgit.lib.Constants; import org.eclipse.jgit.util.FS; +import org.eclipse.jgit.util.SystemReader; /** * Creates and destroys SSH connections to a remote system. @@ -87,22 +92,39 @@ public abstract class SshSessionFactory { INSTANCE = new DefaultSshSessionFactory(); } + /** + * Retrieves the local user name as defined by the system property + * "user.name". + * + * @return the user name + * @since 5.2 + */ + public static String getLocalUserName() { + return AccessController.doPrivileged(new PrivilegedAction() { + @Override + public String run() { + return SystemReader.getInstance() + .getProperty(Constants.OS_USER_NAME_KEY); + } + }); + } + /** * Open (or reuse) a session to a host. *

    * A reasonable UserInfo that can interact with the end-user (if necessary) * is installed on the returned session by this method. *

    - * The caller must connect the session by invoking connect() - * if it has not already been connected. + * The caller must connect the session by invoking connect() if + * it has not already been connected. * * @param uri * URI information about the remote host * @param credentialsProvider * provider to support authentication, may be null. * @param fs - * the file system abstraction which will be necessary to - * perform certain file system operations. + * the file system abstraction which will be necessary to perform + * certain file system operations. * @param tms * Timeout value, in milliseconds. * @return a session that can contact the remote host. -- 2.39.5