OpenSshConfigFile.java

  1. /*
  2.  * Copyright (C) 2008, 2017, Google Inc.
  3.  * Copyright (C) 2017, 2018, Thomas Wolf <thomas.wolf@paranor.ch> and others
  4.  *
  5.  * This program and the accompanying materials are made available under the
  6.  * terms of the Eclipse Distribution License v. 1.0 which is available at
  7.  * https://www.eclipse.org/org/documents/edl-v10.php.
  8.  *
  9.  * SPDX-License-Identifier: BSD-3-Clause
  10.  */

  11. package org.eclipse.jgit.internal.transport.ssh;

  12. import static java.nio.charset.StandardCharsets.UTF_8;

  13. import java.io.BufferedReader;
  14. import java.io.File;
  15. import java.io.IOException;
  16. import java.nio.file.Files;
  17. import java.time.Instant;
  18. import java.util.ArrayList;
  19. import java.util.Collections;
  20. import java.util.HashMap;
  21. import java.util.LinkedHashMap;
  22. import java.util.List;
  23. import java.util.Locale;
  24. import java.util.Map;
  25. import java.util.Set;
  26. import java.util.TreeMap;
  27. import java.util.TreeSet;

  28. import org.eclipse.jgit.annotations.NonNull;
  29. import org.eclipse.jgit.errors.InvalidPatternException;
  30. import org.eclipse.jgit.fnmatch.FileNameMatcher;
  31. import org.eclipse.jgit.transport.SshConfigStore;
  32. import org.eclipse.jgit.transport.SshConstants;
  33. import org.eclipse.jgit.util.FS;
  34. import org.eclipse.jgit.util.StringUtils;
  35. import org.eclipse.jgit.util.SystemReader;

  36. /**
  37.  * Fairly complete configuration parser for the openssh ~/.ssh/config file.
  38.  * <p>
  39.  * Both JSch 0.1.54 and Apache MINA sshd 2.1.0 have parsers for this, but both
  40.  * are buggy. Therefore we implement our own parser to read an openssh
  41.  * configuration file.
  42.  * </p>
  43.  * <p>
  44.  * Limitations compared to the full openssh 7.5 parser:
  45.  * </p>
  46.  * <ul>
  47.  * <li>This parser does not handle Match or Include keywords.
  48.  * <li>This parser does not do host name canonicalization.
  49.  * </ul>
  50.  * <p>
  51.  * Note that openssh's readconf.c is a validating parser; this parser does not
  52.  * validate entries.
  53.  * </p>
  54.  * <p>
  55.  * This config does %-substitutions for the following tokens:
  56.  * </p>
  57.  * <ul>
  58.  * <li>%% - single %
  59.  * <li>%C - short-hand for %l%h%p%r.
  60.  * <li>%d - home directory path
  61.  * <li>%h - remote host name
  62.  * <li>%L - local host name without domain
  63.  * <li>%l - FQDN of the local host
  64.  * <li>%n - host name as specified in {@link #lookup(String, int, String)}
  65.  * <li>%p - port number; if not given in {@link #lookup(String, int, String)}
  66.  * replaced only if set in the config
  67.  * <li>%r - remote user name; if not given in
  68.  * {@link #lookup(String, int, String)} replaced only if set in the config
  69.  * <li>%u - local user name
  70.  * </ul>
  71.  * <p>
  72.  * %i is not handled; Java has no concept of a "user ID". %T is always replaced
  73.  * by NONE.
  74.  * </p>
  75.  *
  76.  * @see <a href="http://man.openbsd.org/OpenBSD-current/man5/ssh_config.5">man
  77.  *      ssh-config</a>
  78.  */
  79. public class OpenSshConfigFile implements SshConfigStore {

  80.     /**
  81.      * "Host" name of the HostEntry for the default options before the first
  82.      * host block in a config file.
  83.      */
  84.     private static final String DEFAULT_NAME = ""; //$NON-NLS-1$

  85.     /** The user's home directory, as key files may be relative to here. */
  86.     private final File home;

  87.     /** The .ssh/config file we read and monitor for updates. */
  88.     private final File configFile;

  89.     /** User name of the user on the host OS. */
  90.     private final String localUserName;

  91.     /** Modification time of {@link #configFile} when it was last loaded. */
  92.     private Instant lastModified;

  93.     /**
  94.      * Encapsulates entries read out of the configuration file, and a cache of
  95.      * fully resolved entries created from that.
  96.      */
  97.     private static class State {
  98.         // Keyed by pattern; if a "Host" line has multiple patterns, we generate
  99.         // duplicate HostEntry objects
  100.         Map<String, HostEntry> entries = new LinkedHashMap<>();

  101.         // Keyed by user@hostname:port
  102.         Map<String, HostEntry> hosts = new HashMap<>();

  103.         @Override
  104.         @SuppressWarnings("nls")
  105.         public String toString() {
  106.             return "State [entries=" + entries + ", hosts=" + hosts + "]";
  107.         }
  108.     }

  109.     /** State read from the config file, plus the cache. */
  110.     private State state;

  111.     /**
  112.      * Creates a new {@link OpenSshConfigFile} that will read the config from
  113.      * file {@code config} use the given file {@code home} as "home" directory.
  114.      *
  115.      * @param home
  116.      *            user's home directory for the purpose of ~ replacement
  117.      * @param config
  118.      *            file to load.
  119.      * @param localUserName
  120.      *            user name of the current user on the local host OS
  121.      */
  122.     public OpenSshConfigFile(@NonNull File home, @NonNull File config,
  123.             @NonNull String localUserName) {
  124.         this.home = home;
  125.         this.configFile = config;
  126.         this.localUserName = localUserName;
  127.         state = new State();
  128.     }

  129.     /**
  130.      * Locate the configuration for a specific host request.
  131.      *
  132.      * @param hostName
  133.      *            the name the user has supplied to the SSH tool. This may be a
  134.      *            real host name, or it may just be a "Host" block in the
  135.      *            configuration file.
  136.      * @param port
  137.      *            the user supplied; <= 0 if none
  138.      * @param userName
  139.      *            the user supplied, may be {@code null} or empty if none given
  140.      * @return the configuration for the requested name.
  141.      */
  142.     @Override
  143.     @NonNull
  144.     public HostEntry lookup(@NonNull String hostName, int port,
  145.             String userName) {
  146.         final State cache = refresh();
  147.         String cacheKey = toCacheKey(hostName, port, userName);
  148.         HostEntry h = cache.hosts.get(cacheKey);
  149.         if (h != null) {
  150.             return h;
  151.         }
  152.         HostEntry fullConfig = new HostEntry();
  153.         // Initialize with default entries at the top of the file, before the
  154.         // first Host block.
  155.         fullConfig.merge(cache.entries.get(DEFAULT_NAME));
  156.         for (Map.Entry<String, HostEntry> e : cache.entries.entrySet()) {
  157.             String pattern = e.getKey();
  158.             if (isHostMatch(pattern, hostName)) {
  159.                 fullConfig.merge(e.getValue());
  160.             }
  161.         }
  162.         fullConfig.substitute(hostName, port, userName, localUserName, home);
  163.         cache.hosts.put(cacheKey, fullConfig);
  164.         return fullConfig;
  165.     }

  166.     @NonNull
  167.     private String toCacheKey(@NonNull String hostName, int port,
  168.             String userName) {
  169.         String key = hostName;
  170.         if (port > 0) {
  171.             key = key + ':' + Integer.toString(port);
  172.         }
  173.         if (userName != null && !userName.isEmpty()) {
  174.             key = userName + '@' + key;
  175.         }
  176.         return key;
  177.     }

  178.     private synchronized State refresh() {
  179.         final Instant mtime = FS.DETECTED.lastModifiedInstant(configFile);
  180.         if (!mtime.equals(lastModified)) {
  181.             State newState = new State();
  182.             try (BufferedReader br = Files
  183.                     .newBufferedReader(configFile.toPath(), UTF_8)) {
  184.                 newState.entries = parse(br);
  185.             } catch (IOException | RuntimeException none) {
  186.                 // Ignore -- we'll set and return an empty state
  187.             }
  188.             lastModified = mtime;
  189.             state = newState;
  190.         }
  191.         return state;
  192.     }

  193.     private Map<String, HostEntry> parse(BufferedReader reader)
  194.             throws IOException {
  195.         final Map<String, HostEntry> entries = new LinkedHashMap<>();
  196.         final List<HostEntry> current = new ArrayList<>(4);
  197.         String line;

  198.         // The man page doesn't say so, but the openssh parser (readconf.c)
  199.         // starts out in active mode and thus always applies any lines that
  200.         // occur before the first host block. We gather those options in a
  201.         // HostEntry for DEFAULT_NAME.
  202.         HostEntry defaults = new HostEntry();
  203.         current.add(defaults);
  204.         entries.put(DEFAULT_NAME, defaults);

  205.         while ((line = reader.readLine()) != null) {
  206.             line = line.trim();
  207.             if (line.isEmpty() || line.startsWith("#")) { //$NON-NLS-1$
  208.                 continue;
  209.             }
  210.             String[] parts = line.split("[ \t]*[= \t]", 2); //$NON-NLS-1$
  211.             // Although the ssh-config man page doesn't say so, the openssh
  212.             // parser does allow quoted keywords.
  213.             String keyword = dequote(parts[0].trim());
  214.             // man 5 ssh-config says lines had the format "keyword arguments",
  215.             // with no indication that arguments were optional. However, let's
  216.             // not crap out on missing arguments. See bug 444319.
  217.             String argValue = parts.length > 1 ? parts[1].trim() : ""; //$NON-NLS-1$

  218.             if (StringUtils.equalsIgnoreCase(SshConstants.HOST, keyword)) {
  219.                 current.clear();
  220.                 for (String name : parseList(argValue)) {
  221.                     if (name == null || name.isEmpty()) {
  222.                         // null should not occur, but better be safe than sorry.
  223.                         continue;
  224.                     }
  225.                     HostEntry c = entries.get(name);
  226.                     if (c == null) {
  227.                         c = new HostEntry();
  228.                         entries.put(name, c);
  229.                     }
  230.                     current.add(c);
  231.                 }
  232.                 continue;
  233.             }

  234.             if (current.isEmpty()) {
  235.                 // We received an option outside of a Host block. We
  236.                 // don't know who this should match against, so skip.
  237.                 continue;
  238.             }

  239.             if (HostEntry.isListKey(keyword)) {
  240.                 List<String> args = validate(keyword, parseList(argValue));
  241.                 for (HostEntry entry : current) {
  242.                     entry.setValue(keyword, args);
  243.                 }
  244.             } else if (!argValue.isEmpty()) {
  245.                 argValue = validate(keyword, dequote(argValue));
  246.                 for (HostEntry entry : current) {
  247.                     entry.setValue(keyword, argValue);
  248.                 }
  249.             }
  250.         }

  251.         return entries;
  252.     }

  253.     /**
  254.      * Splits the argument into a list of whitespace-separated elements.
  255.      * Elements containing whitespace must be quoted and will be de-quoted.
  256.      *
  257.      * @param argument
  258.      *            argument part of the configuration line as read from the
  259.      *            config file
  260.      * @return a {@link List} of elements, possibly empty and possibly
  261.      *         containing empty elements, but not containing {@code null}
  262.      */
  263.     private List<String> parseList(String argument) {
  264.         List<String> result = new ArrayList<>(4);
  265.         int start = 0;
  266.         int length = argument.length();
  267.         while (start < length) {
  268.             // Skip whitespace
  269.             if (Character.isSpaceChar(argument.charAt(start))) {
  270.                 start++;
  271.                 continue;
  272.             }
  273.             if (argument.charAt(start) == '"') {
  274.                 int stop = argument.indexOf('"', ++start);
  275.                 if (stop < start) {
  276.                     // No closing double quote: skip
  277.                     break;
  278.                 }
  279.                 result.add(argument.substring(start, stop));
  280.                 start = stop + 1;
  281.             } else {
  282.                 int stop = start + 1;
  283.                 while (stop < length
  284.                         && !Character.isSpaceChar(argument.charAt(stop))) {
  285.                     stop++;
  286.                 }
  287.                 result.add(argument.substring(start, stop));
  288.                 start = stop + 1;
  289.             }
  290.         }
  291.         return result;
  292.     }

  293.     /**
  294.      * Hook to perform validation on a single value, or to sanitize it. If this
  295.      * throws an (unchecked) exception, parsing of the file is abandoned.
  296.      *
  297.      * @param key
  298.      *            of the entry
  299.      * @param value
  300.      *            as read from the config file
  301.      * @return the validated and possibly sanitized value
  302.      */
  303.     protected String validate(String key, String value) {
  304.         if (String.CASE_INSENSITIVE_ORDER.compare(key,
  305.                 SshConstants.PREFERRED_AUTHENTICATIONS) == 0) {
  306.             return stripWhitespace(value);
  307.         }
  308.         return value;
  309.     }

  310.     /**
  311.      * Hook to perform validation on values, or to sanitize them. If this throws
  312.      * an (unchecked) exception, parsing of the file is abandoned.
  313.      *
  314.      * @param key
  315.      *            of the entry
  316.      * @param value
  317.      *            list of arguments as read from the config file
  318.      * @return a {@link List} of values, possibly empty and possibly containing
  319.      *         empty elements, but not containing {@code null}
  320.      */
  321.     protected List<String> validate(String key, List<String> value) {
  322.         return value;
  323.     }

  324.     private static boolean isHostMatch(String pattern, String name) {
  325.         if (pattern.startsWith("!")) { //$NON-NLS-1$
  326.             return !patternMatchesHost(pattern.substring(1), name);
  327.         }
  328.         return patternMatchesHost(pattern, name);
  329.     }

  330.     private static boolean patternMatchesHost(String pattern, String name) {
  331.         if (pattern.indexOf('*') >= 0 || pattern.indexOf('?') >= 0) {
  332.             final FileNameMatcher fn;
  333.             try {
  334.                 fn = new FileNameMatcher(pattern, null);
  335.             } catch (InvalidPatternException e) {
  336.                 return false;
  337.             }
  338.             fn.append(name);
  339.             return fn.isMatch();
  340.         }
  341.         // Not a pattern but a full host name
  342.         return pattern.equals(name);
  343.     }

  344.     private static String dequote(String value) {
  345.         if (value.startsWith("\"") && value.endsWith("\"") //$NON-NLS-1$ //$NON-NLS-2$
  346.                 && value.length() > 1)
  347.             return value.substring(1, value.length() - 1);
  348.         return value;
  349.     }

  350.     private static String stripWhitespace(String value) {
  351.         final StringBuilder b = new StringBuilder();
  352.         for (int i = 0; i < value.length(); i++) {
  353.             if (!Character.isSpaceChar(value.charAt(i)))
  354.                 b.append(value.charAt(i));
  355.         }
  356.         return b.toString();
  357.     }

  358.     private static File toFile(String path, File home) {
  359.         if (path.startsWith("~/") || path.startsWith("~" + File.separator)) { //$NON-NLS-1$ //$NON-NLS-2$
  360.             return new File(home, path.substring(2));
  361.         }
  362.         File ret = new File(path);
  363.         if (ret.isAbsolute()) {
  364.             return ret;
  365.         }
  366.         return new File(home, path);
  367.     }

  368.     /**
  369.      * Converts a positive value into an {@code int}.
  370.      *
  371.      * @param value
  372.      *            to convert
  373.      * @return the value, or -1 if it wasn't a positive integral value
  374.      */
  375.     public static int positive(String value) {
  376.         if (value != null) {
  377.             try {
  378.                 return Integer.parseUnsignedInt(value);
  379.             } catch (NumberFormatException e) {
  380.                 // Ignore
  381.             }
  382.         }
  383.         return -1;
  384.     }

  385.     /**
  386.      * Converts a ssh config flag value (yes/true/on - no/false/off) into an
  387.      * {@code boolean}.
  388.      *
  389.      * @param value
  390.      *            to convert
  391.      * @return {@code true} if {@code value} is "yes", "on", or "true";
  392.      *         {@code false} otherwise
  393.      */
  394.     public static boolean flag(String value) {
  395.         if (value == null) {
  396.             return false;
  397.         }
  398.         return SshConstants.YES.equals(value) || SshConstants.ON.equals(value)
  399.                 || SshConstants.TRUE.equals(value);
  400.     }

  401.     /**
  402.      * Retrieves the local user name as given in the constructor.
  403.      *
  404.      * @return the user name
  405.      */
  406.     public String getLocalUserName() {
  407.         return localUserName;
  408.     }

  409.     /**
  410.      * A host entry from the ssh config file. Any merging of global values and
  411.      * of several matching host entries, %-substitutions, and ~ replacement have
  412.      * all been done.
  413.      */
  414.     public static class HostEntry implements SshConfigStore.HostConfig {

  415.         /**
  416.          * Keys that can be specified multiple times, building up a list. (I.e.,
  417.          * those are the keys that do not follow the general rule of "first
  418.          * occurrence wins".)
  419.          */
  420.         private static final Set<String> MULTI_KEYS = new TreeSet<>(
  421.                 String.CASE_INSENSITIVE_ORDER);

  422.         static {
  423.             MULTI_KEYS.add(SshConstants.CERTIFICATE_FILE);
  424.             MULTI_KEYS.add(SshConstants.IDENTITY_FILE);
  425.             MULTI_KEYS.add(SshConstants.LOCAL_FORWARD);
  426.             MULTI_KEYS.add(SshConstants.REMOTE_FORWARD);
  427.             MULTI_KEYS.add(SshConstants.SEND_ENV);
  428.         }

  429.         /**
  430.          * Keys that take a whitespace-separated list of elements as argument.
  431.          * Because the dequote-handling is different, we must handle those in
  432.          * the parser. There are a few other keys that take comma-separated
  433.          * lists as arguments, but for the parser those are single arguments
  434.          * that must be quoted if they contain whitespace, and taking them apart
  435.          * is the responsibility of the user of those keys.
  436.          */
  437.         private static final Set<String> LIST_KEYS = new TreeSet<>(
  438.                 String.CASE_INSENSITIVE_ORDER);

  439.         static {
  440.             LIST_KEYS.add(SshConstants.CANONICAL_DOMAINS);
  441.             LIST_KEYS.add(SshConstants.GLOBAL_KNOWN_HOSTS_FILE);
  442.             LIST_KEYS.add(SshConstants.SEND_ENV);
  443.             LIST_KEYS.add(SshConstants.USER_KNOWN_HOSTS_FILE);
  444.         }

  445.         private Map<String, String> options;

  446.         private Map<String, List<String>> multiOptions;

  447.         private Map<String, List<String>> listOptions;

  448.         /**
  449.          * Retrieves the value of a single-valued key, or the first if the key
  450.          * has multiple values. Keys are case-insensitive, so
  451.          * {@code getValue("HostName") == getValue("HOSTNAME")}.
  452.          *
  453.          * @param key
  454.          *            to get the value of
  455.          * @return the value, or {@code null} if none
  456.          */
  457.         @Override
  458.         public String getValue(String key) {
  459.             String result = options != null ? options.get(key) : null;
  460.             if (result == null) {
  461.                 // Let's be lenient and return at least the first value from
  462.                 // a list-valued or multi-valued key.
  463.                 List<String> values = listOptions != null ? listOptions.get(key)
  464.                         : null;
  465.                 if (values == null) {
  466.                     values = multiOptions != null ? multiOptions.get(key)
  467.                             : null;
  468.                 }
  469.                 if (values != null && !values.isEmpty()) {
  470.                     result = values.get(0);
  471.                 }
  472.             }
  473.             return result;
  474.         }

  475.         /**
  476.          * Retrieves the values of a multi or list-valued key. Keys are
  477.          * case-insensitive, so
  478.          * {@code getValue("HostName") == getValue("HOSTNAME")}.
  479.          *
  480.          * @param key
  481.          *            to get the values of
  482.          * @return a possibly empty list of values
  483.          */
  484.         @Override
  485.         public List<String> getValues(String key) {
  486.             List<String> values = listOptions != null ? listOptions.get(key)
  487.                     : null;
  488.             if (values == null) {
  489.                 values = multiOptions != null ? multiOptions.get(key) : null;
  490.             }
  491.             if (values == null || values.isEmpty()) {
  492.                 return new ArrayList<>();
  493.             }
  494.             return new ArrayList<>(values);
  495.         }

  496.         /**
  497.          * Sets the value of a single-valued key if it not set yet, or adds a
  498.          * value to a multi-valued key. If the value is {@code null}, the key is
  499.          * removed altogether, whether it is single-, list-, or multi-valued.
  500.          *
  501.          * @param key
  502.          *            to modify
  503.          * @param value
  504.          *            to set or add
  505.          */
  506.         public void setValue(String key, String value) {
  507.             if (value == null) {
  508.                 if (multiOptions != null) {
  509.                     multiOptions.remove(key);
  510.                 }
  511.                 if (listOptions != null) {
  512.                     listOptions.remove(key);
  513.                 }
  514.                 if (options != null) {
  515.                     options.remove(key);
  516.                 }
  517.                 return;
  518.             }
  519.             if (MULTI_KEYS.contains(key)) {
  520.                 if (multiOptions == null) {
  521.                     multiOptions = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
  522.                 }
  523.                 List<String> values = multiOptions.get(key);
  524.                 if (values == null) {
  525.                     values = new ArrayList<>(4);
  526.                     multiOptions.put(key, values);
  527.                 }
  528.                 values.add(value);
  529.             } else {
  530.                 if (options == null) {
  531.                     options = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
  532.                 }
  533.                 if (!options.containsKey(key)) {
  534.                     options.put(key, value);
  535.                 }
  536.             }
  537.         }

  538.         /**
  539.          * Sets the values of a multi- or list-valued key.
  540.          *
  541.          * @param key
  542.          *            to set
  543.          * @param values
  544.          *            a non-empty list of values
  545.          */
  546.         public void setValue(String key, List<String> values) {
  547.             if (values.isEmpty()) {
  548.                 return;
  549.             }
  550.             // Check multi-valued keys first; because of the replacement
  551.             // strategy, they must take precedence over list-valued keys
  552.             // which always follow the "first occurrence wins" strategy.
  553.             //
  554.             // Note that SendEnv is a multi-valued list-valued key. (It's
  555.             // rather immaterial for JGit, though.)
  556.             if (MULTI_KEYS.contains(key)) {
  557.                 if (multiOptions == null) {
  558.                     multiOptions = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
  559.                 }
  560.                 List<String> items = multiOptions.get(key);
  561.                 if (items == null) {
  562.                     items = new ArrayList<>(values);
  563.                     multiOptions.put(key, items);
  564.                 } else {
  565.                     items.addAll(values);
  566.                 }
  567.             } else {
  568.                 if (listOptions == null) {
  569.                     listOptions = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
  570.                 }
  571.                 if (!listOptions.containsKey(key)) {
  572.                     listOptions.put(key, values);
  573.                 }
  574.             }
  575.         }

  576.         /**
  577.          * Does the key take a whitespace-separated list of values?
  578.          *
  579.          * @param key
  580.          *            to check
  581.          * @return {@code true} if the key is a list-valued key.
  582.          */
  583.         public static boolean isListKey(String key) {
  584.             return LIST_KEYS.contains(key.toUpperCase(Locale.ROOT));
  585.         }

  586.         void merge(HostEntry entry) {
  587.             if (entry == null) {
  588.                 // Can occur if we could not read the config file
  589.                 return;
  590.             }
  591.             if (entry.options != null) {
  592.                 if (options == null) {
  593.                     options = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
  594.                 }
  595.                 for (Map.Entry<String, String> item : entry.options
  596.                         .entrySet()) {
  597.                     if (!options.containsKey(item.getKey())) {
  598.                         options.put(item.getKey(), item.getValue());
  599.                     }
  600.                 }
  601.             }
  602.             if (entry.listOptions != null) {
  603.                 if (listOptions == null) {
  604.                     listOptions = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
  605.                 }
  606.                 for (Map.Entry<String, List<String>> item : entry.listOptions
  607.                         .entrySet()) {
  608.                     if (!listOptions.containsKey(item.getKey())) {
  609.                         listOptions.put(item.getKey(), item.getValue());
  610.                     }
  611.                 }

  612.             }
  613.             if (entry.multiOptions != null) {
  614.                 if (multiOptions == null) {
  615.                     multiOptions = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
  616.                 }
  617.                 for (Map.Entry<String, List<String>> item : entry.multiOptions
  618.                         .entrySet()) {
  619.                     List<String> values = multiOptions.get(item.getKey());
  620.                     if (values == null) {
  621.                         values = new ArrayList<>(item.getValue());
  622.                         multiOptions.put(item.getKey(), values);
  623.                     } else {
  624.                         values.addAll(item.getValue());
  625.                     }
  626.                 }
  627.             }
  628.         }

  629.         private List<String> substitute(List<String> values, String allowed,
  630.                 Replacer r) {
  631.             List<String> result = new ArrayList<>(values.size());
  632.             for (String value : values) {
  633.                 result.add(r.substitute(value, allowed));
  634.             }
  635.             return result;
  636.         }

  637.         private List<String> replaceTilde(List<String> values, File home) {
  638.             List<String> result = new ArrayList<>(values.size());
  639.             for (String value : values) {
  640.                 result.add(toFile(value, home).getPath());
  641.             }
  642.             return result;
  643.         }

  644.         void substitute(String originalHostName, int port, String userName,
  645.                 String localUserName, File home) {
  646.             int p = port >= 0 ? port : positive(getValue(SshConstants.PORT));
  647.             if (p < 0) {
  648.                 p = SshConstants.SSH_DEFAULT_PORT;
  649.             }
  650.             String u = userName != null && !userName.isEmpty() ? userName
  651.                     : getValue(SshConstants.USER);
  652.             if (u == null || u.isEmpty()) {
  653.                 u = localUserName;
  654.             }
  655.             Replacer r = new Replacer(originalHostName, p, u, localUserName,
  656.                     home);
  657.             if (options != null) {
  658.                 // HOSTNAME first
  659.                 String hostName = options.get(SshConstants.HOST_NAME);
  660.                 if (hostName == null || hostName.isEmpty()) {
  661.                     options.put(SshConstants.HOST_NAME, originalHostName);
  662.                 } else {
  663.                     hostName = r.substitute(hostName, "h"); //$NON-NLS-1$
  664.                     options.put(SshConstants.HOST_NAME, hostName);
  665.                     r.update('h', hostName);
  666.                 }
  667.             }
  668.             if (multiOptions != null) {
  669.                 List<String> values = multiOptions
  670.                         .get(SshConstants.IDENTITY_FILE);
  671.                 if (values != null) {
  672.                     values = substitute(values, "dhlru", r); //$NON-NLS-1$
  673.                     values = replaceTilde(values, home);
  674.                     multiOptions.put(SshConstants.IDENTITY_FILE, values);
  675.                 }
  676.                 values = multiOptions.get(SshConstants.CERTIFICATE_FILE);
  677.                 if (values != null) {
  678.                     values = substitute(values, "dhlru", r); //$NON-NLS-1$
  679.                     values = replaceTilde(values, home);
  680.                     multiOptions.put(SshConstants.CERTIFICATE_FILE, values);
  681.                 }
  682.             }
  683.             if (listOptions != null) {
  684.                 List<String> values = listOptions
  685.                         .get(SshConstants.USER_KNOWN_HOSTS_FILE);
  686.                 if (values != null) {
  687.                     values = replaceTilde(values, home);
  688.                     listOptions.put(SshConstants.USER_KNOWN_HOSTS_FILE, values);
  689.                 }
  690.             }
  691.             if (options != null) {
  692.                 // HOSTNAME already done above
  693.                 String value = options.get(SshConstants.IDENTITY_AGENT);
  694.                 if (value != null) {
  695.                     value = r.substitute(value, "dhlru"); //$NON-NLS-1$
  696.                     value = toFile(value, home).getPath();
  697.                     options.put(SshConstants.IDENTITY_AGENT, value);
  698.                 }
  699.                 value = options.get(SshConstants.CONTROL_PATH);
  700.                 if (value != null) {
  701.                     value = r.substitute(value, "ChLlnpru"); //$NON-NLS-1$
  702.                     value = toFile(value, home).getPath();
  703.                     options.put(SshConstants.CONTROL_PATH, value);
  704.                 }
  705.                 value = options.get(SshConstants.LOCAL_COMMAND);
  706.                 if (value != null) {
  707.                     value = r.substitute(value, "CdhlnprTu"); //$NON-NLS-1$
  708.                     options.put(SshConstants.LOCAL_COMMAND, value);
  709.                 }
  710.                 value = options.get(SshConstants.REMOTE_COMMAND);
  711.                 if (value != null) {
  712.                     value = r.substitute(value, "Cdhlnpru"); //$NON-NLS-1$
  713.                     options.put(SshConstants.REMOTE_COMMAND, value);
  714.                 }
  715.                 value = options.get(SshConstants.PROXY_COMMAND);
  716.                 if (value != null) {
  717.                     value = r.substitute(value, "hpr"); //$NON-NLS-1$
  718.                     options.put(SshConstants.PROXY_COMMAND, value);
  719.                 }
  720.             }
  721.             // Match is not implemented and would need to be done elsewhere
  722.             // anyway.
  723.         }

  724.         /**
  725.          * Retrieves an unmodifiable map of all single-valued options, with
  726.          * case-insensitive lookup by keys.
  727.          *
  728.          * @return all single-valued options
  729.          */
  730.         @Override
  731.         @NonNull
  732.         public Map<String, String> getOptions() {
  733.             if (options == null) {
  734.                 return Collections.emptyMap();
  735.             }
  736.             return Collections.unmodifiableMap(options);
  737.         }

  738.         /**
  739.          * Retrieves an unmodifiable map of all multi-valued options, with
  740.          * case-insensitive lookup by keys.
  741.          *
  742.          * @return all multi-valued options
  743.          */
  744.         @Override
  745.         @NonNull
  746.         public Map<String, List<String>> getMultiValuedOptions() {
  747.             if (listOptions == null && multiOptions == null) {
  748.                 return Collections.emptyMap();
  749.             }
  750.             Map<String, List<String>> allValues = new TreeMap<>(
  751.                     String.CASE_INSENSITIVE_ORDER);
  752.             if (multiOptions != null) {
  753.                 allValues.putAll(multiOptions);
  754.             }
  755.             if (listOptions != null) {
  756.                 allValues.putAll(listOptions);
  757.             }
  758.             return Collections.unmodifiableMap(allValues);
  759.         }

  760.         @Override
  761.         @SuppressWarnings("nls")
  762.         public String toString() {
  763.             return "HostEntry [options=" + options + ", multiOptions="
  764.                     + multiOptions + ", listOptions=" + listOptions + "]";
  765.         }
  766.     }

  767.     private static class Replacer {
  768.         private final Map<Character, String> replacements = new HashMap<>();

  769.         public Replacer(String host, int port, String user,
  770.                 String localUserName, File home) {
  771.             replacements.put(Character.valueOf('%'), "%"); //$NON-NLS-1$
  772.             replacements.put(Character.valueOf('d'), home.getPath());
  773.             replacements.put(Character.valueOf('h'), host);
  774.             String localhost = SystemReader.getInstance().getHostname();
  775.             replacements.put(Character.valueOf('l'), localhost);
  776.             int period = localhost.indexOf('.');
  777.             if (period > 0) {
  778.                 localhost = localhost.substring(0, period);
  779.             }
  780.             replacements.put(Character.valueOf('L'), localhost);
  781.             replacements.put(Character.valueOf('n'), host);
  782.             replacements.put(Character.valueOf('p'), Integer.toString(port));
  783.             replacements.put(Character.valueOf('r'), user == null ? "" : user); //$NON-NLS-1$
  784.             replacements.put(Character.valueOf('u'), localUserName);
  785.             replacements.put(Character.valueOf('C'),
  786.                     substitute("%l%h%p%r", "hlpr")); //$NON-NLS-1$ //$NON-NLS-2$
  787.             replacements.put(Character.valueOf('T'), "NONE"); //$NON-NLS-1$
  788.         }

  789.         public void update(char key, String value) {
  790.             replacements.put(Character.valueOf(key), value);
  791.             if ("lhpr".indexOf(key) >= 0) { //$NON-NLS-1$
  792.                 replacements.put(Character.valueOf('C'),
  793.                         substitute("%l%h%p%r", "hlpr")); //$NON-NLS-1$ //$NON-NLS-2$
  794.             }
  795.         }

  796.         public String substitute(String input, String allowed) {
  797.             if (input == null || input.length() <= 1
  798.                     || input.indexOf('%') < 0) {
  799.                 return input;
  800.             }
  801.             StringBuilder builder = new StringBuilder();
  802.             int start = 0;
  803.             int length = input.length();
  804.             while (start < length) {
  805.                 int percent = input.indexOf('%', start);
  806.                 if (percent < 0 || percent + 1 >= length) {
  807.                     builder.append(input.substring(start));
  808.                     break;
  809.                 }
  810.                 String replacement = null;
  811.                 char ch = input.charAt(percent + 1);
  812.                 if (ch == '%' || allowed.indexOf(ch) >= 0) {
  813.                     replacement = replacements.get(Character.valueOf(ch));
  814.                 }
  815.                 if (replacement == null) {
  816.                     builder.append(input.substring(start, percent + 2));
  817.                 } else {
  818.                     builder.append(input.substring(start, percent))
  819.                             .append(replacement);
  820.                 }
  821.                 start = percent + 2;
  822.             }
  823.             return builder.toString();
  824.         }
  825.     }

  826.     /** {@inheritDoc} */
  827.     @Override
  828.     @SuppressWarnings("nls")
  829.     public String toString() {
  830.         return "OpenSshConfig [home=" + home + ", configFile=" + configFile
  831.                 + ", lastModified=" + lastModified + ", state=" + state + "]";
  832.     }
  833. }