View Javadoc
1   /*
2    * Copyright (C) 2008, 2017, Google Inc.
3    * Copyright (C) 2017, 2021, 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  
12  package org.eclipse.jgit.internal.transport.ssh;
13  
14  import static java.nio.charset.StandardCharsets.UTF_8;
15  
16  import java.io.BufferedReader;
17  import java.io.File;
18  import java.io.IOException;
19  import java.nio.file.Files;
20  import java.time.Instant;
21  import java.util.ArrayList;
22  import java.util.Collections;
23  import java.util.HashMap;
24  import java.util.Iterator;
25  import java.util.LinkedList;
26  import java.util.List;
27  import java.util.Map;
28  import java.util.Set;
29  import java.util.TreeMap;
30  import java.util.TreeSet;
31  
32  import org.eclipse.jgit.annotations.NonNull;
33  import org.eclipse.jgit.errors.InvalidPatternException;
34  import org.eclipse.jgit.fnmatch.FileNameMatcher;
35  import org.eclipse.jgit.transport.SshConfigStore;
36  import org.eclipse.jgit.transport.SshConstants;
37  import org.eclipse.jgit.util.FS;
38  import org.eclipse.jgit.util.StringUtils;
39  import org.eclipse.jgit.util.SystemReader;
40  
41  /**
42   * Fairly complete configuration parser for the openssh ~/.ssh/config file.
43   * <p>
44   * Both JSch 0.1.54 and Apache MINA sshd 2.1.0 have parsers for this, but both
45   * are buggy. Therefore we implement our own parser to read an openssh
46   * configuration file.
47   * </p>
48   * <p>
49   * Limitations compared to the full openssh 7.5 parser:
50   * </p>
51   * <ul>
52   * <li>This parser does not handle Match or Include keywords.
53   * <li>This parser does not do host name canonicalization.
54   * </ul>
55   * <p>
56   * Note that openssh's readconf.c is a validating parser; this parser does not
57   * validate entries.
58   * </p>
59   * <p>
60   * This config does %-substitutions for the following tokens:
61   * </p>
62   * <ul>
63   * <li>%% - single %
64   * <li>%C - short-hand for %l%h%p%r.
65   * <li>%d - home directory path
66   * <li>%h - remote host name
67   * <li>%L - local host name without domain
68   * <li>%l - FQDN of the local host
69   * <li>%n - host name as specified in {@link #lookup(String, int, String)}
70   * <li>%p - port number; if not given in {@link #lookup(String, int, String)}
71   * replaced only if set in the config
72   * <li>%r - remote user name; if not given in
73   * {@link #lookup(String, int, String)} replaced only if set in the config
74   * <li>%u - local user name
75   * </ul>
76   * <p>
77   * %i is not handled; Java has no concept of a "user ID". %T is always replaced
78   * by NONE.
79   * </p>
80   *
81   * @see <a href="http://man.openbsd.org/OpenBSD-current/man5/ssh_config.5">man
82   *      ssh-config</a>
83   */
84  public class OpenSshConfigFile implements SshConfigStore {
85  
86  	/** The user's home directory, as key files may be relative to here. */
87  	private final File home;
88  
89  	/** The .ssh/config file we read and monitor for updates. */
90  	private final File configFile;
91  
92  	/** User name of the user on the host OS. */
93  	private final String localUserName;
94  
95  	/** Modification time of {@link #configFile} when it was last loaded. */
96  	private Instant lastModified;
97  
98  	/**
99  	 * Encapsulates entries read out of the configuration file, and a cache of
100 	 * fully resolved entries created from that.
101 	 */
102 	private static class State {
103 		List<HostEntry> entries = new LinkedList<>();
104 
105 		// Previous lookups, keyed by user@hostname:port
106 		Map<String, HostEntry> hosts = new HashMap<>();
107 
108 		@Override
109 		@SuppressWarnings("nls")
110 		public String toString() {
111 			return "State [entries=" + entries + ", hosts=" + hosts + "]";
112 		}
113 	}
114 
115 	/** State read from the config file, plus the cache. */
116 	private State state;
117 
118 	/**
119 	 * Creates a new {@link OpenSshConfigFile} that will read the config from
120 	 * file {@code config} use the given file {@code home} as "home" directory.
121 	 *
122 	 * @param home
123 	 *            user's home directory for the purpose of ~ replacement
124 	 * @param config
125 	 *            file to load.
126 	 * @param localUserName
127 	 *            user name of the current user on the local host OS
128 	 */
129 	public OpenSshConfigFile(@NonNull File home, @NonNull File config,
130 			@NonNull String localUserName) {
131 		this.home = home;
132 		this.configFile = config;
133 		this.localUserName = localUserName;
134 		state = new State();
135 	}
136 
137 	/**
138 	 * Locate the configuration for a specific host request.
139 	 *
140 	 * @param hostName
141 	 *            the name the user has supplied to the SSH tool. This may be a
142 	 *            real host name, or it may just be a "Host" block in the
143 	 *            configuration file.
144 	 * @param port
145 	 *            the user supplied; <= 0 if none
146 	 * @param userName
147 	 *            the user supplied, may be {@code null} or empty if none given
148 	 * @return the configuration for the requested name.
149 	 */
150 	@Override
151 	@NonNull
152 	public HostEntry lookup(@NonNull String hostName, int port,
153 			String userName) {
154 		final State cache = refresh();
155 		String cacheKey = toCacheKey(hostName, port, userName);
156 		HostEntry h = cache.hosts.get(cacheKey);
157 		if (h != null) {
158 			return h;
159 		}
160 		HostEntry fullConfig = new HostEntry();
161 		Iterator<HostEntry> entries = cache.entries.iterator();
162 		if (entries.hasNext()) {
163 			// Should always have at least the first top entry containing
164 			// key-value pairs before the first Host block
165 			fullConfig.merge(entries.next());
166 			entries.forEachRemaining(entry -> {
167 				if (entry.matches(hostName)) {
168 					fullConfig.merge(entry);
169 				}
170 			});
171 		}
172 		fullConfig.substitute(hostName, port, userName, localUserName, home);
173 		cache.hosts.put(cacheKey, fullConfig);
174 		return fullConfig;
175 	}
176 
177 	@NonNull
178 	private String toCacheKey(@NonNull String hostName, int port,
179 			String userName) {
180 		String key = hostName;
181 		if (port > 0) {
182 			key = key + ':' + Integer.toString(port);
183 		}
184 		if (userName != null && !userName.isEmpty()) {
185 			key = userName + '@' + key;
186 		}
187 		return key;
188 	}
189 
190 	private synchronized State refresh() {
191 		final Instant mtime = FS.DETECTED.lastModifiedInstant(configFile);
192 		if (!mtime.equals(lastModified)) {
193 			State newState = new State();
194 			try (BufferedReader br = Files
195 					.newBufferedReader(configFile.toPath(), UTF_8)) {
196 				newState.entries = parse(br);
197 			} catch (IOException | RuntimeException none) {
198 				// Ignore -- we'll set and return an empty state
199 			}
200 			lastModified = mtime;
201 			state = newState;
202 		}
203 		return state;
204 	}
205 
206 	private List<HostEntry> parse(BufferedReader reader)
207 			throws IOException {
208 		final List<HostEntry> entries = new LinkedList<>();
209 
210 		// The man page doesn't say so, but the openssh parser (readconf.c)
211 		// starts out in active mode and thus always applies any lines that
212 		// occur before the first host block. We gather those options in a
213 		// HostEntry for DEFAULT_NAME.
214 		HostEntry defaults = new HostEntry();
215 		HostEntry current = defaults;
216 		entries.add(defaults);
217 
218 		String line;
219 		while ((line = reader.readLine()) != null) {
220 			// OpenSsh ignores trailing comments on a line. Anything after the
221 			// first # on a line is trimmed away (yes, even if the hash is
222 			// inside quotes).
223 			//
224 			// See https://github.com/openssh/openssh-portable/commit/2bcbf679
225 			int i = line.indexOf('#');
226 			if (i >= 0) {
227 				line = line.substring(0, i);
228 			}
229 			line = line.trim();
230 			if (line.isEmpty()) {
231 				continue;
232 			}
233 			String[] parts = line.split("[ \t]*[= \t]", 2); //$NON-NLS-1$
234 			// Although the ssh-config man page doesn't say so, the openssh
235 			// parser does allow quoted keywords.
236 			String keyword = dequote(parts[0].trim());
237 			// man 5 ssh-config says lines had the format "keyword arguments",
238 			// with no indication that arguments were optional. However, let's
239 			// not crap out on missing arguments. See bug 444319.
240 			String argValue = parts.length > 1 ? parts[1].trim() : ""; //$NON-NLS-1$
241 
242 			if (StringUtils.equalsIgnoreCase(SshConstants.HOST, keyword)) {
243 				current = new HostEntry(parseList(argValue));
244 				entries.add(current);
245 				continue;
246 			}
247 
248 			if (HostEntry.isListKey(keyword)) {
249 				List<String> args = validate(keyword, parseList(argValue));
250 				current.setValue(keyword, args);
251 			} else if (!argValue.isEmpty()) {
252 				argValue = validate(keyword, dequote(argValue));
253 				current.setValue(keyword, argValue);
254 			}
255 		}
256 
257 		return entries;
258 	}
259 
260 	/**
261 	 * Splits the argument into a list of whitespace-separated elements.
262 	 * Elements containing whitespace must be quoted and will be de-quoted.
263 	 *
264 	 * @param argument
265 	 *            argument part of the configuration line as read from the
266 	 *            config file
267 	 * @return a {@link List} of elements, possibly empty and possibly
268 	 *         containing empty elements, but not containing {@code null}
269 	 */
270 	private List<String> parseList(String argument) {
271 		List<String> result = new ArrayList<>(4);
272 		int start = 0;
273 		int length = argument.length();
274 		while (start < length) {
275 			// Skip whitespace
276 			if (Character.isWhitespace(argument.charAt(start))) {
277 				start++;
278 				continue;
279 			}
280 			if (argument.charAt(start) == '"') {
281 				int stop = argument.indexOf('"', ++start);
282 				if (stop < start) {
283 					// No closing double quote: skip
284 					break;
285 				}
286 				result.add(argument.substring(start, stop));
287 				start = stop + 1;
288 			} else {
289 				int stop = start + 1;
290 				while (stop < length
291 						&& !Character.isWhitespace(argument.charAt(stop))) {
292 					stop++;
293 				}
294 				result.add(argument.substring(start, stop));
295 				start = stop + 1;
296 			}
297 		}
298 		return result;
299 	}
300 
301 	/**
302 	 * Hook to perform validation on a single value, or to sanitize it. If this
303 	 * throws an (unchecked) exception, parsing of the file is abandoned.
304 	 *
305 	 * @param key
306 	 *            of the entry
307 	 * @param value
308 	 *            as read from the config file
309 	 * @return the validated and possibly sanitized value
310 	 */
311 	protected String validate(String key, String value) {
312 		if (String.CASE_INSENSITIVE_ORDER.compare(key,
313 				SshConstants.PREFERRED_AUTHENTICATIONS) == 0) {
314 			return stripWhitespace(value);
315 		}
316 		return value;
317 	}
318 
319 	/**
320 	 * Hook to perform validation on values, or to sanitize them. If this throws
321 	 * an (unchecked) exception, parsing of the file is abandoned.
322 	 *
323 	 * @param key
324 	 *            of the entry
325 	 * @param value
326 	 *            list of arguments as read from the config file
327 	 * @return a {@link List} of values, possibly empty and possibly containing
328 	 *         empty elements, but not containing {@code null}
329 	 */
330 	protected List<String> validate(String key, List<String> value) {
331 		return value;
332 	}
333 
334 	private static boolean patternMatchesHost(String pattern, String name) {
335 		if (pattern.indexOf('*') >= 0 || pattern.indexOf('?') >= 0) {
336 			final FileNameMatcher fn;
337 			try {
338 				fn = new FileNameMatcher(pattern, null);
339 			} catch (InvalidPatternException e) {
340 				return false;
341 			}
342 			fn.append(name);
343 			return fn.isMatch();
344 		}
345 		// Not a pattern but a full host name
346 		return pattern.equals(name);
347 	}
348 
349 	private static String dequote(String value) {
350 		if (value.startsWith("\"") && value.endsWith("\"") //$NON-NLS-1$ //$NON-NLS-2$
351 				&& value.length() > 1)
352 			return value.substring(1, value.length() - 1);
353 		return value;
354 	}
355 
356 	private static String stripWhitespace(String value) {
357 		final StringBuilder b = new StringBuilder();
358 		int length = value.length();
359 		for (int i = 0; i < length; i++) {
360 			char ch = value.charAt(i);
361 			if (!Character.isWhitespace(ch)) {
362 				b.append(ch);
363 			}
364 		}
365 		return b.toString();
366 	}
367 
368 	private static File toFile(String path, File home) {
369 		if (path.startsWith("~/") || path.startsWith("~" + File.separator)) { //$NON-NLS-1$ //$NON-NLS-2$
370 			return new File(home, path.substring(2));
371 		}
372 		File ret = new File(path);
373 		if (ret.isAbsolute()) {
374 			return ret;
375 		}
376 		return new File(home, path);
377 	}
378 
379 	/**
380 	 * Converts a positive value into an {@code int}.
381 	 *
382 	 * @param value
383 	 *            to convert
384 	 * @return the value, or -1 if it wasn't a positive integral value
385 	 */
386 	public static int positive(String value) {
387 		if (value != null) {
388 			try {
389 				return Integer.parseUnsignedInt(value);
390 			} catch (NumberFormatException e) {
391 				// Ignore
392 			}
393 		}
394 		return -1;
395 	}
396 
397 	/**
398 	 * Converts a ssh config flag value (yes/true/on - no/false/off) into an
399 	 * {@code boolean}.
400 	 *
401 	 * @param value
402 	 *            to convert
403 	 * @return {@code true} if {@code value} is "yes", "on", or "true";
404 	 *         {@code false} otherwise
405 	 */
406 	public static boolean flag(String value) {
407 		if (value == null) {
408 			return false;
409 		}
410 		return SshConstants.YES.equals(value) || SshConstants.ON.equals(value)
411 				|| SshConstants.TRUE.equals(value);
412 	}
413 
414 	/**
415 	 * Retrieves the local user name as given in the constructor.
416 	 *
417 	 * @return the user name
418 	 */
419 	public String getLocalUserName() {
420 		return localUserName;
421 	}
422 
423 	/**
424 	 * A host entry from the ssh config file. Any merging of global values and
425 	 * of several matching host entries, %-substitutions, and ~ replacement have
426 	 * all been done.
427 	 */
428 	public static class HostEntry implements SshConfigStore.HostConfig {
429 
430 		/**
431 		 * Keys that can be specified multiple times, building up a list. (I.e.,
432 		 * those are the keys that do not follow the general rule of "first
433 		 * occurrence wins".)
434 		 */
435 		private static final Set<String> MULTI_KEYS = new TreeSet<>(
436 				String.CASE_INSENSITIVE_ORDER);
437 
438 		static {
439 			MULTI_KEYS.add(SshConstants.CERTIFICATE_FILE);
440 			MULTI_KEYS.add(SshConstants.IDENTITY_FILE);
441 			MULTI_KEYS.add(SshConstants.LOCAL_FORWARD);
442 			MULTI_KEYS.add(SshConstants.REMOTE_FORWARD);
443 			MULTI_KEYS.add(SshConstants.SEND_ENV);
444 		}
445 
446 		/**
447 		 * Keys that take a whitespace-separated list of elements as argument.
448 		 * Because the dequote-handling is different, we must handle those in
449 		 * the parser. There are a few other keys that take comma-separated
450 		 * lists as arguments, but for the parser those are single arguments
451 		 * that must be quoted if they contain whitespace, and taking them apart
452 		 * is the responsibility of the user of those keys.
453 		 */
454 		private static final Set<String> LIST_KEYS = new TreeSet<>(
455 				String.CASE_INSENSITIVE_ORDER);
456 
457 		static {
458 			LIST_KEYS.add(SshConstants.CANONICAL_DOMAINS);
459 			LIST_KEYS.add(SshConstants.GLOBAL_KNOWN_HOSTS_FILE);
460 			LIST_KEYS.add(SshConstants.SEND_ENV);
461 			LIST_KEYS.add(SshConstants.USER_KNOWN_HOSTS_FILE);
462 		}
463 
464 		/**
465 		 * OpenSSH has renamed some config keys. This maps old names to new
466 		 * names.
467 		 */
468 		private static final Map<String, String> ALIASES = new TreeMap<>(
469 				String.CASE_INSENSITIVE_ORDER);
470 
471 		static {
472 			// See https://github.com/openssh/openssh-portable/commit/ee9c0da80
473 			ALIASES.put("PubkeyAcceptedKeyTypes", //$NON-NLS-1$
474 					SshConstants.PUBKEY_ACCEPTED_ALGORITHMS);
475 		}
476 
477 		private Map<String, String> options;
478 
479 		private Map<String, List<String>> multiOptions;
480 
481 		private Map<String, List<String>> listOptions;
482 
483 		private final List<String> patterns;
484 
485 		/**
486 		 * Constructor used to build the merged entry; never matches anything
487 		 */
488 		public HostEntry() {
489 			this.patterns = Collections.emptyList();
490 		}
491 
492 		/**
493 		 * @param patterns
494 		 *            to be used in matching against host name.
495 		 */
496 		public HostEntry(List<String> patterns) {
497 			this.patterns = patterns;
498 		}
499 
500 		boolean matches(String hostName) {
501 			boolean doesMatch = false;
502 			for (String pattern : patterns) {
503 				if (pattern.startsWith("!")) { //$NON-NLS-1$
504 					if (patternMatchesHost(pattern.substring(1), hostName)) {
505 						return false;
506 					}
507 				} else if (!doesMatch
508 						&& patternMatchesHost(pattern, hostName)) {
509 					doesMatch = true;
510 				}
511 			}
512 			return doesMatch;
513 		}
514 
515 		private static String toKey(String key) {
516 			String k = ALIASES.get(key);
517 			return k != null ? k : key;
518 		}
519 
520 		/**
521 		 * Retrieves the value of a single-valued key, or the first if the key
522 		 * has multiple values. Keys are case-insensitive, so
523 		 * {@code getValue("HostName") == getValue("HOSTNAME")}.
524 		 *
525 		 * @param key
526 		 *            to get the value of
527 		 * @return the value, or {@code null} if none
528 		 */
529 		@Override
530 		public String getValue(String key) {
531 			String k = toKey(key);
532 			String result = options != null ? options.get(k) : null;
533 			if (result == null) {
534 				// Let's be lenient and return at least the first value from
535 				// a list-valued or multi-valued key.
536 				List<String> values = listOptions != null ? listOptions.get(k)
537 						: null;
538 				if (values == null) {
539 					values = multiOptions != null ? multiOptions.get(k) : null;
540 				}
541 				if (values != null && !values.isEmpty()) {
542 					result = values.get(0);
543 				}
544 			}
545 			return result;
546 		}
547 
548 		/**
549 		 * Retrieves the values of a multi or list-valued key. Keys are
550 		 * case-insensitive, so
551 		 * {@code getValue("HostName") == getValue("HOSTNAME")}.
552 		 *
553 		 * @param key
554 		 *            to get the values of
555 		 * @return a possibly empty list of values
556 		 */
557 		@Override
558 		public List<String> getValues(String key) {
559 			String k = toKey(key);
560 			List<String> values = listOptions != null ? listOptions.get(k)
561 					: null;
562 			if (values == null) {
563 				values = multiOptions != null ? multiOptions.get(k) : null;
564 			}
565 			if (values == null || values.isEmpty()) {
566 				return new ArrayList<>();
567 			}
568 			return new ArrayList<>(values);
569 		}
570 
571 		/**
572 		 * Sets the value of a single-valued key if it not set yet, or adds a
573 		 * value to a multi-valued key. If the value is {@code null}, the key is
574 		 * removed altogether, whether it is single-, list-, or multi-valued.
575 		 *
576 		 * @param key
577 		 *            to modify
578 		 * @param value
579 		 *            to set or add
580 		 */
581 		public void setValue(String key, String value) {
582 			String k = toKey(key);
583 			if (value == null) {
584 				if (multiOptions != null) {
585 					multiOptions.remove(k);
586 				}
587 				if (listOptions != null) {
588 					listOptions.remove(k);
589 				}
590 				if (options != null) {
591 					options.remove(k);
592 				}
593 				return;
594 			}
595 			if (MULTI_KEYS.contains(k)) {
596 				if (multiOptions == null) {
597 					multiOptions = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
598 				}
599 				List<String> values = multiOptions.get(k);
600 				if (values == null) {
601 					values = new ArrayList<>(4);
602 					multiOptions.put(k, values);
603 				}
604 				values.add(value);
605 			} else {
606 				if (options == null) {
607 					options = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
608 				}
609 				if (!options.containsKey(k)) {
610 					options.put(k, value);
611 				}
612 			}
613 		}
614 
615 		/**
616 		 * Sets the values of a multi- or list-valued key.
617 		 *
618 		 * @param key
619 		 *            to set
620 		 * @param values
621 		 *            a non-empty list of values
622 		 */
623 		public void setValue(String key, List<String> values) {
624 			if (values.isEmpty()) {
625 				return;
626 			}
627 			String k = toKey(key);
628 			// Check multi-valued keys first; because of the replacement
629 			// strategy, they must take precedence over list-valued keys
630 			// which always follow the "first occurrence wins" strategy.
631 			//
632 			// Note that SendEnv is a multi-valued list-valued key. (It's
633 			// rather immaterial for JGit, though.)
634 			if (MULTI_KEYS.contains(k)) {
635 				if (multiOptions == null) {
636 					multiOptions = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
637 				}
638 				List<String> items = multiOptions.get(k);
639 				if (items == null) {
640 					items = new ArrayList<>(values);
641 					multiOptions.put(k, items);
642 				} else {
643 					items.addAll(values);
644 				}
645 			} else {
646 				if (listOptions == null) {
647 					listOptions = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
648 				}
649 				if (!listOptions.containsKey(k)) {
650 					listOptions.put(k, values);
651 				}
652 			}
653 		}
654 
655 		/**
656 		 * Does the key take a whitespace-separated list of values?
657 		 *
658 		 * @param key
659 		 *            to check
660 		 * @return {@code true} if the key is a list-valued key.
661 		 */
662 		public static boolean isListKey(String key) {
663 			return LIST_KEYS.contains(toKey(key));
664 		}
665 
666 		void merge(HostEntry entry) {
667 			if (entry == null) {
668 				// Can occur if we could not read the config file
669 				return;
670 			}
671 			if (entry.options != null) {
672 				if (options == null) {
673 					options = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
674 				}
675 				for (Map.Entry<String, String> item : entry.options
676 						.entrySet()) {
677 					if (!options.containsKey(item.getKey())) {
678 						options.put(item.getKey(), item.getValue());
679 					}
680 				}
681 			}
682 			if (entry.listOptions != null) {
683 				if (listOptions == null) {
684 					listOptions = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
685 				}
686 				for (Map.Entry<String, List<String>> item : entry.listOptions
687 						.entrySet()) {
688 					if (!listOptions.containsKey(item.getKey())) {
689 						listOptions.put(item.getKey(), item.getValue());
690 					}
691 				}
692 
693 			}
694 			if (entry.multiOptions != null) {
695 				if (multiOptions == null) {
696 					multiOptions = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
697 				}
698 				for (Map.Entry<String, List<String>> item : entry.multiOptions
699 						.entrySet()) {
700 					List<String> values = multiOptions.get(item.getKey());
701 					if (values == null) {
702 						values = new ArrayList<>(item.getValue());
703 						multiOptions.put(item.getKey(), values);
704 					} else {
705 						values.addAll(item.getValue());
706 					}
707 				}
708 			}
709 		}
710 
711 		private List<String> substitute(List<String> values, String allowed,
712 				Replacer r, boolean withEnv) {
713 			List<String> result = new ArrayList<>(values.size());
714 			for (String value : values) {
715 				result.add(r.substitute(value, allowed, withEnv));
716 			}
717 			return result;
718 		}
719 
720 		private List<String> replaceTilde(List<String> values, File home) {
721 			List<String> result = new ArrayList<>(values.size());
722 			for (String value : values) {
723 				result.add(toFile(value, home).getPath());
724 			}
725 			return result;
726 		}
727 
728 		void substitute(String originalHostName, int port, String userName,
729 				String localUserName, File home) {
730 			int p = port >= 0 ? port : positive(getValue(SshConstants.PORT));
731 			if (p < 0) {
732 				p = SshConstants.SSH_DEFAULT_PORT;
733 			}
734 			String u = userName != null && !userName.isEmpty() ? userName
735 					: getValue(SshConstants.USER);
736 			if (u == null || u.isEmpty()) {
737 				u = localUserName;
738 			}
739 			Replacer r = new Replacer(originalHostName, p, u, localUserName,
740 					home);
741 			if (options != null) {
742 				// HOSTNAME first
743 				String hostName = options.get(SshConstants.HOST_NAME);
744 				if (hostName == null || hostName.isEmpty()) {
745 					options.put(SshConstants.HOST_NAME, originalHostName);
746 				} else {
747 					hostName = r.substitute(hostName, "h", false); //$NON-NLS-1$
748 					options.put(SshConstants.HOST_NAME, hostName);
749 					r.update('h', hostName);
750 				}
751 			}
752 			if (multiOptions != null) {
753 				List<String> values = multiOptions
754 						.get(SshConstants.IDENTITY_FILE);
755 				if (values != null) {
756 					values = substitute(values, "dhlru", r, true); //$NON-NLS-1$
757 					values = replaceTilde(values, home);
758 					multiOptions.put(SshConstants.IDENTITY_FILE, values);
759 				}
760 				values = multiOptions.get(SshConstants.CERTIFICATE_FILE);
761 				if (values != null) {
762 					values = substitute(values, "dhlru", r, true); //$NON-NLS-1$
763 					values = replaceTilde(values, home);
764 					multiOptions.put(SshConstants.CERTIFICATE_FILE, values);
765 				}
766 			}
767 			if (listOptions != null) {
768 				List<String> values = listOptions
769 						.get(SshConstants.USER_KNOWN_HOSTS_FILE);
770 				if (values != null) {
771 					values = replaceTilde(values, home);
772 					listOptions.put(SshConstants.USER_KNOWN_HOSTS_FILE, values);
773 				}
774 			}
775 			if (options != null) {
776 				// HOSTNAME already done above
777 				String value = options.get(SshConstants.IDENTITY_AGENT);
778 				if (value != null) {
779 					value = r.substitute(value, "dhlru", true); //$NON-NLS-1$
780 					value = toFile(value, home).getPath();
781 					options.put(SshConstants.IDENTITY_AGENT, value);
782 				}
783 				value = options.get(SshConstants.CONTROL_PATH);
784 				if (value != null) {
785 					value = r.substitute(value, "ChLlnpru", true); //$NON-NLS-1$
786 					value = toFile(value, home).getPath();
787 					options.put(SshConstants.CONTROL_PATH, value);
788 				}
789 				value = options.get(SshConstants.LOCAL_COMMAND);
790 				if (value != null) {
791 					value = r.substitute(value, "CdhlnprTu", false); //$NON-NLS-1$
792 					options.put(SshConstants.LOCAL_COMMAND, value);
793 				}
794 				value = options.get(SshConstants.REMOTE_COMMAND);
795 				if (value != null) {
796 					value = r.substitute(value, "Cdhlnpru", false); //$NON-NLS-1$
797 					options.put(SshConstants.REMOTE_COMMAND, value);
798 				}
799 				value = options.get(SshConstants.PROXY_COMMAND);
800 				if (value != null) {
801 					value = r.substitute(value, "hpr", false); //$NON-NLS-1$
802 					options.put(SshConstants.PROXY_COMMAND, value);
803 				}
804 			}
805 			// Match is not implemented and would need to be done elsewhere
806 			// anyway.
807 		}
808 
809 		/**
810 		 * Retrieves an unmodifiable map of all single-valued options, with
811 		 * case-insensitive lookup by keys.
812 		 *
813 		 * @return all single-valued options
814 		 */
815 		@Override
816 		@NonNull
817 		public Map<String, String> getOptions() {
818 			if (options == null) {
819 				return Collections.emptyMap();
820 			}
821 			return Collections.unmodifiableMap(options);
822 		}
823 
824 		/**
825 		 * Retrieves an unmodifiable map of all multi-valued options, with
826 		 * case-insensitive lookup by keys.
827 		 *
828 		 * @return all multi-valued options
829 		 */
830 		@Override
831 		@NonNull
832 		public Map<String, List<String>> getMultiValuedOptions() {
833 			if (listOptions == null && multiOptions == null) {
834 				return Collections.emptyMap();
835 			}
836 			Map<String, List<String>> allValues = new TreeMap<>(
837 					String.CASE_INSENSITIVE_ORDER);
838 			if (multiOptions != null) {
839 				allValues.putAll(multiOptions);
840 			}
841 			if (listOptions != null) {
842 				allValues.putAll(listOptions);
843 			}
844 			return Collections.unmodifiableMap(allValues);
845 		}
846 
847 		@Override
848 		@SuppressWarnings("nls")
849 		public String toString() {
850 			return "HostEntry [options=" + options + ", multiOptions="
851 					+ multiOptions + ", listOptions=" + listOptions + "]";
852 		}
853 	}
854 
855 	private static class Replacer {
856 		private final Map<Character, String> replacements = new HashMap<>();
857 
858 		public Replacer(String host, int port, String user,
859 				String localUserName, File home) {
860 			replacements.put(Character.valueOf('%'), "%"); //$NON-NLS-1$
861 			replacements.put(Character.valueOf('d'), home.getPath());
862 			replacements.put(Character.valueOf('h'), host);
863 			String localhost = SystemReader.getInstance().getHostname();
864 			replacements.put(Character.valueOf('l'), localhost);
865 			int period = localhost.indexOf('.');
866 			if (period > 0) {
867 				localhost = localhost.substring(0, period);
868 			}
869 			replacements.put(Character.valueOf('L'), localhost);
870 			replacements.put(Character.valueOf('n'), host);
871 			replacements.put(Character.valueOf('p'), Integer.toString(port));
872 			replacements.put(Character.valueOf('r'), user == null ? "" : user); //$NON-NLS-1$
873 			replacements.put(Character.valueOf('u'), localUserName);
874 			replacements.put(Character.valueOf('C'),
875 					substitute("%l%h%p%r", "hlpr", false)); //$NON-NLS-1$ //$NON-NLS-2$
876 			replacements.put(Character.valueOf('T'), "NONE"); //$NON-NLS-1$
877 		}
878 
879 		public void update(char key, String value) {
880 			replacements.put(Character.valueOf(key), value);
881 			if ("lhpr".indexOf(key) >= 0) { //$NON-NLS-1$
882 				replacements.put(Character.valueOf('C'),
883 						substitute("%l%h%p%r", "hlpr", false)); //$NON-NLS-1$ //$NON-NLS-2$
884 			}
885 		}
886 
887 		public String substitute(String input, String allowed,
888 				boolean withEnv) {
889 			if (input == null || input.length() <= 1
890 					|| (input.indexOf('%') < 0
891 							&& (!withEnv || input.indexOf("${") < 0))) { //$NON-NLS-1$
892 				return input;
893 			}
894 			StringBuilder builder = new StringBuilder();
895 			int start = 0;
896 			int length = input.length();
897 			while (start < length) {
898 				char ch = input.charAt(start);
899 				switch (ch) {
900 				case '%':
901 					if (start + 1 >= length) {
902 						break;
903 					}
904 					String replacement = null;
905 					ch = input.charAt(start + 1);
906 					if (ch == '%' || allowed.indexOf(ch) >= 0) {
907 						replacement = replacements.get(Character.valueOf(ch));
908 					}
909 					if (replacement == null) {
910 						builder.append('%').append(ch);
911 					} else {
912 						builder.append(replacement);
913 					}
914 					start += 2;
915 					continue;
916 				case '$':
917 					if (!withEnv || start + 2 >= length) {
918 						break;
919 					}
920 					ch = input.charAt(start + 1);
921 					if (ch == '{') {
922 						int close = input.indexOf('}', start + 2);
923 						if (close > start + 2) {
924 							String variable = SystemReader.getInstance()
925 									.getenv(input.substring(start + 2, close));
926 							if (!StringUtils.isEmptyOrNull(variable)) {
927 								builder.append(variable);
928 							}
929 							start = close + 1;
930 							continue;
931 						}
932 					}
933 					ch = '$';
934 					break;
935 				default:
936 					break;
937 				}
938 				builder.append(ch);
939 				start++;
940 			}
941 			return builder.toString();
942 		}
943 	}
944 
945 	/** {@inheritDoc} */
946 	@Override
947 	@SuppressWarnings("nls")
948 	public String toString() {
949 		return "OpenSshConfig [home=" + home + ", configFile=" + configFile
950 				+ ", lastModified=" + lastModified + ", state=" + state + "]";
951 	}
952 }