OpenSshConfigFile.java

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

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.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
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.SshConfigStore;
import org.eclipse.jgit.transport.SshConstants;
import org.eclipse.jgit.util.FS;
import org.eclipse.jgit.util.StringUtils;
import org.eclipse.jgit.util.SystemReader;

/**
 * Fairly complete configuration parser for the openssh ~/.ssh/config file.
 * <p>
 * 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.
 * </p>
 * <p>
 * Limitations compared to the full openssh 7.5 parser:
 * </p>
 * <ul>
 * <li>This parser does not handle Match or Include keywords.
 * <li>This parser does not do host name canonicalization.
 * </ul>
 * <p>
 * Note that openssh's readconf.c is a validating parser; this parser does not
 * validate entries.
 * </p>
 * <p>
 * This config does %-substitutions for the following tokens:
 * </p>
 * <ul>
 * <li>%% - single %
 * <li>%C - short-hand for %l%h%p%r.
 * <li>%d - home directory path
 * <li>%h - remote host name
 * <li>%L - local host name without domain
 * <li>%l - FQDN of the local host
 * <li>%n - host name as specified in {@link #lookup(String, int, String)}
 * <li>%p - port number; if not given in {@link #lookup(String, int, String)}
 * replaced only if set in the config
 * <li>%r - remote user name; if not given in
 * {@link #lookup(String, int, String)} replaced only if set in the config
 * <li>%u - local user name
 * </ul>
 * <p>
 * %i is not handled; Java has no concept of a "user ID". %T is always replaced
 * by NONE.
 * </p>
 *
 * @see <a href="http://man.openbsd.org/OpenBSD-current/man5/ssh_config.5">man
 *      ssh-config</a>
 */
public class OpenSshConfigFile implements SshConfigStore {

	/** 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 Instant lastModified;

	/**
	 * Encapsulates entries read out of the configuration file, and a cache of
	 * fully resolved entries created from that.
	 */
	private static class State {
		List<HostEntry> entries = new LinkedList<>();

		// Previous lookups, keyed by user@hostname:port
		Map<String, HostEntry> 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; &lt;= 0 if none
	 * @param userName
	 *            the user supplied, may be {@code null} or empty if none given
	 * @return the configuration for the requested name.
	 */
	@Override
	@NonNull
	public HostEntry lookup(@NonNull String hostName, int port,
			String userName) {
		return lookup(hostName, port, userName, false);
	}

	@Override
	@NonNull
	public HostEntry lookupDefault(@NonNull String hostName, int port,
			String userName) {
		return lookup(hostName, port, userName, true);
	}

