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