http://git-wip-us.apache.org/repos/asf/mina-sshd/blob/10de190e/sshd-common/src/main/java/org/apache/sshd/client/config/hosts/ConfigFileHostEntryResolver.java ---------------------------------------------------------------------- diff --git a/sshd-common/src/main/java/org/apache/sshd/client/config/hosts/ConfigFileHostEntryResolver.java b/sshd-common/src/main/java/org/apache/sshd/client/config/hosts/ConfigFileHostEntryResolver.java new file mode 100644 index 0000000..f1c9ea8 --- /dev/null +++ b/sshd-common/src/main/java/org/apache/sshd/client/config/hosts/ConfigFileHostEntryResolver.java @@ -0,0 +1,107 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.sshd.client.config.hosts; + +import java.io.File; +import java.io.IOException; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicReference; + +import org.apache.sshd.common.util.GenericUtils; +import org.apache.sshd.common.util.io.IoUtils; +import org.apache.sshd.common.util.io.ModifiableFileWatcher; + +/** + * Watches for changes in a configuration file and automatically reloads any changes + * + * @author <a href="mailto:[email protected]">Apache MINA SSHD Project</a> + */ +public class ConfigFileHostEntryResolver extends ModifiableFileWatcher implements HostConfigEntryResolver { + private final AtomicReference<HostConfigEntryResolver> delegateHolder = // assumes initially empty + new AtomicReference<>(HostConfigEntryResolver.EMPTY); + + public ConfigFileHostEntryResolver(File file) { + this(Objects.requireNonNull(file, "No file to watch").toPath()); + } + + public ConfigFileHostEntryResolver(Path file) { + this(file, IoUtils.EMPTY_LINK_OPTIONS); + } + + public ConfigFileHostEntryResolver(Path file, LinkOption... options) { + super(file, options); + } + + @Override + public HostConfigEntry resolveEffectiveHost(String host, int port, String username) throws IOException { + try { + HostConfigEntryResolver delegate = Objects.requireNonNull(resolveEffectiveResolver(host, port, username), "No delegate"); + HostConfigEntry entry = delegate.resolveEffectiveHost(host, port, username); + if (log.isDebugEnabled()) { + log.debug("resolveEffectiveHost({}@{}:{}) => {}", username, host, port, entry); + } + + return entry; + } catch (Throwable e) { + if (log.isDebugEnabled()) { + log.debug("resolveEffectiveHost({}@{}:{}) failed ({}) to resolve: {}", + username, host, port, e.getClass().getSimpleName(), e.getMessage()); + } + + if (log.isTraceEnabled()) { + log.trace("resolveEffectiveHost(" + username + "@" + host + ":" + port + ") resolution failure details", e); + } + if (e instanceof IOException) { + throw (IOException) e; + } else { + throw new IOException(e); + } + } + } + + protected HostConfigEntryResolver resolveEffectiveResolver(String host, int port, String username) throws IOException { + if (checkReloadRequired()) { + delegateHolder.set(HostConfigEntryResolver.EMPTY); // start fresh + + Path path = getPath(); + if (exists()) { + Collection<HostConfigEntry> entries = reloadHostConfigEntries(path, host, port, username); + if (GenericUtils.size(entries) > 0) { + delegateHolder.set(HostConfigEntry.toHostConfigEntryResolver(entries)); + } + } else { + log.info("resolveEffectiveResolver({}@{}:{}) no configuration file at {}", username, host, port, path); + } + } + + return delegateHolder.get(); + } + + protected List<HostConfigEntry> reloadHostConfigEntries(Path path, String host, int port, String username) throws IOException { + List<HostConfigEntry> entries = HostConfigEntry.readHostConfigEntries(path); + log.info("resolveEffectiveResolver({}@{}:{}) loaded {} entries from {}", username, host, port, GenericUtils.size(entries), path); + updateReloadAttributes(); + return entries; + } +}
http://git-wip-us.apache.org/repos/asf/mina-sshd/blob/10de190e/sshd-common/src/main/java/org/apache/sshd/client/config/hosts/DefaultConfigFileHostEntryResolver.java ---------------------------------------------------------------------- diff --git a/sshd-common/src/main/java/org/apache/sshd/client/config/hosts/DefaultConfigFileHostEntryResolver.java b/sshd-common/src/main/java/org/apache/sshd/client/config/hosts/DefaultConfigFileHostEntryResolver.java new file mode 100644 index 0000000..29bc355 --- /dev/null +++ b/sshd-common/src/main/java/org/apache/sshd/client/config/hosts/DefaultConfigFileHostEntryResolver.java @@ -0,0 +1,95 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.sshd.client.config.hosts; + +import java.io.File; +import java.io.IOException; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import org.apache.sshd.common.util.io.IoUtils; + +/** + * Monitors the {@code ~/.ssh/config} file of the user currently running + * the client, re-loading it if necessary. It also (optionally) enforces + * the same permissions regime as {@code OpenSSH} + * + * @author <a href="mailto:[email protected]">Apache MINA SSHD Project</a> + */ +public class DefaultConfigFileHostEntryResolver extends ConfigFileHostEntryResolver { + /** + * The default instance that enforces the same permissions regime as {@code OpenSSH} + */ + public static final DefaultConfigFileHostEntryResolver INSTANCE = new DefaultConfigFileHostEntryResolver(true); + + private final boolean strict; + + /** + * @param strict If {@code true} then makes sure that the containing folder + * has 0700 access and the file 0644. <B>Note:</B> for <I>Windows</I> it + * does not check these permissions + * @see #validateStrictConfigFilePermissions(Path, LinkOption...) + */ + public DefaultConfigFileHostEntryResolver(boolean strict) { + this(HostConfigEntry.getDefaultHostConfigFile(), strict); + } + + public DefaultConfigFileHostEntryResolver(File file, boolean strict) { + this(Objects.requireNonNull(file, "No file provided").toPath(), strict, IoUtils.getLinkOptions(true)); + } + + public DefaultConfigFileHostEntryResolver(Path path, boolean strict, LinkOption... options) { + super(path, options); + this.strict = strict; + } + + /** + * @return If {@code true} then makes sure that the containing folder + * has 0700 access and the file 0644. <B>Note:</B> for <I>Windows</I> it + * does not check these permissions + * @see #validateStrictConfigFilePermissions(Path, LinkOption...) + */ + public final boolean isStrict() { + return strict; + } + + @Override + protected List<HostConfigEntry> reloadHostConfigEntries(Path path, String host, int port, String username) throws IOException { + if (isStrict()) { + if (log.isDebugEnabled()) { + log.debug("reloadHostConfigEntries({}@{}:{}) check permissions of {}", username, host, port, path); + } + + Map.Entry<String, ?> violation = validateStrictConfigFilePermissions(path); + if (violation != null) { + log.warn("reloadHostConfigEntries({}@{}:{}) invalid file={} permissions: {}", + username, host, port, path, violation.getKey()); + updateReloadAttributes(); + return Collections.emptyList(); + } + } + + return super.reloadHostConfigEntries(path, host, port, username); + } +} http://git-wip-us.apache.org/repos/asf/mina-sshd/blob/10de190e/sshd-common/src/main/java/org/apache/sshd/client/config/hosts/HostConfigEntry.java ---------------------------------------------------------------------- diff --git a/sshd-common/src/main/java/org/apache/sshd/client/config/hosts/HostConfigEntry.java b/sshd-common/src/main/java/org/apache/sshd/client/config/hosts/HostConfigEntry.java new file mode 100644 index 0000000..8a05d75 --- /dev/null +++ b/sshd-common/src/main/java/org/apache/sshd/client/config/hosts/HostConfigEntry.java @@ -0,0 +1,1169 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.sshd.client.config.hosts; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.Reader; +import java.io.StreamCorruptedException; +import java.io.Writer; +import java.net.InetAddress; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.OpenOption; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.TreeMap; + +import org.apache.sshd.common.auth.MutableUserHolder; +import org.apache.sshd.common.config.ConfigFileReaderSupport; +import org.apache.sshd.common.config.keys.IdentityUtils; +import org.apache.sshd.common.config.keys.PublicKeyEntry; +import org.apache.sshd.common.util.GenericUtils; +import org.apache.sshd.common.util.OsUtils; +import org.apache.sshd.common.util.ValidateUtils; +import org.apache.sshd.common.util.io.IoUtils; +import org.apache.sshd.common.util.io.NoCloseInputStream; +import org.apache.sshd.common.util.io.NoCloseOutputStream; +import org.apache.sshd.common.util.io.NoCloseReader; + +/** + * Represents an entry in the client's configuration file as defined by + * the <A HREF="http://www.gsp.com/cgi-bin/man.cgi?topic=ssh_config">configuration + * file format</A> + * @author <a href="mailto:[email protected]">Apache MINA SSHD Project</a> + */ +public class HostConfigEntry extends HostPatternsHolder implements MutableUserHolder { + /** + * Standard OpenSSH config file name + */ + public static final String STD_CONFIG_FILENAME = "config"; + + public static final String HOST_CONFIG_PROP = "Host"; + public static final String HOST_NAME_CONFIG_PROP = "HostName"; + public static final String PORT_CONFIG_PROP = ConfigFileReaderSupport.PORT_CONFIG_PROP; + public static final String USER_CONFIG_PROP = "User"; + public static final String IDENTITY_FILE_CONFIG_PROP = "IdentityFile"; + /** + * Use only the identities specified in the host entry (if any) + */ + public static final String EXCLUSIVE_IDENTITIES_CONFIG_PROP = "IdentitiesOnly"; + public static final boolean DEFAULT_EXCLUSIVE_IDENTITIES = false; + + /** + * A case <U>insensitive</U> {@link Set} of the properties that receive special handling + */ + public static final Set<String> EXPLICIT_PROPERTIES = + Collections.unmodifiableSet( + GenericUtils.asSortedSet(String.CASE_INSENSITIVE_ORDER, + HOST_CONFIG_PROP, HOST_NAME_CONFIG_PROP, PORT_CONFIG_PROP, + USER_CONFIG_PROP, IDENTITY_FILE_CONFIG_PROP, EXCLUSIVE_IDENTITIES_CONFIG_PROP + )); + + public static final String MULTI_VALUE_SEPARATORS = " ,"; + + public static final char HOME_TILDE_CHAR = '~'; + public static final char PATH_MACRO_CHAR = '%'; + public static final char LOCAL_HOME_MACRO = 'd'; + public static final char LOCAL_USER_MACRO = 'u'; + public static final char LOCAL_HOST_MACRO = 'l'; + public static final char REMOTE_HOST_MACRO = 'h'; + public static final char REMOTE_USER_MACRO = 'r'; + // Extra - not part of the standard + public static final char REMOTE_PORT_MACRO = 'p'; + + private static final class LazyDefaultConfigFileHolder { + private static final Path CONFIG_FILE = + PublicKeyEntry.getDefaultKeysFolderPath().resolve(STD_CONFIG_FILENAME); + + private LazyDefaultConfigFileHolder() { + throw new UnsupportedOperationException("No instance allowed"); + } + } + + private String host; + private String hostName; + private int port; + private String username; + private Boolean exclusiveIdentites; + private Collection<String> identities = Collections.emptyList(); + private Map<String, String> properties = Collections.emptyMap(); + + public HostConfigEntry() { + super(); + } + + public HostConfigEntry(String pattern, String host, int port, String username) { + setHost(pattern); + setHostName(host); + setPort(port); + setUsername(username); + } + + /** + * @return The <U>pattern(s)</U> represented by this entry + */ + public String getHost() { + return host; + } + + public void setHost(String host) { + this.host = host; + setPatterns(parsePatterns(parseConfigValue(host))); + } + + public void setHost(Collection<String> patterns) { + this.host = GenericUtils.join(ValidateUtils.checkNotNullAndNotEmpty(patterns, "No patterns"), ','); + setPatterns(parsePatterns(patterns)); + } + + /** + * @return The effective host name to connect to if the pattern matches + */ + public String getHostName() { + return hostName; + } + + public void setHostName(String hostName) { + this.hostName = hostName; + } + + public String resolveHostName(String originalHost) { + return resolveHostName(originalHost, getHostName()); + } + + /** + * @return A port override - if positive + */ + public int getPort() { + return port; + } + + public void setPort(int port) { + this.port = port; + } + + /** + * Resolves the effective port to use + * + * @param originalPort The original requested port + * @return If the host entry port is positive, then it is used, otherwise + * the original requested port + * @see #resolvePort(int, int) + */ + public int resolvePort(int originalPort) { + return resolvePort(originalPort, getPort()); + } + + /** + * @return A username override - if not {@code null}/empty + */ + @Override + public String getUsername() { + return username; + } + + @Override + public void setUsername(String username) { + this.username = username; + } + + /** + * Resolves the effective username + * + * @param originalUser The original requested username + * @return If the configured host entry username is not {@code null}/empty + * then it is used, otherwise the original one. + * @see #resolveUsername(String) + */ + public String resolveUsername(String originalUser) { + return resolveUsername(originalUser, getUsername()); + } + + /** + * @return The current identities file paths - may be {@code null}/empty + */ + public Collection<String> getIdentities() { + return identities; + } + + /** + * @param file A {@link File} that contains an identity key - never {@code null} + */ + public void addIdentity(File file) { + addIdentity(Objects.requireNonNull(file, "No file").toPath()); + } + + /** + * @param path A {@link Path} to a file that contains an identity key + * - never {@code null} + */ + public void addIdentity(Path path) { + addIdentity(Objects.requireNonNull(path, "No path").toAbsolutePath().normalize().toString()); + } + + /** + * Adds a path to an identity file + * + * @param id The identity path to add - never {@code null} + */ + public void addIdentity(String id) { + String path = ValidateUtils.checkNotNullAndNotEmpty(id, "No identity provided"); + if (GenericUtils.isEmpty(identities)) { + identities = new LinkedList<>(); + } + identities.add(path); + } + + public void setIdentities(Collection<String> identities) { + this.identities = (identities == null) ? Collections.emptyList() : identities; + } + + /** + * @return {@code true} if must use only the identities in this entry + */ + public boolean isIdentitiesOnly() { + return (exclusiveIdentites == null) ? DEFAULT_EXCLUSIVE_IDENTITIES : exclusiveIdentites; + } + + public void setIdentitiesOnly(boolean identitiesOnly) { + exclusiveIdentites = identitiesOnly; + } + + /** + * @return A {@link Map} of extra properties that have been read - may be + * {@code null}/empty, or even contain some values that have been parsed + * and set as members of the entry (e.g., host, port, etc.). <B>Note:</B> + * multi-valued keys use a comma-separated list of values + */ + public Map<String, String> getProperties() { + return properties; + } + + /** + * @param name Property name - never {@code null}/empty + * @return Property value or {@code null} if no such property + * @see #getProperty(String, String) + */ + public String getProperty(String name) { + return getProperty(name, null); + } + + /** + * @param name Property name - never {@code null}/empty + * @param defaultValue Default value to return if no such property + * @return The property value or the default one if no such property + */ + public String getProperty(String name, String defaultValue) { + String key = ValidateUtils.checkNotNullAndNotEmpty(name, "No property name"); + Map<String, String> props = getProperties(); + if (GenericUtils.isEmpty(props)) { + return null; + } + + String value = props.get(key); + if (GenericUtils.isEmpty(value)) { + return defaultValue; + } else { + return value; + } + } + + /** + * Updates the values that are <U>not</U> already configured with those + * from the global entry + * + * @param globalEntry The global entry - ignored if {@code null} or + * same reference as this entry + * @return {@code true} if anything updated + */ + public boolean processGlobalValues(HostConfigEntry globalEntry) { + if ((globalEntry == null) || (this == globalEntry)) { + return false; + } + + boolean modified = false; + /* + * NOTE !!! DO NOT TRY TO CHANGE THE ORDER OF THE OR-ing AS IT + * WOULD CAUSE INVALID CODE EXECUTION + */ + modified = updateGlobalPort(globalEntry.getPort()) || modified; + modified = updateGlobalHostName(globalEntry.getHostName()) || modified; + modified = updateGlobalUserName(globalEntry.getUsername()) || modified; + modified = updateGlobalIdentities(globalEntry.getIdentities()) || modified; + modified = updateGlobalIdentityOnly(globalEntry.isIdentitiesOnly()) || modified; + + Map<String, String> updated = updateGlobalProperties(globalEntry.getProperties()); + modified = (GenericUtils.size(updated) > 0) || modified; + + return modified; + } + + /** + * Sets all the properties for which no current value exists in the entry + * + * @param props The global properties - ignored if {@code null}/empty + * @return A {@link Map} of the <U>updated</U> properties + */ + public Map<String, String> updateGlobalProperties(Map<String, String> props) { + if (GenericUtils.isEmpty(props)) { + return Collections.emptyMap(); + } + + Map<String, String> updated = null; + // Cannot use forEach because of the modification of the updated map value (non-final) + for (Map.Entry<String, String> pe : props.entrySet()) { + String key = pe.getKey(); + String curValue = getProperty(key); + if (GenericUtils.length(curValue) > 0) { + continue; + } + + String newValue = pe.getValue(); + setProperty(key, newValue); + + if (updated == null) { + updated = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + } + + updated.put(key, newValue); + } + + if (updated == null) { + return Collections.emptyMap(); + } else { + return updated; + } + } + + /** + * @param ids Global identities - ignored if {@code null}/empty or already + * have configured identities + * @return {@code true} if updated identities + */ + public boolean updateGlobalIdentities(Collection<String> ids) { + if (GenericUtils.isEmpty(ids) || (GenericUtils.size(getIdentities()) > 0)) { + return false; + } + + for (String id : ids) { + addIdentity(id); + } + + return true; + } + + /** + * @param user The global user name - ignored if {@code null}/empty or + * already have a configured user + * @return {@code true} if updated the username + */ + public boolean updateGlobalUserName(String user) { + if (GenericUtils.isEmpty(user) || (GenericUtils.length(getUsername()) > 0)) { + return false; + } + + setUsername(user); + return true; + } + + /** + * @param name The global host name - ignored if {@code null}/empty or + * already have a configured target host + * @return {@code true} if updated the target host + */ + public boolean updateGlobalHostName(String name) { + if (GenericUtils.isEmpty(name) || (GenericUtils.length(getHostName()) > 0)) { + return false; + } + + setHostName(name); + return true; + } + + /** + * @param portValue The global port value - ignored if not positive + * or already have a configured port + * @return {@code true} if updated the port value + */ + public boolean updateGlobalPort(int portValue) { + if ((portValue <= 0) || (getPort() > 0)) { + return false; + } + + setPort(portValue); + return true; + } + + /** + * @param identitiesOnly Whether to use only the identities in this entry. + * Ignored if already set + * @return {@code true} if updated the option value + */ + public boolean updateGlobalIdentityOnly(boolean identitiesOnly) { + if (exclusiveIdentites != null) { + return false; + } + + setIdentitiesOnly(identitiesOnly); + return true; + } + + /** + * @param name Property name - never {@code null}/empty + * @param valsList The available values for the property + * @param ignoreAlreadyInitialized If {@code false} and one of the "known" + * properties is encountered then throws an exception + * @throws IllegalArgumentException If an existing value is overwritten and + * <tt>ignoreAlreadyInitialized</tt> is {@code false} (except for {@link #IDENTITY_FILE_CONFIG_PROP} + * which is <U>cumulative</U> + * @see #HOST_NAME_CONFIG_PROP + * @see #PORT_CONFIG_PROP + * @see #USER_CONFIG_PROP + * @see #IDENTITY_FILE_CONFIG_PROP + */ + public void processProperty(String name, Collection<String> valsList, boolean ignoreAlreadyInitialized) { + String key = ValidateUtils.checkNotNullAndNotEmpty(name, "No property name"); + String joinedValue = GenericUtils.join(valsList, ','); + appendPropertyValue(key, joinedValue); + + if (HOST_NAME_CONFIG_PROP.equalsIgnoreCase(key)) { + ValidateUtils.checkTrue(GenericUtils.size(valsList) == 1, "Multiple target hosts N/A: %s", joinedValue); + + String curValue = getHostName(); + ValidateUtils.checkTrue(GenericUtils.isEmpty(curValue) || ignoreAlreadyInitialized, "Already initialized %s: %s", key, curValue); + setHostName(joinedValue); + } else if (PORT_CONFIG_PROP.equalsIgnoreCase(key)) { + ValidateUtils.checkTrue(GenericUtils.size(valsList) == 1, "Multiple target ports N/A: %s", joinedValue); + + int curValue = getPort(); + ValidateUtils.checkTrue((curValue <= 0) || ignoreAlreadyInitialized, "Already initialized %s: %d", key, curValue); + + int newValue = Integer.parseInt(joinedValue); + ValidateUtils.checkTrue(newValue > 0, "Bad new port value: %d", newValue); + setPort(newValue); + } else if (USER_CONFIG_PROP.equalsIgnoreCase(key)) { + ValidateUtils.checkTrue(GenericUtils.size(valsList) == 1, "Multiple target users N/A: %s", joinedValue); + + String curValue = getUsername(); + ValidateUtils.checkTrue(GenericUtils.isEmpty(curValue) || ignoreAlreadyInitialized, "Already initialized %s: %s", key, curValue); + setUsername(joinedValue); + } else if (IDENTITY_FILE_CONFIG_PROP.equalsIgnoreCase(key)) { + ValidateUtils.checkTrue(GenericUtils.size(valsList) > 0, "No identity files specified"); + for (String id : valsList) { + addIdentity(id); + } + } else if (EXCLUSIVE_IDENTITIES_CONFIG_PROP.equalsIgnoreCase(key)) { + setIdentitiesOnly( + ConfigFileReaderSupport.parseBooleanValue( + ValidateUtils.checkNotNullAndNotEmpty(joinedValue, "No identities option value"))); + } + } + + /** + * Appends a value using a <U>comma</U> to an existing one. If no previous + * value then same as calling {@link #setProperty(String, String)}. + * + * @param name Property name - never {@code null}/empty + * @param value The value to be appended - ignored if {@code null}/empty + * @return The value <U>before</U> appending - {@code null} if no previous value + */ + public String appendPropertyValue(String name, String value) { + String key = ValidateUtils.checkNotNullAndNotEmpty(name, "No property name"); + String curVal = getProperty(key); + if (GenericUtils.isEmpty(value)) { + return curVal; + } + + if (GenericUtils.isEmpty(curVal)) { + return setProperty(key, value); + } + + return setProperty(key, curVal + ',' + value); + } + + /** + * Sets / Replaces the property value + * + * @param name Property name - never {@code null}/empty + * @param value Property value - if {@code null}/empty then + * {@link #removeProperty(String)} is called + * @return The previous property value - {@code null} if no such name + */ + public String setProperty(String name, String value) { + if (GenericUtils.isEmpty(value)) { + return removeProperty(name); + } + + String key = ValidateUtils.checkNotNullAndNotEmpty(name, "No property name"); + if (GenericUtils.isEmpty(properties)) { + properties = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + } + + return properties.put(key, value); + } + + /** + * @param name Property name - never {@code null}/empty + * @return The removed property value - {@code null} if no such property name + */ + public String removeProperty(String name) { + String key = ValidateUtils.checkNotNullAndNotEmpty(name, "No property name"); + Map<String, String> props = getProperties(); + if (GenericUtils.isEmpty(props)) { + return null; + } else { + return props.remove(key); + } + } + + /** + * @param properties The properties to set - if {@code null} then an empty + * map is effectively set. <B>Note:</B> it is highly recommended to use a + * <U>case insensitive</U> key mapper. + */ + public void setProperties(Map<String, String> properties) { + this.properties = (properties == null) ? Collections.emptyMap() : properties; + } + + public <A extends Appendable> A append(A sb) throws IOException { + sb.append(HOST_CONFIG_PROP).append(' ').append(ValidateUtils.checkNotNullAndNotEmpty(getHost(), "No host pattern")).append(IoUtils.EOL); + appendNonEmptyProperty(sb, HOST_NAME_CONFIG_PROP, getHostName()); + appendNonEmptyPort(sb, PORT_CONFIG_PROP, getPort()); + appendNonEmptyProperty(sb, USER_CONFIG_PROP, getUsername()); + appendNonEmptyValues(sb, IDENTITY_FILE_CONFIG_PROP, getIdentities()); + if (exclusiveIdentites != null) { + appendNonEmptyProperty(sb, EXCLUSIVE_IDENTITIES_CONFIG_PROP, ConfigFileReaderSupport.yesNoValueOf(exclusiveIdentites)); + } + appendNonEmptyProperties(sb, getProperties()); + return sb; + } + + @Override + public String toString() { + return getHost() + ": " + getUsername() + "@" + getHostName() + ":" + getPort(); + } + + /** + * @param <A> The {@link Appendable} type + * @param sb The target appender + * @param name The property name - never {@code null}/empty + * @param port The port value - ignored if non-positive + * @return The target appender after having appended (or not) the value + * @throws IOException If failed to append the requested data + * @see #appendNonEmptyProperty(Appendable, String, Object) + */ + public static <A extends Appendable> A appendNonEmptyPort(A sb, String name, int port) throws IOException { + return appendNonEmptyProperty(sb, name, (port > 0) ? Integer.toString(port) : null); + } + + /** + * Appends the extra properties - while skipping the {@link #EXPLICIT_PROPERTIES} ones + * + * @param <A> The {@link Appendable} type + * @param sb The target appender + * @param props The {@link Map} of properties - ignored if {@code null}/empty + * @return The target appender after having appended (or not) the value + * @throws IOException If failed to append the requested data + * @see #appendNonEmptyProperty(Appendable, String, Object) + */ + public static <A extends Appendable> A appendNonEmptyProperties(A sb, Map<String, ?> props) throws IOException { + if (GenericUtils.isEmpty(props)) { + return sb; + } + + // Cannot use forEach because of the IOException being thrown by appendNonEmptyProperty + for (Map.Entry<String, ?> pe : props.entrySet()) { + String name = pe.getKey(); + if (EXPLICIT_PROPERTIES.contains(name)) { + continue; + } + + appendNonEmptyProperty(sb, name, pe.getValue()); + } + + return sb; + } + + /** + * @param <A> The {@link Appendable} type + * @param sb The target appender + * @param name The property name - never {@code null}/empty + * @param value The property value - ignored if {@code null}. <B>Note:</B> + * if the string representation of the value contains any commas, they are + * assumed to indicate a multi-valued property which is broken down to + * <U>individual</U> lines - one per value. + * @return The target appender after having appended (or not) the value + * @throws IOException If failed to append the requested data + * @see #appendNonEmptyValues(Appendable, String, Object...) + */ + public static <A extends Appendable> A appendNonEmptyProperty(A sb, String name, Object value) throws IOException { + String s = Objects.toString(value, null); + String[] vals = GenericUtils.split(s, ','); + return appendNonEmptyValues(sb, name, (Object[]) vals); + } + + /** + * @param <A> The {@link Appendable} type + * @param sb The target appender + * @param name The property name - never {@code null}/empty + * @param values The values to be added - one per line - ignored if {@code null}/empty + * @return The target appender after having appended (or not) the value + * @throws IOException If failed to append the requested data + * @see #appendNonEmptyValues(Appendable, String, Collection) + */ + public static <A extends Appendable> A appendNonEmptyValues(A sb, String name, Object... values) throws IOException { + return appendNonEmptyValues(sb, name, GenericUtils.isEmpty(values) ? Collections.emptyList() : Arrays.asList(values)); + } + + /** + * @param <A> The {@link Appendable} type + * @param sb The target appender + * @param name The property name - never {@code null}/empty + * @param values The values to be added - one per line - ignored if {@code null}/empty + * @return The target appender after having appended (or not) the value + * @throws IOException If failed to append the requested data + */ + public static <A extends Appendable> A appendNonEmptyValues(A sb, String name, Collection<?> values) throws IOException { + String k = ValidateUtils.checkNotNullAndNotEmpty(name, "No property name"); + if (GenericUtils.isEmpty(values)) { + return sb; + } + + for (Object v : values) { + sb.append(" ").append(k).append(' ').append(Objects.toString(v)).append(IoUtils.EOL); + } + + return sb; + } + + /** + * @param entries The entries - ignored if {@code null}/empty + * @return A {@link HostConfigEntryResolver} wrapper using the entries + */ + public static HostConfigEntryResolver toHostConfigEntryResolver(Collection<? extends HostConfigEntry> entries) { + if (GenericUtils.isEmpty(entries)) { + return HostConfigEntryResolver.EMPTY; + } else { + return (host1, port1, username1) -> { + List<HostConfigEntry> matches = findMatchingEntries(host1, entries); + int numMatches = GenericUtils.size(matches); + if (numMatches <= 0) { + return null; + } + + HostConfigEntry match = (numMatches == 1) ? matches.get(0) : findBestMatch(matches); + if (match == null) { + ValidateUtils.throwIllegalArgumentException("No best match found for %s@%s:%d out of %d matches", username1, host1, port1, numMatches); + } + + return normalizeEntry(match, host1, port1, username1); + }; + } + } + + /** + * @param entry The original entry - ignored if {@code null} + * @param host The original host name / address + * @param port The original port + * @param username The original user name + * @return A <U>cloned</U> entry whose values are resolved - including + * expanding macros in the identities files + * @throws IOException If failed to normalize the entry + * @see #resolveHostName(String) + * @see #resolvePort(int) + * @see #resolveUsername(String) + * @see #resolveIdentityFilePath(String, String, int, String) + */ + public static HostConfigEntry normalizeEntry(HostConfigEntry entry, String host, int port, String username) throws IOException { + if (entry == null) { + return null; + } + + HostConfigEntry normal = new HostConfigEntry(); + normal.setHost(host); + normal.setHostName(entry.resolveHostName(host)); + normal.setPort(entry.resolvePort(port)); + normal.setUsername(entry.resolveUsername(username)); + + Map<String, String> props = entry.getProperties(); + if (GenericUtils.size(props) > 0) { + normal.setProperties(new TreeMap<>(props)); + } + + Collection<String> ids = entry.getIdentities(); + if (GenericUtils.isEmpty(ids)) { + return normal; + } + + normal.setIdentities(Collections.emptyList()); // start fresh + for (String id : ids) { + String path = resolveIdentityFilePath(id, host, port, username); + normal.addIdentity(path); + } + + return normal; + } + + /** + * Resolves the effective target host + * + * @param originalName The original requested host + * @param entryName The configured host + * @return If the configured host entry is not {@code null}/empty + * then it is used, otherwise the original one. + */ + public static String resolveHostName(String originalName, String entryName) { + if (GenericUtils.isEmpty(entryName)) { + return originalName; + } else { + return entryName; + } + } + + /** + * Resolves the effective username + * + * @param originalUser The original requested username + * @param entryUser The configured host entry username + * @return If the configured host entry username is not {@code null}/empty + * then it is used, otherwise the original one. + */ + public static String resolveUsername(String originalUser, String entryUser) { + if (GenericUtils.isEmpty(entryUser)) { + return originalUser; + } else { + return entryUser; + } + } + + /** + * Resolves the effective port to use + * + * @param originalPort The original requested port + * @param entryPort The configured host entry port + * @return If the host entry port is positive, then it is used, otherwise + * the original requested port + */ + public static int resolvePort(int originalPort, int entryPort) { + if (entryPort <= 0) { + return originalPort; + } else { + return entryPort; + } + } + + public static List<HostConfigEntry> readHostConfigEntries(File file) throws IOException { + return readHostConfigEntries(file.toPath(), IoUtils.EMPTY_OPEN_OPTIONS); + } + + public static List<HostConfigEntry> readHostConfigEntries(Path path, OpenOption... options) throws IOException { + try (InputStream input = Files.newInputStream(path, options)) { + return readHostConfigEntries(input, true); + } + } + + public static List<HostConfigEntry> readHostConfigEntries(URL url) throws IOException { + try (InputStream input = url.openStream()) { + return readHostConfigEntries(input, true); + } + } + + public static List<HostConfigEntry> readHostConfigEntries(String filePath) throws IOException { + try (InputStream inStream = new FileInputStream(filePath)) { + return readHostConfigEntries(inStream, true); + } + } + + public static List<HostConfigEntry> readHostConfigEntries(InputStream inStream, boolean okToClose) throws IOException { + try (Reader reader = new InputStreamReader(NoCloseInputStream.resolveInputStream(inStream, okToClose), StandardCharsets.UTF_8)) { + return readHostConfigEntries(reader, true); + } + } + + public static List<HostConfigEntry> readHostConfigEntries(Reader rdr, boolean okToClose) throws IOException { + try (BufferedReader buf = new BufferedReader(NoCloseReader.resolveReader(rdr, okToClose))) { + return readHostConfigEntries(buf); + } + } + + /** + * Reads configuration entries + * + * @param rdr The {@link BufferedReader} to use + * @return The {@link List} of read {@link HostConfigEntry}-ies + * @throws IOException If failed to parse the read configuration + */ + public static List<HostConfigEntry> readHostConfigEntries(BufferedReader rdr) throws IOException { + HostConfigEntry curEntry = null; + HostConfigEntry globalEntry = null; + List<HostConfigEntry> entries = null; + + int lineNumber = 1; + for (String line = rdr.readLine(); line != null; line = rdr.readLine(), lineNumber++) { + line = GenericUtils.replaceWhitespaceAndTrim(line); + if (GenericUtils.isEmpty(line)) { + continue; + } + + int pos = line.indexOf(ConfigFileReaderSupport.COMMENT_CHAR); + if (pos == 0) { + continue; + } + + if (pos > 0) { + line = line.substring(0, pos); + line = line.trim(); + } + + /* + * Some options use '=', others use ' ' - try both + * NOTE: we do not validate the format for each option separately + */ + pos = line.indexOf(' '); + if (pos < 0) { + pos = line.indexOf('='); + } + + if (pos < 0) { + throw new StreamCorruptedException("No configuration value delimiter at line " + lineNumber + ": " + line); + } + + String key = line.substring(0, pos); + String value = line.substring(pos + 1); + List<String> valsList = parseConfigValue(value); + + if (HOST_CONFIG_PROP.equalsIgnoreCase(key)) { + if (GenericUtils.isEmpty(valsList)) { + throw new StreamCorruptedException("Missing host pattern(s) at line " + lineNumber + ": " + line); + } + + // If the all-hosts pattern is used, make sure no global section already active + for (String name : valsList) { + if (ALL_HOSTS_PATTERN.equalsIgnoreCase(name) && (globalEntry != null)) { + throw new StreamCorruptedException("Overriding the global section with a specific one at line " + lineNumber + ": " + line); + } + } + + if (curEntry != null) { + curEntry.processGlobalValues(globalEntry); + } + + entries = updateEntriesList(entries, curEntry); + + curEntry = new HostConfigEntry(); + curEntry.setHost(valsList); + } else if (curEntry == null) { + // if 1st encountered property is NOT for a specific host, then configuration applies to ALL + curEntry = new HostConfigEntry(); + curEntry.setHost(Collections.singletonList(ALL_HOSTS_PATTERN)); + globalEntry = curEntry; + } + + try { + curEntry.processProperty(key, valsList, false); + } catch (RuntimeException e) { + throw new StreamCorruptedException("Failed (" + e.getClass().getSimpleName() + ")" + + " to process line #" + lineNumber + " (" + line + ")" + + ": " + e.getMessage()); + } + } + + if (curEntry != null) { + curEntry.processGlobalValues(globalEntry); + } + + entries = updateEntriesList(entries, curEntry); + if (entries == null) { + return Collections.emptyList(); + } else { + return entries; + } + } + + /** + * Finds the best match out of the given ones. + * + * @param matches The available matches - ignored if {@code null}/empty + * @return The best match or {@code null} if no matches or no best match found + * @see #findBestMatch(Iterator) + */ + public static HostConfigEntry findBestMatch(Collection<? extends HostConfigEntry> matches) { + if (GenericUtils.isEmpty(matches)) { + return null; + } else { + return findBestMatch(matches.iterator()); + } + } + + /** + * Finds the best match out of the given ones. + * + * @param matches The available matches - ignored if {@code null}/empty + * @return The best match or {@code null} if no matches or no best match found + * @see #findBestMatch(Iterator) + */ + public static HostConfigEntry findBestMatch(Iterable<? extends HostConfigEntry> matches) { + if (matches == null) { + return null; + } else { + return findBestMatch(matches.iterator()); + } + } + + /** + * Finds the best match out of the given ones. The best match is defined as one whose + * pattern is as <U>specific</U> as possible (if more than one match is available). + * I.e., a non-global match is preferred over global one, and a match with no wildcards + * is preferred over one with such a pattern. + * + * @param matches The available matches - ignored if {@code null}/empty + * @return The best match or {@code null} if no matches or no best match found + * @see #isSpecificHostPattern(String) + */ + public static HostConfigEntry findBestMatch(Iterator<? extends HostConfigEntry> matches) { + if ((matches == null) || (!matches.hasNext())) { + return null; + } + + HostConfigEntry candidate = matches.next(); + int wildcardMatches = 0; + while (matches.hasNext()) { + HostConfigEntry entry = matches.next(); + String entryPattern = entry.getHost(); + String candidatePattern = candidate.getHost(); + // prefer non-global entry over global entry + if (ALL_HOSTS_PATTERN.equalsIgnoreCase(candidatePattern)) { + // unlikely, but handle it + if (ALL_HOSTS_PATTERN.equalsIgnoreCase(entryPattern)) { + wildcardMatches++; + } else { + candidate = entry; + wildcardMatches = 0; + } + continue; + } + + if (isSpecificHostPattern(entryPattern)) { + // if both are specific then no best match + if (isSpecificHostPattern(candidatePattern)) { + return null; + } + + candidate = entry; + wildcardMatches = 0; + continue; + } + + wildcardMatches++; + } + + String candidatePattern = candidate.getHost(); + // best match either has specific host or no wildcard matches + if ((wildcardMatches <= 0) || (isSpecificHostPattern(candidatePattern))) { + return candidate; + } + + return null; + } + + public static List<HostConfigEntry> updateEntriesList(List<HostConfigEntry> entries, HostConfigEntry curEntry) { + if (curEntry == null) { + return entries; + } + + if (entries == null) { + entries = new ArrayList<>(); + } + + entries.add(curEntry); + return entries; + } + + public static void writeHostConfigEntries(File file, Collection<? extends HostConfigEntry> entries) throws IOException { + writeHostConfigEntries(Objects.requireNonNull(file, "No file").toPath(), entries, IoUtils.EMPTY_OPEN_OPTIONS); + } + + public static void writeHostConfigEntries(Path path, Collection<? extends HostConfigEntry> entries, OpenOption... options) throws IOException { + try (OutputStream outputStream = Files.newOutputStream(path, options)) { + writeHostConfigEntries(outputStream, true, entries); + } + } + + public static void writeHostConfigEntries(OutputStream outputStream, boolean okToClose, Collection<? extends HostConfigEntry> entries) throws IOException { + if (GenericUtils.isEmpty(entries)) { + return; + } + + try (Writer w = new OutputStreamWriter(NoCloseOutputStream.resolveOutputStream(outputStream, okToClose), StandardCharsets.UTF_8)) { + appendHostConfigEntries(w, entries); + } + } + + public static <A extends Appendable> A appendHostConfigEntries(A sb, Collection<? extends HostConfigEntry> entries) throws IOException { + if (GenericUtils.isEmpty(entries)) { + return sb; + } + + for (HostConfigEntry entry : entries) { + entry.append(sb); + } + + return sb; + } + + /** + * Checks if this is a multi-value - allow space and comma + * + * @param value The value - ignored if {@code null}/empty (after trimming) + * @return A {@link List} of the encountered values + */ + public static List<String> parseConfigValue(String value) { + String s = GenericUtils.replaceWhitespaceAndTrim(value); + if (GenericUtils.isEmpty(s)) { + return Collections.emptyList(); + } + + for (int index = 0; index < MULTI_VALUE_SEPARATORS.length(); index++) { + char sep = MULTI_VALUE_SEPARATORS.charAt(index); + int pos = s.indexOf(sep); + if (pos >= 0) { + String[] vals = GenericUtils.split(s, sep); + if (GenericUtils.isEmpty(vals)) { + return Collections.emptyList(); + } else { + return Arrays.asList(vals); + } + } + } + + // this point is reached if no separators found + return Collections.singletonList(s); + } + + // The file name may use the tilde syntax to refer to a userâs home directory or one of the following escape characters: + // '%d' (local user's home directory), '%u' (local user name), '%l' (local host name), '%h' (remote host name) or '%r' (remote user name). + public static String resolveIdentityFilePath(String id, String host, int port, String username) throws IOException { + if (GenericUtils.isEmpty(id)) { + return id; + } + + String path = id.replace('/', File.separatorChar); // make sure all separators are local + String[] elements = GenericUtils.split(path, File.separatorChar); + StringBuilder sb = new StringBuilder(path.length() + Long.SIZE); + for (int index = 0; index < elements.length; index++) { + String elem = elements[index]; + if (index > 0) { + sb.append(File.separatorChar); + } + + for (int curPos = 0; curPos < elem.length(); curPos++) { + char ch = elem.charAt(curPos); + if (ch == HOME_TILDE_CHAR) { + ValidateUtils.checkTrue((curPos == 0) && (index == 0), "Home tilde must be first: %s", id); + appendUserHome(sb); + } else if (ch == PATH_MACRO_CHAR) { + curPos++; + ValidateUtils.checkTrue(curPos < elem.length(), "Missing macro modifier in %s", id); + ch = elem.charAt(curPos); + switch(ch) { + case PATH_MACRO_CHAR: + sb.append(ch); + break; + case LOCAL_HOME_MACRO: + ValidateUtils.checkTrue((curPos == 1) && (index == 0), "Home macro must be first: %s", id); + appendUserHome(sb); + break; + case LOCAL_USER_MACRO: + sb.append(ValidateUtils.checkNotNullAndNotEmpty(OsUtils.getCurrentUser(), "No local user name value")); + break; + case LOCAL_HOST_MACRO: { + InetAddress address = Objects.requireNonNull(InetAddress.getLocalHost(), "No local address"); + sb.append(ValidateUtils.checkNotNullAndNotEmpty(address.getHostName(), "No local name")); + break; + } + case REMOTE_HOST_MACRO: + sb.append(ValidateUtils.checkNotNullAndNotEmpty(host, "No remote host provided")); + break; + case REMOTE_USER_MACRO: + sb.append(ValidateUtils.checkNotNullAndNotEmpty(username, "No remote user provided")); + break; + case REMOTE_PORT_MACRO: + ValidateUtils.checkTrue(port > 0, "Bad remote port value: %d", port); + sb.append(port); + break; + default: + ValidateUtils.throwIllegalArgumentException("Bad modifier '%s' in %s", String.valueOf(ch), id); + } + } else { + sb.append(ch); + } + } + } + + return sb.toString(); + } + + public static StringBuilder appendUserHome(StringBuilder sb) { + return appendUserHome(sb, IdentityUtils.getUserHomeFolder()); + } + + public static StringBuilder appendUserHome(StringBuilder sb, Path userHome) { + return appendUserHome(sb, Objects.requireNonNull(userHome, "No user home folder").toString()); + } + + public static StringBuilder appendUserHome(StringBuilder sb, String userHome) { + if (GenericUtils.isEmpty(userHome)) { + return sb; + } + + sb.append(userHome); + // strip any ending separator since we add our own + int len = sb.length(); + if (sb.charAt(len - 1) == File.separatorChar) { + sb.setLength(len - 1); + } + + return sb; + } + + /** + * @return The default {@link Path} location of the OpenSSH hosts entries configuration file + */ + @SuppressWarnings("synthetic-access") + public static Path getDefaultHostConfigFile() { + return LazyDefaultConfigFileHolder.CONFIG_FILE; + } +} http://git-wip-us.apache.org/repos/asf/mina-sshd/blob/10de190e/sshd-common/src/main/java/org/apache/sshd/client/config/hosts/HostConfigEntryResolver.java ---------------------------------------------------------------------- diff --git a/sshd-common/src/main/java/org/apache/sshd/client/config/hosts/HostConfigEntryResolver.java b/sshd-common/src/main/java/org/apache/sshd/client/config/hosts/HostConfigEntryResolver.java new file mode 100644 index 0000000..a07cfcf --- /dev/null +++ b/sshd-common/src/main/java/org/apache/sshd/client/config/hosts/HostConfigEntryResolver.java @@ -0,0 +1,60 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.sshd.client.config.hosts; + +import java.io.IOException; + +/** + * @author <a href="mailto:[email protected]">Apache MINA SSHD Project</a> + */ +@FunctionalInterface +public interface HostConfigEntryResolver { + + /** + * An "empty" implementation that does not resolve any entry + */ + HostConfigEntryResolver EMPTY = new HostConfigEntryResolver() { + @Override + public HostConfigEntry resolveEffectiveHost(String host, int port, String username) throws IOException { + return null; + } + + @Override + public String toString() { + return "EMPTY"; + } + }; + + /** + * Invoked when creating a new client session in order to allow for overriding + * of the original parameters + * + * @param host The requested host - never {@code null}/empty + * @param port The requested port + * @param username The requested username + * @return A {@link HostConfigEntry} for the actual target - {@code null} if use + * original parameters. <B>Note:</B> if any identity files are attached to the + * configuration then they must point to <U>existing</U> locations. This means + * that any macros such as <code>~, %d, %h</code>, etc. must be resolved <U>prior</U> + * to returning the value + * @throws IOException If failed to resolve the configuration + */ + HostConfigEntry resolveEffectiveHost(String host, int port, String username) throws IOException; +} http://git-wip-us.apache.org/repos/asf/mina-sshd/blob/10de190e/sshd-common/src/main/java/org/apache/sshd/client/config/hosts/HostPatternValue.java ---------------------------------------------------------------------- diff --git a/sshd-common/src/main/java/org/apache/sshd/client/config/hosts/HostPatternValue.java b/sshd-common/src/main/java/org/apache/sshd/client/config/hosts/HostPatternValue.java new file mode 100644 index 0000000..20d682f --- /dev/null +++ b/sshd-common/src/main/java/org/apache/sshd/client/config/hosts/HostPatternValue.java @@ -0,0 +1,97 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.sshd.client.config.hosts; + +import java.util.regex.Pattern; + +import org.apache.sshd.common.util.GenericUtils; + +/** + * Represents a pattern definition in the <U>known_hosts</U> file + * @author <a href="mailto:[email protected]">Apache MINA SSHD Project</a> + * @see <A HREF="https://en.wikibooks.org/wiki/OpenSSH/Client_Configuration_Files#About_the_Contents_of_the_known_hosts_Files"> + * OpenSSH cookbook - About the Contents of the known hosts Files</A> + */ +public class HostPatternValue { + private Pattern pattern; + private int port; + private boolean negated; + + public HostPatternValue() { + super(); + } + + public HostPatternValue(Pattern pattern, boolean negated) { + this(pattern, 0, negated); + } + + public HostPatternValue(Pattern pattern, int port, boolean negated) { + this.pattern = pattern; + this.port = port; + this.negated = negated; + } + + public Pattern getPattern() { + return pattern; + } + + public void setPattern(Pattern pattern) { + this.pattern = pattern; + } + + public int getPort() { + return port; + } + + public void setPort(int port) { + this.port = port; + } + + public boolean isNegated() { + return negated; + } + + public void setNegated(boolean negated) { + this.negated = negated; + } + + @Override + public String toString() { + Pattern p = getPattern(); + String purePattern = (p == null) ? null : p.pattern(); + StringBuilder sb = new StringBuilder(GenericUtils.length(purePattern) + Short.SIZE); + if (isNegated()) { + sb.append(HostPatternsHolder.NEGATION_CHAR_PATTERN); + } + + int portValue = getPort(); + if (portValue > 0) { + sb.append(HostPatternsHolder.NON_STANDARD_PORT_PATTERN_ENCLOSURE_START_DELIM); + } + sb.append(purePattern); + if (portValue > 0) { + sb.append(HostPatternsHolder.NON_STANDARD_PORT_PATTERN_ENCLOSURE_END_DELIM); + sb.append(HostPatternsHolder.PORT_VALUE_DELIMITER); + sb.append(portValue); + } + + return sb.toString(); + } +} http://git-wip-us.apache.org/repos/asf/mina-sshd/blob/10de190e/sshd-common/src/main/java/org/apache/sshd/client/config/hosts/HostPatternsHolder.java ---------------------------------------------------------------------- diff --git a/sshd-common/src/main/java/org/apache/sshd/client/config/hosts/HostPatternsHolder.java b/sshd-common/src/main/java/org/apache/sshd/client/config/hosts/HostPatternsHolder.java new file mode 100644 index 0000000..9d90dac --- /dev/null +++ b/sshd-common/src/main/java/org/apache/sshd/client/config/hosts/HostPatternsHolder.java @@ -0,0 +1,343 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.sshd.client.config.hosts; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.apache.sshd.common.util.GenericUtils; +import org.apache.sshd.common.util.ValidateUtils; + +/** + * @author <a href="mailto:[email protected]">Apache MINA SSHD Project</a> + */ +public abstract class HostPatternsHolder { + + /** + * Used in a host pattern to denote zero or more consecutive characters + */ + public static final char WILDCARD_PATTERN = '*'; + public static final String ALL_HOSTS_PATTERN = String.valueOf(WILDCARD_PATTERN); + + /** + * Used in a host pattern to denote any <U>one</U> character + */ + public static final char SINGLE_CHAR_PATTERN = '?'; + + /** + * Used to negate a host pattern + */ + public static final char NEGATION_CHAR_PATTERN = '!'; + + /** + * The available pattern characters + */ + public static final String PATTERN_CHARS = new String(new char[]{WILDCARD_PATTERN, SINGLE_CHAR_PATTERN, NEGATION_CHAR_PATTERN}); + + /** Port value separator if non-standard port pattern used */ + public static final char PORT_VALUE_DELIMITER = ':'; + + /** Non-standard port specification host pattern enclosure start delimiter */ + public static final char NON_STANDARD_PORT_PATTERN_ENCLOSURE_START_DELIM = '['; + + /** Non-standard port specification host pattern enclosure end delimiter */ + public static final char NON_STANDARD_PORT_PATTERN_ENCLOSURE_END_DELIM = ']'; + + private Collection<HostPatternValue> patterns = new LinkedList<>(); + + protected HostPatternsHolder() { + super(); + } + + public Collection<HostPatternValue> getPatterns() { + return patterns; + } + + public void setPatterns(Collection<HostPatternValue> patterns) { + this.patterns = patterns; + } + + /** + * Checks if a given host name / address matches the entry's host pattern(s) + * + * @param host The host name / address - ignored if {@code null}/empty + * @param port The connection port + * @return {@code true} if the name / address matches the pattern(s) + * @see #isHostMatch(String, Pattern) + */ + public boolean isHostMatch(String host, int port) { + return isHostMatch(host, port, getPatterns()); + } + + /** + * @param pattern The pattern to check - ignored if {@code null}/empty + * @return {@code true} if the pattern is not empty and contains no wildcard characters + * @see #WILDCARD_PATTERN + * @see #SINGLE_CHAR_PATTERN + * @see #SINGLE_CHAR_PATTERN + */ + public static boolean isSpecificHostPattern(String pattern) { + if (GenericUtils.isEmpty(pattern)) { + return false; + } + + for (int index = 0; index < PATTERN_CHARS.length(); index++) { + char ch = PATTERN_CHARS.charAt(index); + if (pattern.indexOf(ch) >= 0) { + return false; + } + } + + return true; + } + + /** + * Locates all the matching entries for a give host name / address + * + * @param host The host name / address - ignored if {@code null}/empty + * @param entries The {@link HostConfigEntry}-ies to scan - ignored if {@code null}/empty + * @return A {@link List} of all the matching entries + * @see #isHostMatch(String, int) + */ + public static List<HostConfigEntry> findMatchingEntries(String host, HostConfigEntry... entries) { + // TODO in Java-8 use Stream(s) + predicate + if (GenericUtils.isEmpty(host) || GenericUtils.isEmpty(entries)) { + return Collections.emptyList(); + } else { + return findMatchingEntries(host, Arrays.asList(entries)); + } + } + + /** + * Locates all the matching entries for a give host name / address + * + * @param host The host name / address - ignored if {@code null}/empty + * @param entries The {@link HostConfigEntry}-ies to scan - ignored if {@code null}/empty + * @return A {@link List} of all the matching entries + * @see #isHostMatch(String, int) + */ + public static List<HostConfigEntry> findMatchingEntries(String host, Collection<? extends HostConfigEntry> entries) { + // TODO in Java-8 use Stream(s) + predicate + if (GenericUtils.isEmpty(host) || GenericUtils.isEmpty(entries)) { + return Collections.emptyList(); + } + + List<HostConfigEntry> matches = null; + for (HostConfigEntry entry : entries) { + if (!entry.isHostMatch(host, 0 /* any port */)) { + continue; // debug breakpoint + } + + if (matches == null) { + matches = new ArrayList<>(entries.size()); // in case ALL of them match + } + + matches.add(entry); + } + + if (matches == null) { + return Collections.emptyList(); + } else { + return matches; + } + } + + public static boolean isHostMatch(String host, int port, Collection<HostPatternValue> patterns) { + if (GenericUtils.isEmpty(patterns)) { + return false; + } + + boolean matchFound = false; + for (HostPatternValue pv : patterns) { + boolean negated = pv.isNegated(); + /* + * If already found a match we are interested only in negations + */ + if (matchFound && (!negated)) { + continue; + } + + if (!isHostMatch(host, pv.getPattern())) { + continue; + } + + /* + * According to https://www.freebsd.org/cgi/man.cgi?query=ssh_config&sektion=5: + * + * If a negated entry is matched, then the Host entry is ignored, + * regardless of whether any other patterns on the line match. + */ + if (negated) { + return false; + } + + int pvPort = pv.getPort(); + if ((pvPort != 0) && (port != 0) && (pvPort != port)) { + continue; + } + + matchFound = true; + } + + return matchFound; + } + + /** + * Checks if a given host name / address matches a host pattern + * + * @param host The host name / address - ignored if {@code null}/empty + * @param pattern The host {@link Pattern} - ignored if {@code null} + * @return {@code true} if the name / address matches the pattern + */ + public static boolean isHostMatch(String host, Pattern pattern) { + if (GenericUtils.isEmpty(host) || (pattern == null)) { + return false; + } + + Matcher m = pattern.matcher(host); + return m.matches(); + } + + public static List<HostPatternValue> parsePatterns(CharSequence... patterns) { + return parsePatterns(GenericUtils.isEmpty(patterns) ? Collections.emptyList() : Arrays.asList(patterns)); + } + + public static List<HostPatternValue> parsePatterns(Collection<? extends CharSequence> patterns) { + if (GenericUtils.isEmpty(patterns)) { + return Collections.emptyList(); + } + + List<HostPatternValue> result = new ArrayList<>(patterns.size()); + for (CharSequence p : patterns) { + result.add(ValidateUtils.checkNotNull(toPattern(p), "No pattern for %s", p)); + } + + return result; + } + + /** + * Converts a host pattern string to a regular expression matcher. + * <B>Note:</B> pattern matching is <U>case insensitive</U> + * + * @param patternString The original pattern string - ignored if {@code null}/empty + * @return The regular expression matcher {@link Pattern} and the indication + * whether it is a negating pattern or not - {@code null} if no original string + * @see #NON_STANDARD_PORT_PATTERN_ENCLOSURE_START_DELIM + * @see #NON_STANDARD_PORT_PATTERN_ENCLOSURE_END_DELIM + * @see #WILDCARD_PATTERN + * @see #SINGLE_CHAR_PATTERN + * @see #NEGATION_CHAR_PATTERN + */ + public static HostPatternValue toPattern(CharSequence patternString) { + String pattern = GenericUtils.replaceWhitespaceAndTrim(Objects.toString(patternString, null)); + if (GenericUtils.isEmpty(pattern)) { + return null; + } + + int patternLen = pattern.length(); + int port = 0; + // Check if non-standard port value used + StringBuilder sb = new StringBuilder(patternLen); + if (pattern.charAt(0) == HostPatternsHolder.NON_STANDARD_PORT_PATTERN_ENCLOSURE_START_DELIM) { + int pos = GenericUtils.lastIndexOf(pattern, HostPatternsHolder.PORT_VALUE_DELIMITER); + ValidateUtils.checkTrue(pos > 0, "Missing non-standard port value delimiter in %s", pattern); + ValidateUtils.checkTrue(pos < (patternLen - 1), "Missing non-standard port value number in %s", pattern); + ValidateUtils.checkTrue(pattern.charAt(pos - 1) == HostPatternsHolder.NON_STANDARD_PORT_PATTERN_ENCLOSURE_END_DELIM, + "Invalid non-standard port value host pattern enclosure delimiters in %s", pattern); + + String csPort = pattern.substring(pos + 1, patternLen); + port = Integer.parseInt(csPort); + ValidateUtils.checkTrue((port > 0) && (port <= 0xFFFF), "Invalid non-start port value (%d) in %s", port, pattern); + + pattern = pattern.substring(1, pos - 1); + patternLen = pattern.length(); + } + + boolean negated = false; + for (int curPos = 0; curPos < patternLen; curPos++) { + char ch = pattern.charAt(curPos); + ValidateUtils.checkTrue(isValidPatternChar(ch), "Invalid host pattern char in %s", pattern); + + switch(ch) { + case '.': // need to escape it + sb.append('\\').append(ch); + break; + case SINGLE_CHAR_PATTERN: + sb.append('.'); + break; + case WILDCARD_PATTERN: + sb.append(".*"); + break; + case NEGATION_CHAR_PATTERN: + ValidateUtils.checkTrue(!negated, "Double negation in %s", pattern); + ValidateUtils.checkTrue(curPos == 0, "Negation must be 1st char: %s", pattern); + negated = true; + break; + default: + sb.append(ch); + } + } + + return new HostPatternValue(Pattern.compile(sb.toString(), Pattern.CASE_INSENSITIVE), port, negated); + } + + /** + * Checks if the given character is valid for a host pattern. Valid + * characters are: + * <UL> + * <LI>A-Z</LI> + * <LI>a-z</LI> + * <LI>0-9</LI> + * <LI>Underscore (_)</LI> + * <LI>Hyphen (-)</LI> + * <LI>Dot (.)</LI> + * <LI>The {@link #WILDCARD_PATTERN}</LI> + * <LI>The {@link #SINGLE_CHAR_PATTERN}</LI> + * </UL> + * + * @param ch The character to validate + * @return {@code true} if valid pattern character + */ + public static boolean isValidPatternChar(char ch) { + if ((ch <= ' ') || (ch >= 0x7E)) { + return false; + } + if ((ch >= 'a') && (ch <= 'z')) { + return true; + } + if ((ch >= 'A') && (ch <= 'Z')) { + return true; + } + if ((ch >= '0') && (ch <= '9')) { + return true; + } + if ("-_.".indexOf(ch) >= 0) { + return true; + } + return PATTERN_CHARS.indexOf(ch) >= 0; + } +} http://git-wip-us.apache.org/repos/asf/mina-sshd/blob/10de190e/sshd-common/src/main/java/org/apache/sshd/client/config/hosts/KnownHostDigest.java ---------------------------------------------------------------------- diff --git a/sshd-common/src/main/java/org/apache/sshd/client/config/hosts/KnownHostDigest.java b/sshd-common/src/main/java/org/apache/sshd/client/config/hosts/KnownHostDigest.java new file mode 100644 index 0000000..2d9a322 --- /dev/null +++ b/sshd-common/src/main/java/org/apache/sshd/client/config/hosts/KnownHostDigest.java @@ -0,0 +1,66 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.sshd.client.config.hosts; + +import java.util.Collections; +import java.util.EnumSet; +import java.util.Objects; +import java.util.Set; + +import org.apache.sshd.common.Factory; +import org.apache.sshd.common.NamedFactory; +import org.apache.sshd.common.NamedResource; +import org.apache.sshd.common.mac.BuiltinMacs; +import org.apache.sshd.common.mac.Mac; +import org.apache.sshd.common.util.ValidateUtils; + +/** + * Available digesters for known hosts entries + * + * @author <a href="mailto:[email protected]">Apache MINA SSHD Project</a> + */ +public enum KnownHostDigest implements NamedFactory<Mac> { + SHA1("1", BuiltinMacs.hmacsha1); + + public static final Set<KnownHostDigest> VALUES = + Collections.unmodifiableSet(EnumSet.allOf(KnownHostDigest.class)); + + private final String name; + private final Factory<Mac> factory; + + KnownHostDigest(String name, Factory<Mac> factory) { + this.name = ValidateUtils.checkNotNullAndNotEmpty(name, "No name"); + this.factory = Objects.requireNonNull(factory, "No factory"); + } + + @Override + public String getName() { + return name; + } + + @Override + public Mac create() { + return factory.create(); + } + + public static KnownHostDigest fromName(String name) { + return NamedResource.findByName(name, String.CASE_INSENSITIVE_ORDER, VALUES); + } +} http://git-wip-us.apache.org/repos/asf/mina-sshd/blob/10de190e/sshd-common/src/main/java/org/apache/sshd/client/config/hosts/KnownHostEntry.java ---------------------------------------------------------------------- diff --git a/sshd-common/src/main/java/org/apache/sshd/client/config/hosts/KnownHostEntry.java b/sshd-common/src/main/java/org/apache/sshd/client/config/hosts/KnownHostEntry.java new file mode 100644 index 0000000..bcaf965 --- /dev/null +++ b/sshd-common/src/main/java/org/apache/sshd/client/config/hosts/KnownHostEntry.java @@ -0,0 +1,276 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.sshd.client.config.hosts; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.io.StreamCorruptedException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.OpenOption; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.apache.sshd.common.config.ConfigFileReaderSupport; +import org.apache.sshd.common.config.keys.AuthorizedKeyEntry; +import org.apache.sshd.common.config.keys.PublicKeyEntry; +import org.apache.sshd.common.util.GenericUtils; +import org.apache.sshd.common.util.ValidateUtils; +import org.apache.sshd.common.util.io.IoUtils; +import org.apache.sshd.common.util.io.NoCloseInputStream; +import org.apache.sshd.common.util.io.NoCloseReader; + +/** + * Contains a representation of an entry in the <code>known_hosts</code> file + * + * @author <a href="mailto:[email protected]">Apache MINA SSHD Project</a> + * @see <A HREF="http://www.manpagez.com/man/8/sshd/">sshd(8) man page</A> + */ +public class KnownHostEntry extends HostPatternsHolder { + /** + * Character that denotes that start of a marker + */ + public static final char MARKER_INDICATOR = '@'; + + /** + * Standard OpenSSH config file name + */ + public static final String STD_HOSTS_FILENAME = "known_hosts"; + + private static final class LazyDefaultConfigFileHolder { + private static final Path HOSTS_FILE = + PublicKeyEntry.getDefaultKeysFolderPath().resolve(STD_HOSTS_FILENAME); + + private LazyDefaultConfigFileHolder() { + throw new UnsupportedOperationException("No instance allowed"); + } + } + + private String line; + private String marker; + private AuthorizedKeyEntry keyEntry; + private KnownHostHashValue hashedEntry; + + public KnownHostEntry() { + super(); + } + + /** + * @param line The original line from which this entry was created + */ + public KnownHostEntry(String line) { + this.line = line; + } + + /** + * @return The original line from which this entry was created + */ + public String getConfigLine() { + return line; + } + + public void setConfigLine(String line) { + this.line = line; + } + + public String getMarker() { + return marker; + } + + public void setMarker(String marker) { + this.marker = marker; + } + + public AuthorizedKeyEntry getKeyEntry() { + return keyEntry; + } + + public void setKeyEntry(AuthorizedKeyEntry keyEntry) { + this.keyEntry = keyEntry; + } + + public KnownHostHashValue getHashedEntry() { + return hashedEntry; + } + + public void setHashedEntry(KnownHostHashValue hashedEntry) { + this.hashedEntry = hashedEntry; + } + + @Override + public boolean isHostMatch(String host, int port) { + if (super.isHostMatch(host, port)) { + return true; + } + + KnownHostHashValue hash = getHashedEntry(); + return (hash != null) && hash.isHostMatch(host); + } + + @Override + public String toString() { + return getConfigLine(); + } + + /** + * @return The default {@link Path} location of the OpenSSH known hosts file + */ + @SuppressWarnings("synthetic-access") + public static Path getDefaultKnownHostsFile() { + return LazyDefaultConfigFileHolder.HOSTS_FILE; + } + + public static List<KnownHostEntry> readKnownHostEntries(File file) throws IOException { + return readKnownHostEntries(file.toPath(), IoUtils.EMPTY_OPEN_OPTIONS); + } + + public static List<KnownHostEntry> readKnownHostEntries(Path path, OpenOption... options) throws IOException { + try (InputStream input = Files.newInputStream(path, options)) { + return readKnownHostEntries(input, true); + } + } + + public static List<KnownHostEntry> readKnownHostEntries(URL url) throws IOException { + try (InputStream input = url.openStream()) { + return readKnownHostEntries(input, true); + } + } + + public static List<KnownHostEntry> readKnownHostEntries(String filePath) throws IOException { + try (InputStream inStream = new FileInputStream(filePath)) { + return readKnownHostEntries(inStream, true); + } + } + + public static List<KnownHostEntry> readKnownHostEntries(InputStream inStream, boolean okToClose) throws IOException { + try (Reader reader = new InputStreamReader(NoCloseInputStream.resolveInputStream(inStream, okToClose), StandardCharsets.UTF_8)) { + return readKnownHostEntries(reader, true); + } + } + + public static List<KnownHostEntry> readKnownHostEntries(Reader rdr, boolean okToClose) throws IOException { + try (BufferedReader buf = new BufferedReader(NoCloseReader.resolveReader(rdr, okToClose))) { + return readKnownHostEntries(buf); + } + } + + /** + * Reads configuration entries + * + * @param rdr The {@link BufferedReader} to use + * @return The {@link List} of read {@link KnownHostEntry}-ies + * @throws IOException If failed to parse the read configuration + */ + public static List<KnownHostEntry> readKnownHostEntries(BufferedReader rdr) throws IOException { + List<KnownHostEntry> entries = null; + + int lineNumber = 1; + for (String line = rdr.readLine(); line != null; line = rdr.readLine(), lineNumber++) { + line = GenericUtils.trimToEmpty(line); + if (GenericUtils.isEmpty(line)) { + continue; + } + + int pos = line.indexOf(ConfigFileReaderSupport.COMMENT_CHAR); + if (pos == 0) { + continue; + } + + if (pos > 0) { + line = line.substring(0, pos); + line = line.trim(); + } + + try { + KnownHostEntry entry = parseKnownHostEntry(line); + if (entry == null) { + continue; + } + + if (entries == null) { + entries = new ArrayList<>(); + } + entries.add(entry); + } catch (RuntimeException | Error e) { // TODO consider consulting a user callback + throw new StreamCorruptedException("Failed (" + e.getClass().getSimpleName() + ")" + + " to parse line #" + lineNumber + " '" + line + "': " + e.getMessage()); + } + } + + if (entries == null) { + return Collections.emptyList(); + } else { + return entries; + } + } + + public static KnownHostEntry parseKnownHostEntry(String line) { + return parseKnownHostEntry(GenericUtils.isEmpty(line) ? null : new KnownHostEntry(), line); + } + + public static <E extends KnownHostEntry> E parseKnownHostEntry(E entry, String data) { + String line = GenericUtils.replaceWhitespaceAndTrim(data); + if (GenericUtils.isEmpty(line) || (line.charAt(0) == PublicKeyEntry.COMMENT_CHAR)) { + return entry; + } + + entry.setConfigLine(line); + + if (line.charAt(0) == MARKER_INDICATOR) { + int pos = line.indexOf(' '); + ValidateUtils.checkTrue(pos > 0, "Missing marker name end delimiter in line=%s", data); + ValidateUtils.checkTrue(pos > 1, "No marker name after indicator in line=%s", data); + entry.setMarker(line.substring(1, pos)); + line = line.substring(pos + 1).trim(); + } else { + entry.setMarker(null); + } + + int pos = line.indexOf(' '); + ValidateUtils.checkTrue(pos > 0, "Missing host patterns end delimiter in line=%s", data); + String hostPattern = line.substring(0, pos); + line = line.substring(pos + 1).trim(); + + if (hostPattern.charAt(0) == KnownHostHashValue.HASHED_HOST_DELIMITER) { + KnownHostHashValue hash = + ValidateUtils.checkNotNull(KnownHostHashValue.parse(hostPattern), + "Failed to extract host hash value from line=%s", data); + entry.setHashedEntry(hash); + entry.setPatterns(null); + } else { + entry.setHashedEntry(null); + entry.setPatterns(parsePatterns(GenericUtils.split(hostPattern, ','))); + } + + AuthorizedKeyEntry key = + ValidateUtils.checkNotNull(AuthorizedKeyEntry.parseAuthorizedKeyEntry(line), + "No valid key entry recovered from line=%s", data); + entry.setKeyEntry(key); + return entry; + } +}
