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