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