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