	private HostEntry lookup(@NonNull String hostName, int port,
			String userName, boolean fillDefaults) {
		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();
		Iterator<HostEntry> entries = cache.entries.iterator();
		if (entries.hasNext()) {
			// Should always have at least the first top entry containing
			// key-value pairs before the first Host block
			fullConfig.merge(entries.next());
			entries.forEachRemaining(entry -> {
				if (entry.matches(hostName)) {
					fullConfig.merge(entry);
				}
			});
		}
		fullConfig.substitute(hostName, port, userName, localUserName, home,
				fillDefaults);
		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 Instant mtime = FS.DETECTED.lastModifiedInstant(configFile);
		if (!mtime.equals(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 List<HostEntry> parse(BufferedReader reader)
			throws IOException {
		final List<HostEntry> entries = new LinkedList<>();

		// 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.
		HostEntry defaults = new HostEntry();
		HostEntry current = defaults;
		entries.add(defaults);

		String line;
		while ((line = reader.readLine()) != null) {
			line = line.strip();
			if (line.isEmpty()) {
				continue;
			}
			String[] parts = line.split("[ \t]*[= \t]", 2); //$NON-NLS-1$
			String keyword = parts[0].strip();
			if (keyword.isEmpty()) {
				continue;
			}
			switch (keyword.charAt(0)) {
			case '#':
				continue;
			case '"':
				// Although the ssh-config man page doesn't say so, the openssh
				// parser does allow quoted keywords.
				List<String> dequoted = parseList(keyword);
				keyword = dequoted.isEmpty() ? "" : dequoted.get(0); //$NON-NLS-1$
				break;
			default:
				// Keywords never contain hashes, nor whitespace
				int i = keyword.indexOf('#');
				if (i >= 0) {
					keyword = keyword.substring(0, i);
				}
				break;
			}
			if (keyword.isEmpty()) {
				continue;
			}
			// 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].strip() : ""; //$NON-NLS-1$

			if (StringUtils.equalsIgnoreCase(SshConstants.HOST, keyword)) {
				current = new HostEntry(parseList(argValue));
				entries.add(current);
				continue;
			}

			if (HostEntry.isListKey(keyword)) {
				List<String> args = validate(keyword, parseList(argValue));
				current.setValue(keyword, args);
			} else if (!argValue.isEmpty()) {
				List<String> args = parseList(argValue);
				String arg = args.isEmpty() ? "" : args.get(0); //$NON-NLS-1$
				argValue = validate(keyword, arg);
				current.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.
	 * Backslash-escapes are handled for quotes and blanks.
	 *
	 * @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 static List<String> parseList(String argument) {
		List<String> result = new ArrayList<>(4);
		int start = 0;
		int length = argument.length();
		while (start < length) {
			// Skip whitespace
			char ch = argument.charAt(start);
			if (Character.isWhitespace(ch)) {
				start++;
			} else if (ch == '#') {
				break; // Comment start
			} else {
				// Parse one token now.
				start = parseToken(argument, start, length, result);
			}
		}
		return result;
	}

	/**
	 * Parses a token up to the next whitespace not inside a string quoted by
	 * single or double quotes. Inside a string, quotes can be escaped by
	 * backslash characters. Outside of a string, "\ " can be used to include a
	 * space in a token; inside a string "\ " is taken literally as '\' followed
	 * by ' '.
	 *
	 * @param argument
	 *            to parse the token out of
	 * @param from
	 *            index at the beginning of the token
	 * @param to
	 *            index one after the last character to look at
	 * @param result
	 *            a list collecting tokens to which the parsed token is added
	 * @return the index after the token
	 */
	private static int parseToken(String argument, int from, int to,
			List<String> result) {
		StringBuilder b = new StringBuilder();
		int i = from;
		char quote = 0;
		boolean escaped = false;
		SCAN: while (i < to) {
			char ch = argument.charAt(i);
			switch (ch) {
			case '"':
			case '\'':
				if (quote == 0) {
					if (escaped) {
						b.append(ch);
					} else {
						quote = ch;
					}
				} else if (!escaped && quote == ch) {
					quote = 0;
				} else {
					b.append(ch);
				}
				escaped = false;
				break;
			case '\\':
				if (escaped) {
					b.append(ch);
				}
				escaped = !escaped;
				break;
			case ' ':
				if (quote == 0) {
					if (escaped) {
						b.append(ch);
						escaped = false;
					} else {
						break SCAN;
					}
				} else {
					if (escaped) {
						b.append('\\');
					}
					b.append(ch);
					escaped = false;
				}
				break;
			default:
				if (escaped) {
					b.append('\\');
				}
				if (quote == 0 && Character.isWhitespace(ch)) {
					break SCAN;
				}
				b.append(ch);
				escaped = false;
				break;
			}
			i++;
		}
		if (b.length() > 0) {
			result.add(b.toString());
		}
		return i;
	}

	/**
	 * 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 (SshConstants.PREFERRED_AUTHENTICATIONS.equalsIgnoreCase(key)) {
			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<String> validate(String key, List<String> value) {
		return value;
	}

	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();
		}
		// Not a pattern but a full host name
		return pattern.equals(name);
	}

	private static String stripWhitespace(String value) {
		final StringBuilder b = new StringBuilder();
		int length = value.length();
		for (int i = 0; i < length; i++) {
			char ch = value.charAt(i);
			if (!Character.isWhitespace(ch)) {
				b.append(ch);
			}
		}
		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);
	}

	/**
	 * Converts an OpenSSH time value into a number of seconds. The format is
	 * defined by OpenSSH as a sequence of (positive) integers with suffixes for
	 * seconds, minutes, hours, days, and weeks.
	 *
	 * @param value
	 *            to convert
	 * @return the parsed value as a number of seconds, or -1 if the value is
	 *         not a valid OpenSSH time value
	 * @see <a href="https://man.openbsd.org/sshd_config.5#TIME_FORMATS">OpenBSD
	 *      man 5 sshd_config, section TIME FORMATS</a>
	 */
	public static int timeSpec(String value) {
		if (value == null) {
			return -1;
		}
		try {
			int length = value.length();
			int i = 0;
			int seconds = 0;
			boolean valueSeen = false;
			while (i < length) {
				// Skip whitespace
				char ch = value.charAt(i);
				if (Character.isWhitespace(ch)) {
					i++;
					continue;
				}
				if (ch == '+') {
					// OpenSSH uses strtol with base 10: a leading plus sign is
					// allowed.
					i++;
				}
				int val = 0;
				int j = i;
				while (j < length) {
					ch = value.charAt(j++);
					if (ch >= '0' && ch <= '9') {
						val = Math.addExact(Math.multiplyExact(val, 10),
								ch - '0');
					} else {
						j--;
						break;
					}
				}
				if (i == j) {
					// No digits seen
					return -1;
				}
				i = j;
				int multiplier = 1;
				if (i < length) {
					ch = value.charAt(i++);
					switch (ch) {
					case 's':
					case 'S':
						break;
					case 'm':
					case 'M':
						multiplier = 60;
						break;
					case 'h':
					case 'H':
						multiplier = 3600;
						break;
					case 'd':
					case 'D':
						multiplier = 24 * 3600;
						break;
					case 'w':
					case 'W':
						multiplier = 7 * 24 * 3600;
						break;
					default:
						if (Character.isWhitespace(ch)) {
							break;
						}
						// Invalid time spec
						return -1;
					}
				}
				seconds = Math.addExact(seconds,
						Math.multiplyExact(val, multiplier));
				valueSeen = true;
			}
			return valueSeen ? seconds : -1;
		} catch (ArithmeticException e) {
			// Overflow
			return -1;
		}
	}

	/**
	 * 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 implements SshConfigStore.HostConfig {

		/**
		 * Keys that can be specified multiple times, building up a list. (I.e.,
		 * those are the keys that do not follow the general rule of "first
		 * occurrence wins".)
		 */
		private static final Set<String> MULTI_KEYS = new 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<String> 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);
			LIST_KEYS.add(SshConstants.ADD_KEYS_TO_AGENT); // confirm timeSpec
		}

		/**
		 * OpenSSH has renamed some config keys. This maps old names to new
		 * names.
		 */
		private static final Map<String, String> ALIASES = new TreeMap<>(
				String.CASE_INSENSITIVE_ORDER);

		static {
			// See https://github.com/openssh/openssh-portable/commit/ee9c0da80
			ALIASES.put("PubkeyAcceptedKeyTypes", //$NON-NLS-1$
					SshConstants.PUBKEY_ACCEPTED_ALGORITHMS);
		}

		private Map<String, String> options;

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

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

		private final List<String> patterns;

		/**
		 * Constructor used to build the merged entry; never matches anything
		 */
		public HostEntry() {
			this.patterns = Collections.emptyList();
		}

		/**
		 * @param patterns
		 *            to be used in matching against host name.
		 */
		public HostEntry(List<String> patterns) {
			this.patterns = patterns;
		}

		boolean matches(String hostName) {
			boolean doesMatch = false;
			for (String pattern : patterns) {
				if (pattern.startsWith("!")) { //$NON-NLS-1$
					if (patternMatchesHost(pattern.substring(1), hostName)) {
						return false;
					}
				} else if (!doesMatch
						&& patternMatchesHost(pattern, hostName)) {
					doesMatch = true;
				}
			}
			return doesMatch;
		}

		private static String toKey(String key) {
			String k = ALIASES.get(key);
			return k != null ? k : key;
		}

		/**
		 * Retrieves the value of a single-valued key, or the first if 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
		 */
		@Override
		public String getValue(String key) {
			String k = toKey(key);
			String result = options != null ? options.get(k) : null;
			if (result == null) {
				// Let's be lenient and return at least the first value from
				// a list-valued or multi-valued key.
				List<String> values = listOptions != null ? listOptions.get(k)
						: null;
				if (values == null) {
					values = multiOptions != null ? multiOptions.get(k) : null;
				}
				if (values != null && !values.isEmpty()) {
					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
		 */
		@Override
		public List<String> getValues(String key) {
			String k = toKey(key);
			List<String> values = listOptions != null ? listOptions.get(k)
					: null;
			if (values == null) {
				values = multiOptions != null ? multiOptions.get(k) : null;
			}
			if (values == null || values.isEmpty()) {
				return new 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) {
			String k = toKey(key);
			if (value == null) {
				if (multiOptions != null) {
					multiOptions.remove(k);
				}
				if (listOptions != null) {
					listOptions.remove(k);
				}
				if (options != null) {
					options.remove(k);
				}
				return;
			}
			if (MULTI_KEYS.contains(k)) {
				if (multiOptions == null) {
					multiOptions = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
				}
				List<String> values = multiOptions.get(k);
				if (values == null) {
					values = new ArrayList<>(4);
					multiOptions.put(k, values);
				}
				values.add(value);
			} else {
				if (options == null) {
					options = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
				}
				if (!options.containsKey(k)) {
					options.put(k, 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<String> values) {
			if (values.isEmpty()) {
				return;
			}
			String k = toKey(key);
			// 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 TreeMap<>(String.CASE_INSENSITIVE_ORDER);
				}
				List<String> items = multiOptions.get(k);
				if (items == null) {
					items = new ArrayList<>(values);
					multiOptions.put(k, items);
				} else {
					items.addAll(values);
				}
			} else {
				if (listOptions == null) {
					listOptions = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
				}
				if (!listOptions.containsKey(k)) {
					listOptions.put(k, 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(toKey(key));
		}

		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<String, String> item : entry.options
						.entrySet()) {
					if (!options.containsKey(item.getKey())) {
						options.put(item.getKey(), item.getValue());
					}
				}
			}
			if (entry.listOptions != null) {
				if (listOptions == null) {
					listOptions = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
				}
				for (Map.Entry<String, List<String>> item : entry.listOptions
						.entrySet()) {
					if (!listOptions.containsKey(item.getKey())) {
						listOptions.put(item.getKey(), item.getValue());
					}
				}

			}
			if (entry.multiOptions != null) {
				if (multiOptions == null) {
					multiOptions = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
				}
				for (Map.Entry<String, List<String>> item : entry.multiOptions
						.entrySet()) {
					List<String> values = multiOptions.get(item.getKey());
					if (values == null) {
						values = new ArrayList<>(item.getValue());
						multiOptions.put(item.getKey(), values);
					} else {
						values.addAll(item.getValue());
					}
				}
			}
		}

		private List<String> substitute(List<String> values, String allowed,
				Replacer r, boolean withEnv) {
			List<String> result = new ArrayList<>(values.size());
			for (String value : values) {
				result.add(r.substitute(value, allowed, withEnv));
			}
			return result;
		}

		private List<String> replaceTilde(List<String> values, File home) {
			List<String> 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, boolean fillDefaults) {
			int p = port > 0 ? port : positive(getValue(SshConstants.PORT));
			if (p <= 0) {
				p = SshConstants.SSH_DEFAULT_PORT;
			}
			String u = !StringUtils.isEmptyOrNull(userName) ? 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", false); //$NON-NLS-1$
					options.put(SshConstants.HOST_NAME, hostName);
					r.update('h', hostName);
				}
			} else if (fillDefaults) {
				setValue(SshConstants.HOST_NAME, originalHostName);
			}
			if (multiOptions != null) {
				List<String> values = multiOptions
						.get(SshConstants.IDENTITY_FILE);
				if (values != null) {
					values = substitute(values, Replacer.DEFAULT_TOKENS, r,
							true);
					values = replaceTilde(values, home);
					multiOptions.put(SshConstants.IDENTITY_FILE, values);
				}
				values = multiOptions.get(SshConstants.CERTIFICATE_FILE);
				if (values != null) {
					values = substitute(values, Replacer.DEFAULT_TOKENS, r,
							true);
					values = replaceTilde(values, home);
					multiOptions.put(SshConstants.CERTIFICATE_FILE, values);
				}
			}
			if (listOptions != null) {
				List<String> values = listOptions
						.get(SshConstants.USER_KNOWN_HOSTS_FILE);
				if (values != null) {
					values = substitute(values, Replacer.DEFAULT_TOKENS, r,
							true);
					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 && !SshConstants.NONE.equals(value)
						&& !SshConstants.ENV_SSH_AUTH_SOCKET.equals(value)) {
					value = r.substitute(value, Replacer.DEFAULT_TOKENS, true);
					value = toFile(value, home).getPath();
					options.put(SshConstants.IDENTITY_AGENT, value);
				}
				value = options.get(SshConstants.CONTROL_PATH);
				if (value != null) {
					value = r.substitute(value, Replacer.DEFAULT_TOKENS, true);
					value = toFile(value, home).getPath();
					options.put(SshConstants.CONTROL_PATH, value);
				}
				value = options.get(SshConstants.LOCAL_COMMAND);
				if (value != null) {
					value = r.substitute(value, "CdhLlnprTu", false); //$NON-NLS-1$
					options.put(SshConstants.LOCAL_COMMAND, value);
				}
				value = options.get(SshConstants.REMOTE_COMMAND);
				if (value != null) {
					value = r.substitute(value, Replacer.DEFAULT_TOKENS, false);
					options.put(SshConstants.REMOTE_COMMAND, value);
				}
				value = options.get(SshConstants.PROXY_COMMAND);
				if (value != null) {
					value = r.substitute(value, "hnpr", false); //$NON-NLS-1$
					options.put(SshConstants.PROXY_COMMAND, value);
				}
			}
			// Match is not implemented and would need to be done elsewhere
			// anyway.
			if (fillDefaults) {
				String s = options.get(SshConstants.USER);
				if (StringUtils.isEmptyOrNull(s)) {
					options.put(SshConstants.USER, u);
				}
				if (positive(options.get(SshConstants.PORT)) <= 0) {
					options.put(SshConstants.PORT, Integer.toString(p));
				}
				if (positive(
						options.get(SshConstants.CONNECTION_ATTEMPTS)) <= 0) {
					options.put(SshConstants.CONNECTION_ATTEMPTS, "1"); //$NON-NLS-1$
				}
			}
		}

		/**
		 * Retrieves an unmodifiable map of all single-valued options, with
		 * case-insensitive lookup by keys.
		 *
		 * @return all single-valued options
		 */
		@Override
		@NonNull
		public Map<String, String> 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
		 */
		@Override
		@NonNull
		public Map<String, List<String>> getMultiValuedOptions() {
			if (listOptions == null && multiOptions == null) {
				return Collections.emptyMap();
			}
			Map<String, List<String>> 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 {

		/**
		 * Tokens applicable to most keys.
		 *
		 * @see <a href="https://man.openbsd.org/ssh_config.5#TOKENS">man
		 *      ssh_config</a>
		 */
		public static final String DEFAULT_TOKENS = "CdhLlnpru"; //$NON-NLS-1$

		private final Map<Character, String> 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", false)); //$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", false)); //$NON-NLS-1$ //$NON-NLS-2$
			}
		}

		public String substitute(String input, String allowed,
				boolean withEnv) {
			if (input == null || input.length() <= 1
					|| (input.indexOf('%') < 0
							&& (!withEnv || input.indexOf("${") < 0))) { //$NON-NLS-1$
				return input;
			}
			StringBuilder builder = new StringBuilder();
			int start = 0;
			int length = input.length();
			while (start < length) {
				char ch = input.charAt(start);
				switch (ch) {
				case '%':
					if (start + 1 >= length) {
						break;
					}
					String replacement = null;
					ch = input.charAt(start + 1);
					if (ch == '%' || allowed.indexOf(ch) >= 0) {
						replacement = replacements.get(Character.valueOf(ch));
					}
					if (replacement == null) {
						builder.append('%').append(ch);
					} else {
						builder.append(replacement);
					}
					start += 2;
					continue;
				case '$':
					if (!withEnv || start + 2 >= length) {
						break;
					}
					ch = input.charAt(start + 1);
					if (ch == '{') {
						int close = input.indexOf('}', start + 2);
						if (close > start + 2) {
							String variable = SystemReader.getInstance()
									.getenv(input.substring(start + 2, close));
							if (!StringUtils.isEmptyOrNull(variable)) {
								builder.append(variable);
							}
							start = close + 1;
							continue;
						}
					}
					ch = '$';
					break;
				default:
					break;
				}
				builder.append(ch);
				start++;
			}
			return builder.toString();
		}
	}

	/** {@inheritDoc} */
	@Override
	@SuppressWarnings("nls")
	public String toString() {
		return "OpenSshConfig [home=" + home + ", configFile=" + configFile
				+ ", lastModified=" + lastModified + ", state=" + state + "]";
	}
}