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 076058576..1a22e10f4 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.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 void testListValueMultiple() throws Exception { 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 1a10ce78a..aec1dd890 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 000000000..e8a6ba730 --- /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: + *

+ * + *

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

+ *

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

+ * + *

+ * %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 7924ec8c2..0bdd6ba81 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 @@ 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 @@ private static void setPreferredKeyTypesOrder(Session session) { } 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 a5fa3fee3..32e1dff23 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 @@ *
  • 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 @@ * {@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 static OpenSshConfig get(FS fs) { 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; + final File config = new File(new File(home, SshConstants.SSH_DIR), + SshConstants.CONFIG); + return new OpenSshConfig(home, config); } - /** 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 + "]"; - } - } - - /** 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 String toString() { * @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 static class Host { 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 Host() { // 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 int getConnectionAttempts() { } - 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 String toString() { + ", preferredAuthentications=" + preferredAuthentications + ", batchMode=" + batchMode + ", strictHostKeyChecking=" + strictHostKeyChecking + ", connectionAttempts=" - + connectionAttempts + ", config=" + config + "]"; + + connectionAttempts + ", entry=" + entry + "]"; } } @@ -980,9 +372,7 @@ public Config getConfig(String hostName) { /** {@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 000000000..fd6301bb4 --- /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 ae357dfb7..005a0c2d0 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 static void setInstance(SshSessionFactory newFactory) { 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.