http://git-wip-us.apache.org/repos/asf/mina-sshd/blob/251db9b9/sshd-sftp/src/main/java/org/apache/sshd/client/subsystem/sftp/SftpClient.java
----------------------------------------------------------------------
diff --git 
a/sshd-sftp/src/main/java/org/apache/sshd/client/subsystem/sftp/SftpClient.java 
b/sshd-sftp/src/main/java/org/apache/sshd/client/subsystem/sftp/SftpClient.java
new file mode 100644
index 0000000..43cc619
--- /dev/null
+++ 
b/sshd-sftp/src/main/java/org/apache/sshd/client/subsystem/sftp/SftpClient.java
@@ -0,0 +1,1038 @@
+/*
+ * 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.subsystem.sftp;
+
+import java.io.Closeable;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.channels.Channel;
+import java.nio.charset.Charset;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.OpenOption;
+import java.nio.file.StandardOpenOption;
+import java.nio.file.attribute.AclEntry;
+import java.nio.file.attribute.FileTime;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Map;
+import java.util.NavigableMap;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.apache.sshd.client.subsystem.SubsystemClient;
+import org.apache.sshd.client.subsystem.sftp.extensions.SftpClientExtension;
+import org.apache.sshd.common.subsystem.sftp.SftpConstants;
+import org.apache.sshd.common.subsystem.sftp.SftpHelper;
+import org.apache.sshd.common.util.GenericUtils;
+import org.apache.sshd.common.util.ValidateUtils;
+import org.apache.sshd.common.util.buffer.BufferUtils;
+
+/**
+ * @author <a href="http://mina.apache.org";>Apache MINA Project</a>
+ */
+public interface SftpClient extends SubsystemClient {
+    /**
+     * Used to indicate the {@link Charset} (or its name) for decoding
+     * referenced files/folders names - extracted from the client session
+     * when 1st initialized.
+     * @see #DEFAULT_NAME_DECODING_CHARSET
+     * @see #getNameDecodingCharset()
+     * @see #setNameDecodingCharset(Charset)
+     */
+    String NAME_DECODING_CHARSET = "sftp-name-decoding-charset";
+
+    /**
+     * Default value of {@value #NAME_DECODING_CHARSET}
+     */
+    Charset DEFAULT_NAME_DECODING_CHARSET = StandardCharsets.UTF_8;
+
+    enum OpenMode {
+        Read,
+        Write,
+        Append,
+        Create,
+        Truncate,
+        Exclusive;
+
+        /**
+         * The {@link Set} of {@link OpenOption}-s supported by {@link 
#fromOpenOptions(Collection)}
+         */
+        public static final Set<OpenOption> SUPPORTED_OPTIONS =
+                Collections.unmodifiableSet(
+                        EnumSet.of(
+                                StandardOpenOption.READ, 
StandardOpenOption.APPEND,
+                                StandardOpenOption.CREATE, 
StandardOpenOption.TRUNCATE_EXISTING,
+                                StandardOpenOption.WRITE, 
StandardOpenOption.CREATE_NEW,
+                                StandardOpenOption.SPARSE));
+
+        /**
+         * Converts {@link StandardOpenOption}-s into {@link OpenMode}-s
+         *
+         * @param options The original options - ignored if {@code null}/empty
+         * @return A {@link Set} of the equivalent modes
+         * @throws IllegalArgumentException If an unsupported option is 
requested
+         * @see #SUPPORTED_OPTIONS
+         */
+        public static Set<OpenMode> fromOpenOptions(Collection<? extends 
OpenOption> options) {
+            if (GenericUtils.isEmpty(options)) {
+                return Collections.emptySet();
+            }
+
+            Set<OpenMode> modes = EnumSet.noneOf(OpenMode.class);
+            for (OpenOption option : options) {
+                if (option == StandardOpenOption.READ) {
+                    modes.add(Read);
+                } else if (option == StandardOpenOption.APPEND) {
+                    modes.add(Append);
+                } else if (option == StandardOpenOption.CREATE) {
+                    modes.add(Create);
+                } else if (option == StandardOpenOption.TRUNCATE_EXISTING) {
+                    modes.add(Truncate);
+                } else if (option == StandardOpenOption.WRITE) {
+                    modes.add(Write);
+                } else if (option == StandardOpenOption.CREATE_NEW) {
+                    modes.add(Create);
+                    modes.add(Exclusive);
+                } else if (option == StandardOpenOption.SPARSE) {
+                    /*
+                     * As per the Javadoc:
+                     *
+                     *      The option is ignored when the file system does not
+                     *  support the creation of sparse files
+                     */
+                    continue;
+                } else {
+                    throw new IllegalArgumentException("Unsupported open 
option: " + option);
+                }
+            }
+
+            return modes;
+        }
+    }
+
+    enum CopyMode {
+        Atomic,
+        Overwrite
+    }
+
+    enum Attribute {
+        Size,
+        UidGid,
+        Perms,
+        OwnerGroup,
+        AccessTime,
+        ModifyTime,
+        CreateTime,
+        Acl,
+        Extensions
+    }
+
+    class Handle {
+        private final String path;
+        private final byte[] id;
+
+        Handle(String path, byte[] id) {
+            // clone the original so the handle is immutable
+            this.path = ValidateUtils.checkNotNullAndNotEmpty(path, "No remote 
path");
+            this.id = ValidateUtils.checkNotNullAndNotEmpty(id, "No handle 
ID").clone();
+        }
+
+        /**
+         * @return The remote path represented by this handle
+         */
+        public String getPath() {
+            return path;
+        }
+
+        public int length() {
+            return id.length;
+        }
+
+        /**
+         * @return A <U>cloned</U> instance of the identifier in order to
+         * avoid inadvertent modifications to the handle contents
+         */
+        public byte[] getIdentifier() {
+            return id.clone();
+        }
+
+        @Override
+        public int hashCode() {
+            return Arrays.hashCode(id);
+        }
+
+        @Override
+        public boolean equals(Object obj) {
+            if (obj == null) {
+                return false;
+            }
+
+            if (obj == this) {
+                return true;
+            }
+
+            // we do not ask getClass() == obj.getClass() in order to allow 
for derived classes equality
+            if (!(obj instanceof Handle)) {
+                return false;
+            }
+
+            return Arrays.equals(id, ((Handle) obj).id);
+        }
+
+        @Override
+        public String toString() {
+            return getPath() + ": " + 
BufferUtils.toHex(BufferUtils.EMPTY_HEX_SEPARATOR, id);
+        }
+    }
+
+    // CHECKSTYLE:OFF
+    abstract class CloseableHandle extends Handle implements Channel, 
Closeable {
+        protected CloseableHandle(String path, byte[] id) {
+            super(path, id);
+        }
+    }
+    // CHECKSTYLE:ON
+
+    class Attributes {
+        private Set<Attribute> flags = EnumSet.noneOf(Attribute.class);
+        private int type = SftpConstants.SSH_FILEXFER_TYPE_UNKNOWN;
+        private int perms;
+        private int uid;
+        private int gid;
+        private String owner;
+        private String group;
+        private long size;
+        private FileTime accessTime;
+        private FileTime createTime;
+        private FileTime modifyTime;
+        private List<AclEntry> acl;
+        private Map<String, byte[]> extensions = Collections.emptyMap();
+
+        public Attributes() {
+            super();
+        }
+
+        public Set<Attribute> getFlags() {
+            return flags;
+        }
+
+        public Attributes addFlag(Attribute flag) {
+            flags.add(flag);
+            return this;
+        }
+
+        public Attributes removeFlag(Attribute flag) {
+            flags.remove(flag);
+            return this;
+        }
+
+        public int getType() {
+            return type;
+        }
+
+        public void setType(int type) {
+            this.type = type;
+        }
+
+        public long getSize() {
+            return size;
+        }
+
+        public Attributes size(long size) {
+            setSize(size);
+            return this;
+        }
+
+        public void setSize(long size) {
+            this.size = size;
+            addFlag(Attribute.Size);
+        }
+
+        public String getOwner() {
+            return owner;
+        }
+
+        public Attributes owner(String owner) {
+            setOwner(owner);
+            return this;
+        }
+
+        public void setOwner(String owner) {
+            this.owner = owner;
+            /*
+             * According to 
https://tools.ietf.org/wg/secsh/draft-ietf-secsh-filexfer/draft-ietf-secsh-filexfer-13.txt
+             * section 7.5
+             *
+             *      If either the owner or group field is zero length, the 
field
+             *      should be considered absent, and no change should be made 
to
+             *      that specific field during a modification operation.
+             */
+            if (GenericUtils.isEmpty(owner)) {
+                removeFlag(Attribute.OwnerGroup);
+            } else {
+                addFlag(Attribute.OwnerGroup);
+            }
+        }
+
+        public String getGroup() {
+            return group;
+        }
+
+        public Attributes group(String group) {
+            setGroup(group);
+            return this;
+        }
+
+        public void setGroup(String group) {
+            this.group = group;
+            /*
+             * According to 
https://tools.ietf.org/wg/secsh/draft-ietf-secsh-filexfer/draft-ietf-secsh-filexfer-13.txt
+             * section 7.5
+             *
+             *      If either the owner or group field is zero length, the 
field
+             *      should be considered absent, and no change should be made 
to
+             *      that specific field during a modification operation.
+             */
+            if (GenericUtils.isEmpty(group)) {
+                removeFlag(Attribute.OwnerGroup);
+            } else {
+                addFlag(Attribute.OwnerGroup);
+            }
+        }
+
+        public int getUserId() {
+            return uid;
+        }
+
+        public int getGroupId() {
+            return gid;
+        }
+
+        public Attributes owner(int uid, int gid) {
+            this.uid = uid;
+            this.gid = gid;
+            addFlag(Attribute.UidGid);
+            return this;
+        }
+
+        public int getPermissions() {
+            return perms;
+        }
+
+        public Attributes perms(int perms) {
+            setPermissions(perms);
+            return this;
+        }
+
+        public void setPermissions(int perms) {
+            this.perms = perms;
+            addFlag(Attribute.Perms);
+        }
+
+        public FileTime getAccessTime() {
+            return accessTime;
+        }
+
+        public Attributes accessTime(long atime) {
+            return accessTime(atime, TimeUnit.SECONDS);
+        }
+
+        public Attributes accessTime(long atime, TimeUnit unit) {
+            return accessTime(FileTime.from(atime, unit));
+        }
+
+        public Attributes accessTime(FileTime atime) {
+            setAccessTime(atime);
+            return this;
+        }
+
+        public void setAccessTime(FileTime atime) {
+            accessTime = Objects.requireNonNull(atime, "No access time");
+            addFlag(Attribute.AccessTime);
+        }
+
+        public FileTime getCreateTime() {
+            return createTime;
+        }
+
+        public Attributes createTime(long ctime) {
+            return createTime(ctime, TimeUnit.SECONDS);
+        }
+
+        public Attributes createTime(long ctime, TimeUnit unit) {
+            return createTime(FileTime.from(ctime, unit));
+        }
+
+        public Attributes createTime(FileTime ctime) {
+            setCreateTime(ctime);
+            return this;
+        }
+
+        public void setCreateTime(FileTime ctime) {
+            createTime = Objects.requireNonNull(ctime, "No create time");
+            addFlag(Attribute.CreateTime);
+        }
+
+        public FileTime getModifyTime() {
+            return modifyTime;
+        }
+
+        public Attributes modifyTime(long mtime) {
+            return modifyTime(mtime, TimeUnit.SECONDS);
+        }
+
+        public Attributes modifyTime(long mtime, TimeUnit unit) {
+            return modifyTime(FileTime.from(mtime, unit));
+        }
+
+        public Attributes modifyTime(FileTime mtime) {
+            setModifyTime(mtime);
+            return this;
+        }
+
+        public void setModifyTime(FileTime mtime) {
+            modifyTime = Objects.requireNonNull(mtime, "No modify time");
+            addFlag(Attribute.ModifyTime);
+        }
+
+        public List<AclEntry> getAcl() {
+            return acl;
+        }
+
+        public Attributes acl(List<AclEntry> acl) {
+            setAcl(acl);
+            return this;
+        }
+
+        public void setAcl(List<AclEntry> acl) {
+            this.acl = Objects.requireNonNull(acl, "No ACLs");
+            addFlag(Attribute.Acl);
+        }
+
+        public Map<String, byte[]> getExtensions() {
+            return extensions;
+        }
+
+        public Attributes extensions(Map<String, byte[]> extensions) {
+            setExtensions(extensions);
+            return this;
+        }
+
+        public void setStringExtensions(Map<String, String> extensions) {
+            setExtensions(SftpHelper.toBinaryExtensions(extensions));
+        }
+
+        public void setExtensions(Map<String, byte[]> extensions) {
+            this.extensions = Objects.requireNonNull(extensions, "No 
extensions");
+            addFlag(Attribute.Extensions);
+        }
+
+        public boolean isRegularFile() {
+            return (getPermissions() & SftpConstants.S_IFMT) == 
SftpConstants.S_IFREG;
+        }
+
+        public boolean isDirectory() {
+            return (getPermissions() & SftpConstants.S_IFMT) == 
SftpConstants.S_IFDIR;
+        }
+
+        public boolean isSymbolicLink() {
+            return (getPermissions() & SftpConstants.S_IFMT) == 
SftpConstants.S_IFLNK;
+        }
+
+        public boolean isOther() {
+            return !isRegularFile() && !isDirectory() && !isSymbolicLink();
+        }
+
+        @Override
+        public String toString() {
+            return "type=" + getType()
+                 + ";size=" + getSize()
+                 + ";uid=" + getUserId()
+                 + ";gid=" + getGroupId()
+                 + ";perms=0x" + Integer.toHexString(getPermissions())
+                 + ";flags=" + getFlags()
+                 + ";owner=" + getOwner()
+                 + ";group=" + getGroup()
+                 + ";aTime=" + getAccessTime()
+                 + ";cTime=" + getCreateTime()
+                 + ";mTime=" + getModifyTime()
+                 + ";extensions=" + getExtensions().keySet();
+        }
+    }
+
+    class DirEntry {
+        public static final Comparator<DirEntry> BY_CASE_SENSITIVE_FILENAME = 
new Comparator<DirEntry>() {
+            @Override
+            public int compare(DirEntry o1, DirEntry o2) {
+                if (o1 == o2) {
+                    return 0;
+                } else if (o1 == null) {
+                    return 1;
+                } else if (o2 == null) {
+                    return -1;
+                } else {
+                    return GenericUtils.safeCompare(o1.getFilename(), 
o2.getFilename(), true);
+                }
+            }
+        };
+
+        public static final Comparator<DirEntry> BY_CASE_INSENSITIVE_FILENAME 
= new Comparator<DirEntry>() {
+            @Override
+            public int compare(DirEntry o1, DirEntry o2) {
+                if (o1 == o2) {
+                    return 0;
+                } else if (o1 == null) {
+                    return 1;
+                } else if (o2 == null) {
+                    return -1;
+                } else {
+                    return GenericUtils.safeCompare(o1.getFilename(), 
o2.getFilename(), false);
+                }
+            }
+        };
+
+        private final String filename;
+        private final String longFilename;
+        private final Attributes attributes;
+
+        public DirEntry(String filename, String longFilename, Attributes 
attributes) {
+            this.filename = filename;
+            this.longFilename = longFilename;
+            this.attributes = attributes;
+        }
+
+        public String getFilename() {
+            return filename;
+        }
+
+        public String getLongFilename() {
+            return longFilename;
+        }
+
+        public Attributes getAttributes() {
+            return attributes;
+        }
+
+        @Override
+        public String toString() {
+            return getFilename() + "[" + getLongFilename() + "]: " + 
getAttributes();
+        }
+    }
+
+    DirEntry[] EMPTY_DIR_ENTRIES = new DirEntry[0];
+
+    // default values used if none specified
+    int MIN_BUFFER_SIZE = Byte.MAX_VALUE;
+    int MIN_READ_BUFFER_SIZE = MIN_BUFFER_SIZE;
+    int MIN_WRITE_BUFFER_SIZE = MIN_BUFFER_SIZE;
+    int IO_BUFFER_SIZE = 32 * 1024;
+    int DEFAULT_READ_BUFFER_SIZE = IO_BUFFER_SIZE;
+    int DEFAULT_WRITE_BUFFER_SIZE = IO_BUFFER_SIZE;
+    long DEFAULT_WAIT_TIMEOUT = TimeUnit.SECONDS.toMillis(15L);
+
+    /**
+     * Property that can be used on the {@link 
org.apache.sshd.common.FactoryManager}
+     * to control the internal timeout used by the client to open a channel.
+     * If not specified then {@link #DEFAULT_CHANNEL_OPEN_TIMEOUT} value
+     * is used
+     */
+    String SFTP_CHANNEL_OPEN_TIMEOUT = "sftp-channel-open-timeout";
+    long DEFAULT_CHANNEL_OPEN_TIMEOUT = DEFAULT_WAIT_TIMEOUT;
+
+    /**
+     * Default modes for opening a channel if no specific modes specified
+     */
+    Set<OpenMode> DEFAULT_CHANNEL_MODES =
+            Collections.unmodifiableSet(EnumSet.of(OpenMode.Read, 
OpenMode.Write));
+
+    /**
+     * @return The negotiated SFTP protocol version
+     */
+    int getVersion();
+
+    @Override
+    default String getName() {
+        return SftpConstants.SFTP_SUBSYSTEM_NAME;
+    }
+
+    /**
+     * @return The (never {@code null}) {@link Charset} used to decode 
referenced files/folders names
+     * @see #NAME_DECODING_CHARSET
+     */
+    Charset getNameDecodingCharset();
+
+    void setNameDecodingCharset(Charset cs);
+
+    /**
+     * @return An (unmodifiable) {@link NavigableMap} of the reported server 
extensions.
+     * where key=extension name (case <U>insensitive</U>)
+     */
+    NavigableMap<String, byte[]> getServerExtensions();
+
+    boolean isClosing();
+
+    //
+    // Low level API
+    //
+
+    /**
+     * Opens a remote file for read
+     *
+     * @param path The remote path
+     * @return The file's {@link CloseableHandle}
+     * @throws IOException If failed to open the remote file
+     * @see #open(String, Collection)
+     */
+    default CloseableHandle open(String path) throws IOException {
+        return open(path, Collections.emptySet());
+    }
+
+    /**
+     * Opens a remote file with the specified mode(s)
+     *
+     * @param path    The remote path
+     * @param options The desired mode - if none specified
+     *                then {@link OpenMode#Read} is assumed
+     * @return The file's {@link CloseableHandle}
+     * @throws IOException If failed to open the remote file
+     * @see #open(String, Collection)
+     */
+    default CloseableHandle open(String path, OpenMode... options) throws 
IOException {
+        return open(path, GenericUtils.of(options));
+    }
+
+    /**
+     * Opens a remote file with the specified mode(s)
+     *
+     * @param path    The remote path
+     * @param options The desired mode - if none specified
+     *                then {@link OpenMode#Read} is assumed
+     * @return The file's {@link CloseableHandle}
+     * @throws IOException If failed to open the remote file
+     */
+    CloseableHandle open(String path, Collection<OpenMode> options) throws 
IOException;
+
+    /**
+     * Close the handle obtained from one of the {@code open} methods
+     *
+     * @param handle The {@code Handle} to close
+     * @throws IOException If failed to execute
+     */
+    void close(Handle handle) throws IOException;
+
+    /**
+     * @param path The remote path to remove
+     * @throws IOException If failed to execute
+     */
+    void remove(String path) throws IOException;
+
+    default void rename(String oldPath, String newPath) throws IOException {
+        rename(oldPath, newPath, Collections.emptySet());
+    }
+
+    default void rename(String oldPath, String newPath, CopyMode... options) 
throws IOException {
+        rename(oldPath, newPath, GenericUtils.of(options));
+    }
+
+    void rename(String oldPath, String newPath, Collection<CopyMode> options) 
throws IOException;
+
+    /**
+     * Reads data from the open (file) handle
+     *
+     * @param handle     The file {@link Handle} to read from
+     * @param fileOffset The file offset to read from
+     * @param dst        The destination buffer
+     * @return Number of read bytes - {@code -1} if EOF reached
+     * @throws IOException If failed to read the data
+     * @see #read(Handle, long, byte[], int, int)
+     */
+    default int read(Handle handle, long fileOffset, byte[] dst) throws 
IOException  {
+        return read(handle, fileOffset, dst, null);
+    }
+
+    /**
+     * Reads data from the open (file) handle
+     *
+     * @param handle     The file {@link Handle} to read from
+     * @param fileOffset The file offset to read from
+     * @param dst        The destination buffer
+     * @param eofSignalled If not {@code null} then upon return holds a value 
indicating
+     *                   whether EOF was reached due to the read. If {@code 
null} indicator
+     *                   value then this indication is not available
+     * @return Number of read bytes - {@code -1} if EOF reached
+     * @throws IOException If failed to read the data
+     * @see #read(Handle, long, byte[], int, int, AtomicReference)
+     * @see <A 
HREF="https://tools.ietf.org/html/draft-ietf-secsh-filexfer-13#section-9.3";>SFTP
 v6 - section 9.3</A>
+     */
+    default int read(Handle handle, long fileOffset, byte[] dst, 
AtomicReference<Boolean> eofSignalled) throws IOException {
+        return read(handle, fileOffset, dst, 0, dst.length, eofSignalled);
+    }
+
+    default int read(Handle handle, long fileOffset, byte[] dst, int 
dstOffset, int len) throws IOException {
+        return read(handle, fileOffset, dst, dstOffset, len, null);
+    }
+
+    /**
+     * Reads data from the open (file) handle
+     *
+     * @param handle     The file {@link Handle} to read from
+     * @param fileOffset The file offset to read from
+     * @param dst        The destination buffer
+     * @param dstOffset  Offset in destination buffer to place the read data
+     * @param len        Available destination buffer size to read
+     * @param eofSignalled If not {@code null} then upon return holds a value 
indicating
+     *                   whether EOF was reached due to the read. If {@code 
null} indicator
+     *                   value then this indication is not available
+     * @return Number of read bytes - {@code -1} if EOF reached
+     * @throws IOException If failed to read the data
+     * @see <A 
HREF="https://tools.ietf.org/html/draft-ietf-secsh-filexfer-13#section-9.3";>SFTP
 v6 - section 9.3</A>
+     */
+    int read(Handle handle, long fileOffset, byte[] dst, int dstOffset, int 
len, AtomicReference<Boolean> eofSignalled) throws IOException;
+
+    default void write(Handle handle, long fileOffset, byte[] src) throws 
IOException {
+        write(handle, fileOffset, src, 0, src.length);
+    }
+
+    /**
+     * Write data to (open) file handle
+     *
+     * @param handle     The file {@link Handle}
+     * @param fileOffset Zero-based offset to write in file
+     * @param src        Data buffer
+     * @param srcOffset  Offset of valid data in buffer
+     * @param len        Number of bytes to write
+     * @throws IOException If failed to write the data
+     */
+    void write(Handle handle, long fileOffset, byte[] src, int srcOffset, int 
len) throws IOException;
+
+    /**
+     * Create remote directory
+     *
+     * @param path Remote directory path
+     * @throws IOException If failed to execute
+     */
+    void mkdir(String path) throws IOException;
+
+    /**
+     * Remove remote directory
+     *
+     * @param path Remote directory path
+     * @throws IOException If failed to execute
+     */
+    void rmdir(String path) throws IOException;
+
+    /**
+     * Obtain a handle for a directory
+     *
+     * @param path Remote directory path
+     * @return The associated directory {@link Handle}
+     * @throws IOException If failed to execute
+     */
+    CloseableHandle openDir(String path) throws IOException;
+
+    /**
+     * @param handle Directory {@link Handle} to read from
+     * @return A {@link List} of entries - {@code null} to indicate no more 
entries
+     * <B>Note:</B> the list may be <U>incomplete</U> since the client and
+     * server have some internal imposed limit on the number of entries they
+     * can process. Therefore several calls to this method may be required
+     * (until {@code null}). In order to iterate over all the entries use
+     * {@link #readDir(String)}
+     * @throws IOException If failed to access the remote site
+     */
+    default List<DirEntry> readDir(Handle handle) throws IOException {
+        return readDir(handle, null);
+    }
+
+    /**
+     * @param handle Directory {@link Handle} to read from
+     * @return A {@link List} of entries - {@code null} to indicate no more 
entries
+     * @param eolIndicator An indicator that can be used to get information
+     * whether end of list has been reached - ignored if {@code null}. Upon
+     * return, set value indicates whether all entries have been exhausted - a 
{@code null}
+     * value means that this information cannot be provided and another call to
+     * {@code readDir} is necessary in order to verify that no more entries 
are pending
+     * @throws IOException If failed to access the remote site
+     * @see <A 
HREF="https://tools.ietf.org/html/draft-ietf-secsh-filexfer-13#section-9.4";>SFTP
 v6 - section 9.4</A>
+     */
+    List<DirEntry> readDir(Handle handle, AtomicReference<Boolean> 
eolIndicator) throws IOException;
+
+    /**
+     * @param handle A directory {@link Handle}
+     * @return An {@link Iterable} that can be used to iterate over all the
+     * directory entries (like {@link #readDir(String)}). <B>Note:</B> the
+     * iterable instance is not re-usable - i.e., files can be iterated
+     * only <U>once</U>
+     * @throws IOException If failed to access the directory
+     */
+    default Iterable<DirEntry> listDir(Handle handle) throws IOException {
+        if (!isOpen()) {
+            throw new IOException("listDir(" + handle + ") client is closed");
+        }
+
+        return new StfpIterableDirHandle(this, handle);
+    }
+
+    /**
+     * The effective &quot;normalized&quot; remote path
+     *
+     * @param path The requested path - may be relative, and/or contain
+     * dots - e.g., &quot;.&quot;, &quot;..&quot;, &quot;./foo&quot;, 
&quot;../bar&quot;
+     *
+     * @return The effective &quot;normalized&quot; remote path
+     * @throws IOException If failed to execute
+     */
+    String canonicalPath(String path) throws IOException;
+
+    /**
+     * Retrieve remote path meta-data - follow symbolic links if encountered
+     *
+     * @param path The remote path
+     * @return The associated {@link Attributes}
+     * @throws IOException If failed to execute
+     */
+    Attributes stat(String path) throws IOException;
+
+    /**
+     * Retrieve remote path meta-data - do <B>not</B> follow symbolic links
+     *
+     * @param path The remote path
+     * @return The associated {@link Attributes}
+     * @throws IOException If failed to execute
+     */
+    Attributes lstat(String path) throws IOException;
+
+    /**
+     * Retrieve file/directory handle meta-data
+     *
+     * @param handle The {@link Handle} obtained via one of the {@code open} 
calls
+     * @return The associated {@link Attributes}
+     * @throws IOException If failed to execute
+     */
+    Attributes stat(Handle handle) throws IOException;
+
+    /**
+     * Update remote node meta-data
+     *
+     * @param path The remote path
+     * @param attributes The {@link Attributes} to update
+     * @throws IOException If failed to execute
+     */
+    void setStat(String path, Attributes attributes) throws IOException;
+
+    /**
+     * Update remote node meta-data
+     *
+     * @param handle The {@link Handle} obtained via one of the {@code open} 
calls
+     * @param attributes The {@link Attributes} to update
+     * @throws IOException If failed to execute
+     */
+    void setStat(Handle handle, Attributes attributes) throws IOException;
+
+    /**
+     * Retrieve target of a link
+     *
+     * @param path Remote path that represents a link
+     * @return The link target
+     * @throws IOException If failed to execute
+     */
+    String readLink(String path) throws IOException;
+
+    /**
+     * Create symbolic link
+     *
+     * @param linkPath   The link location
+     * @param targetPath The referenced target by the link
+     * @throws IOException If failed to execute
+     * @see #link(String, String, boolean)
+     */
+    default void symLink(String linkPath, String targetPath) throws 
IOException {
+        link(linkPath, targetPath, true);
+    }
+
+    /**
+     * Create a link
+     *
+     * @param linkPath   The link location
+     * @param targetPath The referenced target by the link
+     * @param symbolic   If {@code true} then make this a symbolic link, 
otherwise a hard one
+     * @throws IOException If failed to execute
+     */
+    void link(String linkPath, String targetPath, boolean symbolic) throws 
IOException;
+
+    // see SSH_FXP_BLOCK / SSH_FXP_UNBLOCK for byte range locks
+    void lock(Handle handle, long offset, long length, int mask) throws 
IOException;
+
+    void unlock(Handle handle, long offset, long length) throws IOException;
+
+    //
+    // High level API
+    //
+
+    default SftpRemotePathChannel openRemotePathChannel(String path, 
OpenOption... options) throws IOException {
+        return openRemotePathChannel(path, GenericUtils.isEmpty(options) ? 
Collections.emptyList() : Arrays.asList(options));
+    }
+
+    default SftpRemotePathChannel openRemotePathChannel(String path, 
Collection<? extends OpenOption> options) throws IOException {
+        return openRemoteFileChannel(path, OpenMode.fromOpenOptions(options));
+    }
+
+    default SftpRemotePathChannel openRemoteFileChannel(String path, 
OpenMode... modes) throws IOException {
+        return openRemoteFileChannel(path, GenericUtils.isEmpty(modes) ? 
Collections.emptyList() : Arrays.asList(modes));
+    }
+
+    /**
+     * Opens an {@link SftpRemotePathChannel} on the specified remote path
+     *
+     * @param path The remote path
+     * @param modes The access mode(s) - if {@code null}/empty then the {@link 
#DEFAULT_CHANNEL_MODES} are used
+     * @return The open {@link SftpRemotePathChannel} - <B>Note:</B> do not 
close this
+     * owner client instance until the channel is no longer needed since it 
uses the client
+     * for providing the channel's functionality.
+     * @throws IOException If failed to open the channel
+     * @see 
java.nio.channels.Channels#newInputStream(java.nio.channels.ReadableByteChannel)
+     * @see 
java.nio.channels.Channels#newOutputStream(java.nio.channels.WritableByteChannel)
+     */
+    default SftpRemotePathChannel openRemoteFileChannel(String path, 
Collection<OpenMode> modes) throws IOException {
+        return new SftpRemotePathChannel(path, this, false, 
GenericUtils.isEmpty(modes) ? DEFAULT_CHANNEL_MODES : modes);
+    }
+
+    /**
+     * @param path The remote directory path
+     * @return An {@link Iterable} that can be used to iterate over all the
+     * directory entries (unlike {@link #readDir(Handle)})
+     * @throws IOException If failed to access the remote site
+     * @see #readDir(Handle)
+     */
+    default Iterable<DirEntry> readDir(String path) throws IOException {
+        if (!isOpen()) {
+            throw new IOException("readDir(" + path + ") client is closed");
+        }
+
+        return new SftpIterableDirEntry(this, path);
+    }
+
+    default InputStream read(String path) throws IOException {
+        return read(path, DEFAULT_READ_BUFFER_SIZE);
+    }
+
+    default InputStream read(String path, int bufferSize) throws IOException {
+        return read(path, bufferSize, EnumSet.of(OpenMode.Read));
+    }
+
+    default InputStream read(String path, OpenMode... mode) throws IOException 
{
+        return read(path, DEFAULT_READ_BUFFER_SIZE, mode);
+    }
+
+    default InputStream read(String path, int bufferSize, OpenMode... mode) 
throws IOException {
+        return read(path, bufferSize, GenericUtils.of(mode));
+    }
+
+    default InputStream read(String path, Collection<OpenMode> mode) throws 
IOException {
+        return read(path, DEFAULT_READ_BUFFER_SIZE, mode);
+    }
+
+    /**
+     * Read a remote file's data via an input stream
+     *
+     * @param path       The remote file path
+     * @param bufferSize The internal read buffer size
+     * @param mode       The remote file {@link OpenMode}s
+     * @return An {@link InputStream} for reading the remote file data
+     * @throws IOException If failed to execute
+     */
+    default InputStream read(String path, int bufferSize, Collection<OpenMode> 
mode) throws IOException {
+        if (bufferSize < MIN_READ_BUFFER_SIZE) {
+            throw new IllegalArgumentException("Insufficient read buffer size: 
" + bufferSize + ", min.=" + MIN_READ_BUFFER_SIZE);
+        }
+
+        if (!isOpen()) {
+            throw new IOException("read(" + path + ")[" + mode + "] size=" + 
bufferSize + ": client is closed");
+        }
+
+        return new SftpInputStreamWithChannel(this, bufferSize, path, mode);
+    }
+
+    default OutputStream write(String path) throws IOException {
+        return write(path, DEFAULT_WRITE_BUFFER_SIZE);
+    }
+
+    default OutputStream write(String path, int bufferSize) throws IOException 
{
+        return write(path, bufferSize, EnumSet.of(OpenMode.Write, 
OpenMode.Create, OpenMode.Truncate));
+    }
+
+    default OutputStream write(String path, OpenMode... mode) throws 
IOException {
+        return write(path, DEFAULT_WRITE_BUFFER_SIZE, mode);
+    }
+
+    default OutputStream write(String path, int bufferSize, OpenMode... mode) 
throws IOException {
+        return write(path, bufferSize, GenericUtils.of(mode));
+    }
+
+    default OutputStream write(String path, Collection<OpenMode> mode) throws 
IOException {
+        return write(path, DEFAULT_WRITE_BUFFER_SIZE, mode);
+    }
+
+    /**
+     * Write to a remote file via an output stream
+     *
+     * @param path       The remote file path
+     * @param bufferSize The internal write buffer size
+     * @param mode       The remote file {@link OpenMode}s
+     * @return An {@link OutputStream} for writing the data
+     * @throws IOException If failed to execute
+     */
+    default OutputStream write(String path, int bufferSize, 
Collection<OpenMode> mode) throws IOException {
+        if (bufferSize < MIN_WRITE_BUFFER_SIZE) {
+            throw new IllegalArgumentException("Insufficient write buffer 
size: " + bufferSize + ", min.=" + MIN_WRITE_BUFFER_SIZE);
+        }
+
+        if (!isOpen()) {
+            throw new IOException("write(" + path + ")[" + mode + "] size=" + 
bufferSize + ": client is closed");
+        }
+
+        return new SftpOutputStreamWithChannel(this, bufferSize, path, mode);
+    }
+
+    /**
+     * @param <E>           The generic extension type
+     * @param extensionType The extension type
+     * @return The extension instance - <B>Note:</B> it is up to the caller
+     * to invoke {@link SftpClientExtension#isSupported()} - {@code null} if
+     * this extension type is not implemented by the client
+     * @see #getServerExtensions()
+     */
+    <E extends SftpClientExtension> E getExtension(Class<? extends E> 
extensionType);
+
+    /**
+     * @param extensionName The extension name
+     * @return The extension instance - <B>Note:</B> it is up to the caller
+     * to invoke {@link SftpClientExtension#isSupported()} - {@code null} if
+     * this extension type is not implemented by the client
+     * @see #getServerExtensions()
+     */
+    SftpClientExtension getExtension(String extensionName);
+}

http://git-wip-us.apache.org/repos/asf/mina-sshd/blob/251db9b9/sshd-sftp/src/main/java/org/apache/sshd/client/subsystem/sftp/SftpClientFactory.java
----------------------------------------------------------------------
diff --git 
a/sshd-sftp/src/main/java/org/apache/sshd/client/subsystem/sftp/SftpClientFactory.java
 
b/sshd-sftp/src/main/java/org/apache/sshd/client/subsystem/sftp/SftpClientFactory.java
new file mode 100644
index 0000000..7f79b33
--- /dev/null
+++ 
b/sshd-sftp/src/main/java/org/apache/sshd/client/subsystem/sftp/SftpClientFactory.java
@@ -0,0 +1,100 @@
+/*
+ * 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.subsystem.sftp;
+
+import java.io.IOException;
+import java.nio.file.FileSystem;
+
+import org.apache.sshd.client.session.ClientSession;
+import org.apache.sshd.client.subsystem.sftp.impl.DefaultSftpClientFactory;
+
+/**
+ * TODO Add javadoc
+ *
+ * @author <a href="mailto:d...@mina.apache.org";>Apache MINA SSHD Project</a>
+ */
+public interface SftpClientFactory {
+
+    static SftpClientFactory instance() {
+        return DefaultSftpClientFactory.INSTANCE;
+    }
+
+    /**
+     * Create an SFTP client from this session.
+     *
+     * @return The created {@link SftpClient}
+     * @throws IOException if failed to create the client
+     */
+    default SftpClient createSftpClient(ClientSession session) throws 
IOException {
+        return createSftpClient(session, SftpVersionSelector.CURRENT);
+    }
+
+    /**
+     * Creates an SFTP client using the specified version
+     *
+     * @param version The version to use - <B>Note:</B> if the specified
+     *                version is not supported by the server then an exception
+     *                will occur
+     * @return The created {@link SftpClient}
+     * @throws IOException If failed to create the client or use the specified 
version
+     */
+    default SftpClient createSftpClient(ClientSession session, int version) 
throws IOException {
+        return createSftpClient(session, 
SftpVersionSelector.fixedVersionSelector(version));
+    }
+
+    /**
+     * @param session The {@link ClientSession} to which the SFTP client 
should be attached
+     * @param selector The {@link SftpVersionSelector} to use in order to 
negotiate the SFTP version
+     * @return The created {@link SftpClient} instance
+     * @throws IOException If failed to create the client
+     */
+    SftpClient createSftpClient(ClientSession session, SftpVersionSelector 
selector) throws IOException;
+
+    default FileSystem createSftpFileSystem(ClientSession session) throws 
IOException {
+        return createSftpFileSystem(session, SftpVersionSelector.CURRENT);
+    }
+
+    default FileSystem createSftpFileSystem(ClientSession session, int 
version) throws IOException {
+        return createSftpFileSystem(session, 
SftpVersionSelector.fixedVersionSelector(version));
+    }
+
+    default FileSystem createSftpFileSystem(ClientSession session, 
SftpVersionSelector selector) throws IOException {
+        return createSftpFileSystem(session, selector, 
SftpClient.DEFAULT_READ_BUFFER_SIZE, SftpClient.DEFAULT_WRITE_BUFFER_SIZE);
+    }
+
+    default FileSystem createSftpFileSystem(ClientSession session, int 
version, int readBufferSize, int writeBufferSize) throws IOException {
+        return createSftpFileSystem(session, 
SftpVersionSelector.fixedVersionSelector(version), readBufferSize, 
writeBufferSize);
+    }
+
+    default FileSystem createSftpFileSystem(ClientSession session, int 
readBufferSize, int writeBufferSize) throws IOException {
+        return createSftpFileSystem(session, SftpVersionSelector.CURRENT, 
readBufferSize, writeBufferSize);
+    }
+
+    /**
+     * @param session The {@link ClientSession} to which the SFTP client 
backing the file system should be attached
+     * @param selector The {@link SftpVersionSelector} to use in order to 
negotiate the SFTP version
+     * @param readBufferSize Default I/O read buffer size
+     * @param writeBufferSize Default I/O write buffer size
+     * @return The created {@link FileSystem} instance
+     * @throws IOException If failed to create the instance
+     */
+    FileSystem createSftpFileSystem(
+        ClientSession session, SftpVersionSelector selector, int 
readBufferSize, int writeBufferSize)
+            throws IOException;
+}

http://git-wip-us.apache.org/repos/asf/mina-sshd/blob/251db9b9/sshd-sftp/src/main/java/org/apache/sshd/client/subsystem/sftp/SftpCommand.java
----------------------------------------------------------------------
diff --git 
a/sshd-sftp/src/main/java/org/apache/sshd/client/subsystem/sftp/SftpCommand.java
 
b/sshd-sftp/src/main/java/org/apache/sshd/client/subsystem/sftp/SftpCommand.java
new file mode 100644
index 0000000..61a83ec
--- /dev/null
+++ 
b/sshd-sftp/src/main/java/org/apache/sshd/client/subsystem/sftp/SftpCommand.java
@@ -0,0 +1,920 @@
+/*
+ * 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.subsystem.sftp;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.io.PrintStream;
+import java.lang.reflect.Field;
+import java.lang.reflect.Modifier;
+import java.nio.channels.Channel;
+import java.nio.file.DirectoryStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Map;
+import java.util.Objects;
+import java.util.TreeMap;
+import java.util.logging.Level;
+
+import org.apache.sshd.client.SshClient;
+import org.apache.sshd.client.session.ClientSession;
+import org.apache.sshd.client.subsystem.sftp.SftpClient.Attributes;
+import org.apache.sshd.client.subsystem.sftp.SftpClient.DirEntry;
+import 
org.apache.sshd.client.subsystem.sftp.extensions.openssh.OpenSSHStatExtensionInfo;
+import 
org.apache.sshd.client.subsystem.sftp.extensions.openssh.OpenSSHStatPathExtension;
+import org.apache.sshd.common.NamedResource;
+import org.apache.sshd.common.io.IoSession;
+import org.apache.sshd.common.kex.KexProposalOption;
+import org.apache.sshd.common.session.Session;
+import org.apache.sshd.common.subsystem.sftp.SftpConstants;
+import org.apache.sshd.common.subsystem.sftp.SftpException;
+import org.apache.sshd.common.subsystem.sftp.extensions.ParserUtils;
+import 
org.apache.sshd.common.subsystem.sftp.extensions.openssh.StatVfsExtensionParser;
+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.buffer.BufferUtils;
+import org.apache.sshd.common.util.io.IoUtils;
+import org.apache.sshd.common.util.io.NoCloseInputStream;
+
+/**
+ * Implements a simple command line SFTP client similar to the Linux one
+ *
+ * @author <a href="mailto:d...@mina.apache.org";>Apache MINA SSHD Project</a>
+ */
+public class SftpCommand implements Channel {
+    /**
+     * Command line option used to indicate a non-default port number
+     */
+    public static final String SFTP_PORT_OPTION = "-P";
+
+    private final SftpClient client;
+    private final Map<String, CommandExecutor> commandsMap;
+    private String cwdRemote;
+    private String cwdLocal;
+
+    @SuppressWarnings("synthetic-access")
+    public SftpCommand(SftpClient client) {
+        this.client = Objects.requireNonNull(client, "No client");
+
+        Map<String, CommandExecutor> map = new 
TreeMap<>(String.CASE_INSENSITIVE_ORDER);
+        for (CommandExecutor e : Arrays.asList(
+                new ExitCommandExecutor(),
+                new PwdCommandExecutor(),
+                new InfoCommandExecutor(),
+                new SessionCommandExecutor(),
+                new VersionCommandExecutor(),
+                new CdCommandExecutor(),
+                new LcdCommandExecutor(),
+                new MkdirCommandExecutor(),
+                new LsCommandExecutor(),
+                new LStatCommandExecutor(),
+                new ReadLinkCommandExecutor(),
+                new RmCommandExecutor(),
+                new RmdirCommandExecutor(),
+                new RenameCommandExecutor(),
+                new StatVfsCommandExecutor(),
+                new GetCommandExecutor(),
+                new PutCommandExecutor(),
+                new HelpCommandExecutor()
+        )) {
+            String name = e.getName();
+            ValidateUtils.checkTrue(map.put(name, e) == null, "Multiple 
commands named '%s'", name);
+        }
+        commandsMap = Collections.unmodifiableMap(map);
+        cwdLocal = System.getProperty("user.dir");
+    }
+
+    public final SftpClient getClient() {
+        return client;
+    }
+
+    public void doInteractive(BufferedReader stdin, PrintStream stdout, 
PrintStream stderr) throws Exception {
+        SftpClient sftp = getClient();
+        setCurrentRemoteDirectory(sftp.canonicalPath("."));
+        while (true) {
+            stdout.append(getCurrentRemoteDirectory()).append(" > ").flush();
+            String line = stdin.readLine();
+            if (line == null) { // EOF
+                break;
+            }
+
+            line = GenericUtils.replaceWhitespaceAndTrim(line);
+            if (GenericUtils.isEmpty(line)) {
+                continue;
+            }
+
+            String cmd;
+            String args;
+            int pos = line.indexOf(' ');
+            if (pos > 0) {
+                cmd = line.substring(0, pos);
+                args = line.substring(pos + 1).trim();
+            } else {
+                cmd = line;
+                args = "";
+            }
+
+            CommandExecutor exec = commandsMap.get(cmd);
+            try {
+                if (exec == null) {
+                    stderr.append("Unknown command: ").println(line);
+                } else {
+                    try {
+                        if (exec.executeCommand(args, stdin, stdout, stderr)) {
+                            break;
+                        }
+                    } catch (Exception e) {
+                        stderr.append(e.getClass().getSimpleName()).append(": 
").println(e.getMessage());
+                    } finally {
+                        stdout.flush();
+                    }
+                }
+            } finally {
+                stderr.flush(); // just makings sure
+            }
+        }
+    }
+
+    protected String resolveLocalPath(String pathArg) {
+        String cwd = getCurrentLocalDirectory();
+        if (GenericUtils.isEmpty(pathArg)) {
+            return cwd;
+        }
+
+        if (OsUtils.isWin32()) {
+            if ((pathArg.length() >= 2) && (pathArg.charAt(1) == ':')) {
+                return pathArg;
+            }
+        } else {
+            if (pathArg.charAt(0) == '/') {
+                return pathArg;
+            }
+        }
+
+        return cwd + File.separator + pathArg.replace('/', File.separatorChar);
+    }
+
+    protected String resolveRemotePath(String pathArg) {
+        String cwd = getCurrentRemoteDirectory();
+        if (GenericUtils.isEmpty(pathArg)) {
+            return cwd;
+        }
+
+        if (pathArg.charAt(0) == '/') {
+            return pathArg;
+        } else {
+            return cwd + "/" + pathArg;
+        }
+    }
+
+    protected <A extends Appendable> A appendFileAttributes(A stdout, 
SftpClient sftp, String path, Attributes attrs) throws IOException {
+        stdout.append('\t').append(Long.toString(attrs.getSize()))
+              
.append('\t').append(SftpFileSystemProvider.getRWXPermissions(attrs.getPermissions()));
+        if (attrs.isSymbolicLink()) {
+            String linkValue = sftp.readLink(path);
+            stdout.append(" => ")
+                  .append('(').append(attrs.isDirectory() ? "dir" : 
"file").append(')')
+                  .append(' ').append(linkValue);
+        }
+
+        return stdout;
+    }
+
+    public String getCurrentRemoteDirectory() {
+        return cwdRemote;
+    }
+
+    public void setCurrentRemoteDirectory(String path) {
+        cwdRemote = path;
+    }
+
+    public String getCurrentLocalDirectory() {
+        return cwdLocal;
+    }
+
+    public void setCurrentLocalDirectory(String path) {
+        cwdLocal = path;
+    }
+
+    @Override
+    public boolean isOpen() {
+        return client.isOpen();
+    }
+
+    @Override
+    public void close() throws IOException {
+        if (isOpen()) {
+            client.close();
+        }
+    }
+
+    public interface CommandExecutor extends NamedResource {
+        // return value is whether to stop running
+        boolean executeCommand(String args, BufferedReader stdin, PrintStream 
stdout, PrintStream stderr) throws Exception;
+    }
+
+    //////////////////////////////////////////////////////////////////////////
+
+    public static <A extends Appendable> A appendInfoValue(A sb, CharSequence 
name, Object value) throws IOException {
+        sb.append('\t').append(name).append(": 
").append(Objects.toString(value));
+        return sb;
+    }
+
+    public static void main(String[] args) throws Exception {
+        PrintStream stdout = System.out;
+        PrintStream stderr = System.err;
+        OutputStream logStream = stderr;
+        try (BufferedReader stdin = new BufferedReader(new 
InputStreamReader(new NoCloseInputStream(System.in)))) {
+            Level level = SshClient.resolveLoggingVerbosity(args);
+            logStream = SshClient.resolveLoggingTargetStream(stdout, stderr, 
args);
+            if (logStream != null) {
+                SshClient.setupLogging(level, stdout, stderr, logStream);
+            }
+
+            ClientSession session = (logStream == null) ? null : 
SshClient.setupClientSession(SFTP_PORT_OPTION, stdin, stdout, stderr, args);
+            if (session == null) {
+                System.err.println("usage: sftp [-v[v][v]] [-E logoutput] [-i 
identity]"
+                        + " [-l login] [" + SFTP_PORT_OPTION + " port] [-o 
option=value]"
+                        + " [-w password] [-c cipherlist]  [-m maclist] [-C] 
hostname/user@host");
+                System.exit(-1);
+                return;
+            }
+
+            try {
+                try (SftpCommand sftp = new 
SftpCommand(SftpClientFactory.instance().createSftpClient(session))) {
+                    sftp.doInteractive(stdin, stdout, stderr);
+                }
+            } finally {
+                session.close();
+            }
+        } finally {
+            if ((logStream != stdout) && (logStream != stderr)) {
+                logStream.close();
+            }
+        }
+    }
+
+    private static class ExitCommandExecutor implements CommandExecutor {
+        ExitCommandExecutor() {
+            super();
+        }
+
+        @Override
+        public String getName() {
+            return "exit";
+        }
+
+        @Override
+        public boolean executeCommand(String args, BufferedReader stdin, 
PrintStream stdout, PrintStream stderr) throws Exception {
+            ValidateUtils.checkTrue(GenericUtils.isEmpty(args), "Unexpected 
arguments: %s", args);
+            stdout.println("Exiting");
+            return true;
+        }
+    }
+
+    private class PwdCommandExecutor implements CommandExecutor {
+        protected PwdCommandExecutor() {
+            super();
+        }
+
+        @Override
+        public String getName() {
+            return "pwd";
+        }
+
+        @Override
+        public boolean executeCommand(String args, BufferedReader stdin, 
PrintStream stdout, PrintStream stderr) throws Exception {
+            ValidateUtils.checkTrue(GenericUtils.isEmpty(args), "Unexpected 
arguments: %s", args);
+            stdout.append('\t').append("Remote: 
").println(getCurrentRemoteDirectory());
+            stdout.append('\t').append("Local: 
").println(getCurrentLocalDirectory());
+            return false;
+        }
+    }
+
+    private class SessionCommandExecutor implements CommandExecutor {
+        SessionCommandExecutor() {
+            super();
+        }
+
+        @Override
+        public String getName() {
+            return "session";
+        }
+
+        @Override
+        public boolean executeCommand(String args, BufferedReader stdin, 
PrintStream stdout, PrintStream stderr) throws Exception {
+            ValidateUtils.checkTrue(GenericUtils.isEmpty(args), "Unexpected 
arguments: %s", args);
+            SftpClient sftp = getClient();
+            ClientSession session = sftp.getSession();
+            appendInfoValue(stdout, "Session ID", 
BufferUtils.toHex(session.getSessionId())).println();
+            appendInfoValue(stdout, "Connect address", 
session.getConnectAddress()).println();
+
+            IoSession ioSession = session.getIoSession();
+            appendInfoValue(stdout, "Local address", 
ioSession.getLocalAddress()).println();
+            appendInfoValue(stdout, "Remote address", 
ioSession.getRemoteAddress()).println();
+
+            for (KexProposalOption option : KexProposalOption.VALUES) {
+                appendInfoValue(stdout, option.getDescription(), 
session.getNegotiatedKexParameter(option)).println();
+            }
+
+            return false;
+        }
+    }
+
+    private class InfoCommandExecutor implements CommandExecutor {
+        InfoCommandExecutor() {
+            super();
+        }
+
+        @Override
+        public String getName() {
+            return "info";
+        }
+
+        @Override
+        public boolean executeCommand(String args, BufferedReader stdin, 
PrintStream stdout, PrintStream stderr) throws Exception {
+            ValidateUtils.checkTrue(GenericUtils.isEmpty(args), "Unexpected 
arguments: %s", args);
+            SftpClient sftp = getClient();
+            Session session = sftp.getSession();
+            stdout.append('\t').println(session.getServerVersion());
+
+            Map<String, byte[]> extensions = sftp.getServerExtensions();
+            Map<String, ?> parsed = ParserUtils.parse(extensions);
+            if (GenericUtils.size(extensions) > 0) {
+                stdout.println();
+            }
+
+            extensions.forEach((name, value) -> {
+                Object info = parsed.get(name);
+
+                stdout.append('\t').append(name).append(": ");
+                if (info == null) {
+                    stdout.println(BufferUtils.toHex(value));
+                } else {
+                    stdout.println(info);
+                }
+            });
+
+            return false;
+        }
+    }
+
+    private class VersionCommandExecutor implements CommandExecutor {
+        VersionCommandExecutor() {
+            super();
+        }
+
+        @Override
+        public String getName() {
+            return "version";
+        }
+
+        @Override
+        public boolean executeCommand(String args, BufferedReader stdin, 
PrintStream stdout, PrintStream stderr) throws Exception {
+            ValidateUtils.checkTrue(GenericUtils.isEmpty(args), "Unexpected 
arguments: %s", args);
+            SftpClient sftp = getClient();
+            stdout.append('\t').println(sftp.getVersion());
+            return false;
+        }
+    }
+
+    private class CdCommandExecutor extends PwdCommandExecutor {
+        CdCommandExecutor() {
+            super();
+        }
+
+        @Override
+        public String getName() {
+            return "cd";
+        }
+
+        @Override
+        public boolean executeCommand(String args, BufferedReader stdin, 
PrintStream stdout, PrintStream stderr) throws Exception {
+            ValidateUtils.checkNotNullAndNotEmpty(args, "No remote directory 
specified");
+
+            String newPath = resolveRemotePath(args);
+            SftpClient sftp = getClient();
+            setCurrentRemoteDirectory(sftp.canonicalPath(newPath));
+            return super.executeCommand("", stdin, stdout, stderr);
+        }
+    }
+
+    private class LcdCommandExecutor extends PwdCommandExecutor {
+        LcdCommandExecutor() {
+            super();
+        }
+
+        @Override
+        public String getName() {
+            return "lcd";
+        }
+
+        @Override
+        public boolean executeCommand(String args, BufferedReader stdin, 
PrintStream stdout, PrintStream stderr) throws Exception {
+            if (GenericUtils.isEmpty(args)) {
+                setCurrentLocalDirectory(System.getProperty("user.home"));
+            } else {
+                Path path = 
Paths.get(resolveLocalPath(args)).normalize().toAbsolutePath();
+                ValidateUtils.checkTrue(Files.exists(path), "No such local 
directory: %s", path);
+                ValidateUtils.checkTrue(Files.isDirectory(path), "Path is not 
a directory: %s", path);
+                setCurrentLocalDirectory(path.toString());
+            }
+
+            return super.executeCommand("", stdin, stdout, stderr);
+        }
+    }
+
+    private class MkdirCommandExecutor implements CommandExecutor {
+        MkdirCommandExecutor() {
+            super();
+        }
+
+        @Override
+        public String getName() {
+            return "mkdir";
+        }
+
+        @Override
+        public boolean executeCommand(String args, BufferedReader stdin, 
PrintStream stdout, PrintStream stderr) throws Exception {
+            ValidateUtils.checkNotNullAndNotEmpty(args, "No remote directory 
specified");
+
+            String path = resolveRemotePath(args);
+            SftpClient sftp = getClient();
+            sftp.mkdir(path);
+            return false;
+        }
+    }
+
+    private class LsCommandExecutor implements CommandExecutor {
+        LsCommandExecutor() {
+            super();
+        }
+
+        @Override
+        public String getName() {
+            return "ls";
+        }
+
+        @Override
+        public boolean executeCommand(String args, BufferedReader stdin, 
PrintStream stdout, PrintStream stderr) throws Exception {
+            String[] comps = GenericUtils.split(args, ' ');
+            int numComps = GenericUtils.length(comps);
+            String pathArg = (numComps <= 0) ? null : 
GenericUtils.trimToEmpty(comps[numComps - 1]);
+            String flags = (numComps >= 2) ? 
GenericUtils.trimToEmpty(comps[0]) : null;
+            // ignore all flags
+            if ((GenericUtils.length(pathArg) > 0) && (pathArg.charAt(0) == 
'-')) {
+                flags = pathArg;
+                pathArg = null;
+            }
+
+            String path = resolveRemotePath(pathArg);
+            SftpClient sftp = getClient();
+            int version = sftp.getVersion();
+            boolean showLongName = (version == SftpConstants.SFTP_V3) && 
(GenericUtils.length(flags) > 1) && (flags.indexOf('l') > 0);
+            for (SftpClient.DirEntry entry : sftp.readDir(path)) {
+                String fileName = entry.getFilename();
+                SftpClient.Attributes attrs = entry.getAttributes();
+                appendFileAttributes(stdout.append('\t').append(fileName), 
sftp, path + "/" + fileName, attrs).println();
+                if (showLongName) {
+                    stdout.append("\t\tlong-name: 
").println(entry.getLongFilename());
+                }
+            }
+
+            return false;
+        }
+    }
+
+    private class RmCommandExecutor implements CommandExecutor {
+        RmCommandExecutor() {
+            super();
+        }
+
+        @Override
+        public String getName() {
+            return "rm";
+        }
+
+        @Override
+        public boolean executeCommand(String args, BufferedReader stdin, 
PrintStream stdout, PrintStream stderr) throws Exception {
+            String[] comps = GenericUtils.split(args, ' ');
+            int numArgs = GenericUtils.length(comps);
+            ValidateUtils.checkTrue(numArgs >= 1, "No arguments");
+            ValidateUtils.checkTrue(numArgs <= 2, "Too many arguments: %s", 
args);
+
+            String remotePath = comps[0];
+            boolean recursive = false;
+            boolean verbose = false;
+            if (remotePath.charAt(0) == '-') {
+                ValidateUtils.checkTrue(remotePath.length() > 1, "Missing 
flags specification: %s", args);
+                ValidateUtils.checkTrue(numArgs == 2, "Missing remote 
directory: %s", args);
+
+                for (int index = 1; index < remotePath.length(); index++) {
+                    char ch = remotePath.charAt(index);
+                    switch(ch) {
+                        case 'r' :
+                            recursive = true;
+                            break;
+                        case 'v':
+                            verbose = true;
+                            break;
+                        default:
+                            throw new IllegalArgumentException("Unknown flag 
(" + String.valueOf(ch) + ")");
+                    }
+                }
+                remotePath = comps[1];
+            }
+
+            String path = resolveRemotePath(remotePath);
+            SftpClient sftp = getClient();
+            if (recursive) {
+                Attributes attrs = sftp.stat(path);
+                ValidateUtils.checkTrue(attrs.isDirectory(), "Remote path not 
a directory: %s", args);
+                removeRecursive(sftp, path, attrs, stdout, verbose);
+            } else {
+                sftp.remove(path);
+                if (verbose) {
+                    stdout.append('\t').append("Removed ").println(path);
+                }
+            }
+
+            return false;
+        }
+
+        private void removeRecursive(SftpClient sftp, String path, Attributes 
attrs, PrintStream stdout, boolean verbose) throws IOException {
+            if (attrs.isDirectory()) {
+                for (DirEntry entry : sftp.readDir(path)) {
+                    String name = entry.getFilename();
+                    if (".".equals(name) || "..".equals(name)) {
+                        continue;
+                    }
+
+                    removeRecursive(sftp, path + "/" + name, 
entry.getAttributes(), stdout, verbose);
+                }
+
+                sftp.rmdir(path);
+            } else if (attrs.isRegularFile()) {
+                sftp.remove(path);
+            } else {
+                if (verbose) {
+                    stdout.append('\t').append("Skip special file 
").println(path);
+                    return;
+                }
+            }
+
+            if (verbose) {
+                stdout.append('\t').append("Removed ").println(path);
+            }
+        }
+    }
+
+    private class RmdirCommandExecutor implements CommandExecutor {
+        RmdirCommandExecutor() {
+            super();
+        }
+
+        @Override
+        public String getName() {
+            return "rmdir";
+        }
+
+        @Override
+        public boolean executeCommand(String args, BufferedReader stdin, 
PrintStream stdout, PrintStream stderr) throws Exception {
+            ValidateUtils.checkNotNullAndNotEmpty(args, "No remote directory 
specified");
+
+            String path = resolveRemotePath(args);
+            SftpClient sftp = getClient();
+            sftp.rmdir(path);
+            return false;
+        }
+    }
+
+    private class RenameCommandExecutor implements CommandExecutor {
+        RenameCommandExecutor() {
+            super();
+        }
+
+        @Override
+        public String getName() {
+            return "rename";
+        }
+
+        @Override
+        public boolean executeCommand(String args, BufferedReader stdin, 
PrintStream stdout, PrintStream stderr) throws Exception {
+            String[] comps = GenericUtils.split(args, ' ');
+            ValidateUtils.checkTrue(GenericUtils.length(comps) == 2, "Invalid 
number of arguments: %s", args);
+
+            String oldPath = 
resolveRemotePath(GenericUtils.trimToEmpty(comps[0]));
+            String newPath = 
resolveRemotePath(GenericUtils.trimToEmpty(comps[1]));
+            SftpClient sftp = getClient();
+            sftp.rename(oldPath, newPath);
+            return false;
+        }
+    }
+
+    private class StatVfsCommandExecutor implements CommandExecutor {
+        StatVfsCommandExecutor() {
+            super();
+        }
+
+        @Override
+        public String getName() {
+            return StatVfsExtensionParser.NAME;
+        }
+
+        @Override
+        public boolean executeCommand(String args, BufferedReader stdin, 
PrintStream stdout, PrintStream stderr) throws Exception {
+            String[] comps = GenericUtils.split(args, ' ');
+            int numArgs = GenericUtils.length(comps);
+            ValidateUtils.checkTrue(numArgs <= 1, "Invalid number of 
arguments: %s", args);
+
+            SftpClient sftp = getClient();
+            OpenSSHStatPathExtension ext = 
sftp.getExtension(OpenSSHStatPathExtension.class);
+            ValidateUtils.checkTrue(ext.isSupported(), "Extension not 
supported by server: %s", ext.getName());
+
+            String remPath = resolveRemotePath((numArgs >= 1) ? 
GenericUtils.trimToEmpty(comps[0]) :  GenericUtils.trimToEmpty(args));
+            OpenSSHStatExtensionInfo info = ext.stat(remPath);
+            Field[] fields = info.getClass().getFields();
+            for (Field f : fields) {
+                String name = f.getName();
+                int mod = f.getModifiers();
+                if (Modifier.isStatic(mod)) {
+                    continue;
+                }
+
+                Object value = f.get(info);
+                stdout.append('\t').append(name).append(": ").println(value);
+            }
+
+            return false;
+        }
+    }
+
+    private class LStatCommandExecutor implements CommandExecutor {
+        LStatCommandExecutor() {
+            super();
+        }
+
+        @Override
+        public String getName() {
+            return "lstat";
+        }
+
+        @Override
+        public boolean executeCommand(String args, BufferedReader stdin, 
PrintStream stdout, PrintStream stderr) throws Exception {
+            String[] comps = GenericUtils.split(args, ' ');
+            ValidateUtils.checkTrue(GenericUtils.length(comps) <= 1, "Invalid 
number of arguments: %s", args);
+
+            String path = GenericUtils.trimToEmpty(resolveRemotePath(args));
+            SftpClient client = getClient();
+            Attributes attrs = client.lstat(path);
+            appendFileAttributes(stdout, client, path, attrs).println();
+            return false;
+        }
+    }
+
+    private class ReadLinkCommandExecutor implements CommandExecutor {
+        ReadLinkCommandExecutor() {
+            super();
+        }
+
+        @Override
+        public String getName() {
+            return "readlink";
+        }
+
+        @Override
+        public boolean executeCommand(String args, BufferedReader stdin, 
PrintStream stdout, PrintStream stderr) throws Exception {
+            String[] comps = GenericUtils.split(args, ' ');
+            ValidateUtils.checkTrue(GenericUtils.length(comps) <= 1, "Invalid 
number of arguments: %s", args);
+
+            String path = GenericUtils.trimToEmpty(resolveRemotePath(args));
+            SftpClient client = getClient();
+            String linkData = client.readLink(path);
+            stdout.append('\t').println(linkData);
+            return false;
+        }
+    }
+
+    private class HelpCommandExecutor implements CommandExecutor {
+        HelpCommandExecutor() {
+            super();
+        }
+
+        @Override
+        public String getName() {
+            return "help";
+        }
+
+        @Override
+        @SuppressWarnings("synthetic-access")
+        public boolean executeCommand(String args, BufferedReader stdin, 
PrintStream stdout, PrintStream stderr) throws Exception {
+            ValidateUtils.checkTrue(GenericUtils.isEmpty(args), "Unexpected 
arguments: %s", args);
+            for (String cmd : commandsMap.keySet()) {
+                stdout.append('\t').println(cmd);
+            }
+            return false;
+        }
+    }
+
+    private abstract class TransferCommandExecutor implements CommandExecutor {
+        protected TransferCommandExecutor() {
+            super();
+        }
+
+        protected void createDirectories(SftpClient sftp, String remotePath) 
throws IOException {
+            try {
+                Attributes attrs = sftp.stat(remotePath);
+                ValidateUtils.checkTrue(attrs.isDirectory(), "Remote path 
already exists but is not a directory: %s", remotePath);
+                return;
+            } catch (SftpException e) {
+                int status = e.getStatus();
+                ValidateUtils.checkTrue(status == 
SftpConstants.SSH_FX_NO_SUCH_FILE, "Failed to get status of %s: %s", 
remotePath, e.getMessage());
+            }
+
+            int pos = remotePath.lastIndexOf('/');
+            ValidateUtils.checkTrue(pos > 0, "No more parents for %s", 
remotePath);
+            createDirectories(sftp, remotePath.substring(0, pos));
+        }
+
+        protected void transferFile(SftpClient sftp, Path localPath, String 
remotePath, boolean upload, PrintStream stdout, boolean verbose) throws 
IOException {
+            // Create the file's hierarchy
+            if (upload) {
+                int pos = remotePath.lastIndexOf('/');
+                ValidateUtils.checkTrue(pos > 0, "Missing full remote file 
path: %s", remotePath);
+                createDirectories(sftp, remotePath.substring(0, pos));
+            } else {
+                Files.createDirectories(localPath.getParent());
+            }
+
+            try (InputStream input = upload ? Files.newInputStream(localPath) 
: sftp.read(remotePath);
+                 OutputStream output = upload ? sftp.write(remotePath) : 
Files.newOutputStream(localPath)) {
+                IoUtils.copy(input, output, SftpClient.IO_BUFFER_SIZE);
+            }
+
+            if (verbose) {
+                stdout.append('\t')
+                      .append("Copied ").append(upload ? localPath.toString() 
: remotePath)
+                      .append(" to ").println(upload ? remotePath : 
localPath.toString());
+            }
+        }
+
+        protected void transferRemoteDir(SftpClient sftp, Path localPath, 
String remotePath, Attributes attrs, PrintStream stdout, boolean verbose) 
throws IOException {
+            if (attrs.isDirectory()) {
+                for (DirEntry entry : sftp.readDir(remotePath)) {
+                    String name = entry.getFilename();
+                    if (".".equals(name) || "..".equals(name)) {
+                        continue;
+                    }
+
+                    transferRemoteDir(sftp, localPath.resolve(name), 
remotePath + "/" + name, entry.getAttributes(), stdout, verbose);
+                }
+            } else if (attrs.isRegularFile()) {
+                transferFile(sftp, localPath, remotePath, false, stdout, 
verbose);
+            } else {
+                if (verbose) {
+                    stdout.append('\t').append("Skip remote special file 
").println(remotePath);
+                }
+            }
+        }
+
+        protected void transferLocalDir(SftpClient sftp, Path localPath, 
String remotePath, PrintStream stdout, boolean verbose) throws IOException {
+            if (Files.isDirectory(localPath)) {
+                try (DirectoryStream<Path> ds = 
Files.newDirectoryStream(localPath)) {
+                    for (Path entry : ds) {
+                        String name = entry.getFileName().toString();
+                        transferLocalDir(sftp, localPath.resolve(name), 
remotePath + "/" + name, stdout, verbose);
+                    }
+                }
+            } else if (Files.isRegularFile(localPath)) {
+                transferFile(sftp, localPath, remotePath, true, stdout, 
verbose);
+            } else {
+                if (verbose) {
+                    stdout.append('\t').append("Skip local special file 
").println(localPath);
+                }
+            }
+        }
+
+        protected void executeCommand(String args, boolean upload, PrintStream 
stdout) throws IOException {
+            String[] comps = GenericUtils.split(args, ' ');
+            int numArgs = GenericUtils.length(comps);
+            ValidateUtils.checkTrue((numArgs >= 1) && (numArgs <= 3), "Invalid 
number of arguments: %s", args);
+
+            String src = comps[0];
+            boolean recursive = false;
+            boolean verbose = false;
+            int tgtIndex = 1;
+            if (src.charAt(0) == '-') {
+                ValidateUtils.checkTrue(src.length() > 1, "Missing flags 
specification: %s", args);
+                ValidateUtils.checkTrue(numArgs >= 2, "Missing source 
specification: %s", args);
+
+                for (int index = 1; index < src.length(); index++) {
+                    char ch = src.charAt(index);
+                    switch(ch) {
+                        case 'r' :
+                            recursive = true;
+                            break;
+                        case 'v':
+                            verbose = true;
+                            break;
+                        default:
+                            throw new IllegalArgumentException("Unknown flag 
(" + String.valueOf(ch) + ")");
+                    }
+                }
+                src = comps[1];
+                tgtIndex++;
+            }
+
+            String tgt = (tgtIndex < numArgs) ? comps[tgtIndex] : null;
+            String localPath;
+            String remotePath;
+            if (upload) {
+                localPath = src;
+                remotePath = ValidateUtils.checkNotNullAndNotEmpty(tgt, "No 
remote target specified: %s", args);
+            } else {
+                localPath = GenericUtils.isEmpty(tgt) ? 
getCurrentLocalDirectory() : tgt;
+                remotePath = src;
+            }
+
+            SftpClient sftp = getClient();
+            Path local = 
Paths.get(resolveLocalPath(localPath)).normalize().toAbsolutePath();
+            String remote = resolveRemotePath(remotePath);
+            if (recursive) {
+                if (upload) {
+                    ValidateUtils.checkTrue(Files.isDirectory(local), "Local 
path not a directory or does not exist: %s", local);
+                    transferLocalDir(sftp, local, remote, stdout, verbose);
+                } else {
+                    Attributes attrs = sftp.stat(remote);
+                    ValidateUtils.checkTrue(attrs.isDirectory(), "Remote path 
not a directory: %s", remote);
+                    transferRemoteDir(sftp, local, remote, attrs, stdout, 
verbose);
+                }
+            } else {
+                if (Files.exists(local) && Files.isDirectory(local)) {
+                    int pos = remote.lastIndexOf('/');
+                    String name = (pos >= 0) ? remote.substring(pos + 1) : 
remote;
+                    local = local.resolve(name);
+                }
+
+                transferFile(sftp, local, remote, upload, stdout, verbose);
+            }
+        }
+    }
+
+    private class GetCommandExecutor extends TransferCommandExecutor {
+        GetCommandExecutor() {
+            super();
+        }
+
+        @Override
+        public String getName() {
+            return "get";
+        }
+
+        @Override
+        public boolean executeCommand(String args, BufferedReader stdin, 
PrintStream stdout, PrintStream stderr) throws Exception {
+            executeCommand(args, false, stdout);
+            return false;
+        }
+    }
+
+    private class PutCommandExecutor extends TransferCommandExecutor {
+        PutCommandExecutor() {
+            super();
+        }
+
+        @Override
+        public String getName() {
+            return "put";
+        }
+
+        @Override
+        public boolean executeCommand(String args, BufferedReader stdin, 
PrintStream stdout, PrintStream stderr) throws Exception {
+            executeCommand(args, true, stdout);
+            return false;
+        }
+    }
+}

http://git-wip-us.apache.org/repos/asf/mina-sshd/blob/251db9b9/sshd-sftp/src/main/java/org/apache/sshd/client/subsystem/sftp/SftpDirEntryIterator.java
----------------------------------------------------------------------
diff --git 
a/sshd-sftp/src/main/java/org/apache/sshd/client/subsystem/sftp/SftpDirEntryIterator.java
 
b/sshd-sftp/src/main/java/org/apache/sshd/client/subsystem/sftp/SftpDirEntryIterator.java
new file mode 100644
index 0000000..abf3a1d
--- /dev/null
+++ 
b/sshd-sftp/src/main/java/org/apache/sshd/client/subsystem/sftp/SftpDirEntryIterator.java
@@ -0,0 +1,194 @@
+/*
+ * 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.subsystem.sftp;
+
+import java.io.Closeable;
+import java.io.IOException;
+import java.nio.channels.Channel;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.apache.sshd.client.subsystem.sftp.SftpClient.DirEntry;
+import org.apache.sshd.client.subsystem.sftp.SftpClient.Handle;
+import org.apache.sshd.common.util.ValidateUtils;
+import org.apache.sshd.common.util.logging.AbstractLoggingBean;
+
+/**
+ * Iterates over the available directory entries for a given path. <B>Note:</B>
+ * if the iteration is carried out until no more entries are available, then
+ * no need to close the iterator. Otherwise, it is recommended to close it so
+ * as to release the internal handle.
+ *
+ * @author <a href="mailto:d...@mina.apache.org";>Apache MINA SSHD Project</a>
+ */
+public class SftpDirEntryIterator extends AbstractLoggingBean implements 
Iterator<DirEntry>, Channel {
+    private final AtomicReference<Boolean> eolIndicator = new 
AtomicReference<>();
+    private final AtomicBoolean open = new AtomicBoolean(true);
+    private final SftpClient client;
+    private final String dirPath;
+    private final boolean closeOnFinished;
+    private Handle dirHandle;
+    private List<DirEntry> dirEntries;
+    private int index;
+
+    /**
+     * @param client The {@link SftpClient} instance to use for the iteration
+     * @param path The remote directory path
+     * @throws IOException If failed to gain access to the remote directory 
path
+     */
+    public SftpDirEntryIterator(SftpClient client, String path) throws 
IOException {
+        this(client, path, client.openDir(path), true);
+    }
+
+    /**
+     * @param client The {@link SftpClient} instance to use for the iteration
+     * @param dirHandle The directory {@link Handle} to use for listing the 
entries
+     */
+    public SftpDirEntryIterator(SftpClient client, Handle dirHandle) {
+        this(client, Objects.toString(dirHandle, null), dirHandle, false);
+    }
+
+    /**
+     * @param client The {@link SftpClient} instance to use for the iteration
+     * @param path A hint as to the remote directory path - used only for 
logging
+     * @param dirHandle The directory {@link Handle} to use for listing the 
entries
+     * @param closeOnFinished If {@code true} then close the directory handle 
when
+     * all entries have been exhausted
+     */
+    public SftpDirEntryIterator(SftpClient client, String path, Handle 
dirHandle, boolean closeOnFinished) {
+        this.client = Objects.requireNonNull(client, "No SFTP client 
instance");
+        this.dirPath = ValidateUtils.checkNotNullAndNotEmpty(path, "No path");
+        this.dirHandle = Objects.requireNonNull(dirHandle, "No directory 
handle");
+        this.closeOnFinished = closeOnFinished;
+        this.dirEntries = load(dirHandle);
+    }
+
+    /**
+     * The client instance
+     *
+     * @return {@link SftpClient} instance used to access the remote folder
+     */
+    public final SftpClient getClient() {
+        return client;
+    }
+
+    /**
+     * The remotely accessed directory path
+     *
+     * @return Remote directory hint - may be the handle's value if accessed 
directly
+     * via a {@link Handle} instead of via a path - used only for logging
+     */
+    public final String getPath() {
+        return dirPath;
+    }
+
+    /**
+     * @return The directory {@link Handle} used to access the remote directory
+     */
+    public final Handle getHandle() {
+        return dirHandle;
+    }
+
+    @Override
+    public boolean hasNext() {
+        return (dirEntries != null) && (index < dirEntries.size());
+    }
+
+    @Override
+    public DirEntry next() {
+        DirEntry entry = dirEntries.get(index++);
+        if (index >= dirEntries.size()) {
+            index = 0;
+
+            try {
+                dirEntries = load(getHandle());
+            } catch (RuntimeException e) {
+                dirEntries = null;
+                throw e;
+            }
+        }
+
+        return entry;
+    }
+
+    @Override
+    public boolean isOpen() {
+        return open.get();
+    }
+
+    public boolean isCloseOnFinished() {
+        return closeOnFinished;
+    }
+
+    @Override
+    public void close() throws IOException {
+        if (open.getAndSet(false)) {
+            Handle handle = getHandle();
+            if ((handle instanceof Closeable) && isCloseOnFinished()) {
+                if (log.isDebugEnabled()) {
+                    log.debug("close(" + getPath() + ") handle=" + handle);
+                }
+                ((Closeable) handle).close();
+            }
+        }
+    }
+
+    protected List<DirEntry> load(Handle handle) {
+        try {
+            // check if previous call yielded an end-of-list indication
+            Boolean eolReached = eolIndicator.getAndSet(null);
+            if ((eolReached != null) && eolReached) {
+                if (log.isTraceEnabled()) {
+                    log.trace("load({})[{}] exhausted all entries on previous 
call", getPath(), handle);
+                }
+                return null;
+            }
+
+            List<DirEntry> entries = client.readDir(handle, eolIndicator);
+            eolReached = eolIndicator.get();
+            if ((entries == null) || ((eolReached != null) && eolReached)) {
+                if (log.isTraceEnabled()) {
+                    log.trace("load({})[{}] exhausted all entries - EOL={}", 
getPath(), handle, eolReached);
+                }
+                close();
+            }
+
+            return entries;
+        } catch (IOException e) {
+            try {
+                close();
+            } catch (IOException t) {
+                if (log.isTraceEnabled()) {
+                    log.trace(t.getClass().getSimpleName() + " while close 
handle=" + handle
+                            + " due to " + e.getClass().getSimpleName() + " [" 
+ e.getMessage() + "]"
+                            + ": " + t.getMessage());
+                }
+            }
+            throw new RuntimeException(e);
+        }
+    }
+
+    @Override
+    public void remove() {
+        throw new UnsupportedOperationException("readDir(" + getPath() + ")[" 
+ getHandle() + "] Iterator#remove() N/A");
+    }
+}
\ No newline at end of file

Reply via email to