You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@mina.apache.org by lg...@apache.org on 2018/04/25 05:01:23 UTC

[2/8] mina-sshd git commit: [SSHD-818] Split SCP code (client + server) to its own module

http://git-wip-us.apache.org/repos/asf/mina-sshd/blob/af415e5f/sshd-scp/src/main/java/org/apache/sshd/server/scp/ScpCommandFactory.java
----------------------------------------------------------------------
diff --git a/sshd-scp/src/main/java/org/apache/sshd/server/scp/ScpCommandFactory.java b/sshd-scp/src/main/java/org/apache/sshd/server/scp/ScpCommandFactory.java
new file mode 100644
index 0000000..99e3e34
--- /dev/null
+++ b/sshd-scp/src/main/java/org/apache/sshd/server/scp/ScpCommandFactory.java
@@ -0,0 +1,272 @@
+/*
+ * 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.server.scp;
+
+import java.util.Collection;
+import java.util.concurrent.CopyOnWriteArraySet;
+import java.util.concurrent.ExecutorService;
+
+import org.apache.sshd.common.scp.ScpFileOpener;
+import org.apache.sshd.common.scp.ScpFileOpenerHolder;
+import org.apache.sshd.common.scp.ScpHelper;
+import org.apache.sshd.common.scp.ScpTransferEventListener;
+import org.apache.sshd.common.util.EventListenerUtils;
+import org.apache.sshd.common.util.ObjectBuilder;
+import org.apache.sshd.common.util.threads.ExecutorServiceConfigurer;
+import org.apache.sshd.server.Command;
+import org.apache.sshd.server.CommandFactory;
+
+/**
+ * This <code>CommandFactory</code> can be used as a standalone command factory
+ * or can be used to augment another <code>CommandFactory</code> and provides
+ * <code>SCP</code> support.
+ *
+ * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
+ * @see ScpCommand
+ */
+public class ScpCommandFactory
+        implements ScpFileOpenerHolder,
+        CommandFactory,
+        Cloneable,
+        ExecutorServiceConfigurer {
+    /**
+     * A useful {@link ObjectBuilder} for {@link ScpCommandFactory}
+     */
+    public static class Builder implements ObjectBuilder<ScpCommandFactory> {
+        private final ScpCommandFactory factory = new ScpCommandFactory();
+
+        public Builder() {
+            super();
+        }
+
+        public Builder withFileOpener(ScpFileOpener opener) {
+            factory.setScpFileOpener(opener);
+            return this;
+        }
+
+        public Builder withDelegate(CommandFactory delegate) {
+            factory.setDelegateCommandFactory(delegate);
+            return this;
+        }
+
+        public Builder withExecutorService(ExecutorService service) {
+            factory.setExecutorService(service);
+            return this;
+        }
+
+        public Builder withShutdownOnExit(boolean shutdown) {
+            factory.setShutdownOnExit(shutdown);
+            return this;
+        }
+
+        public Builder withSendBufferSize(int sendSize) {
+            factory.setSendBufferSize(sendSize);
+            return this;
+        }
+
+        public Builder withReceiveBufferSize(int receiveSize) {
+            factory.setReceiveBufferSize(receiveSize);
+            return this;
+        }
+
+        public Builder addEventListener(ScpTransferEventListener listener) {
+            factory.addEventListener(listener);
+            return this;
+        }
+
+        public Builder removeEventListener(ScpTransferEventListener listener) {
+            factory.removeEventListener(listener);
+            return this;
+        }
+
+        @Override
+        public ScpCommandFactory build() {
+            return factory.clone();
+        }
+    }
+
+    /*
+     * NOTE: we expose setters since there is no problem to change these settings between
+     * successive invocations of the 'createCommand' method
+     */
+    private CommandFactory delegate;
+    private ExecutorService executors;
+    private boolean shutdownExecutor;
+    private ScpFileOpener fileOpener;
+    private int sendBufferSize = ScpHelper.MIN_SEND_BUFFER_SIZE;
+    private int receiveBufferSize = ScpHelper.MIN_RECEIVE_BUFFER_SIZE;
+    private Collection<ScpTransferEventListener> listeners = new CopyOnWriteArraySet<>();
+    private ScpTransferEventListener listenerProxy;
+
+    public ScpCommandFactory() {
+        listenerProxy = EventListenerUtils.proxyWrapper(ScpTransferEventListener.class, getClass().getClassLoader(), listeners);
+    }
+
+    @Override
+    public ScpFileOpener getScpFileOpener() {
+        return fileOpener;
+    }
+
+    @Override
+    public void setScpFileOpener(ScpFileOpener fileOpener) {
+        this.fileOpener = fileOpener;
+    }
+
+    public CommandFactory getDelegateCommandFactory() {
+        return delegate;
+    }
+
+    /**
+     * @param factory A {@link CommandFactory} to be used if the
+     * command is not an SCP one. If {@code null} then an {@link IllegalArgumentException}
+     * will be thrown when attempting to invoke {@link #createCommand(String)}
+     * with a non-SCP command
+     */
+    public void setDelegateCommandFactory(CommandFactory factory) {
+        delegate = factory;
+    }
+
+    @Override
+    public ExecutorService getExecutorService() {
+        return executors;
+    }
+
+    /**
+     * @param service An {@link ExecutorService} to be used when
+     * starting {@link ScpCommand} execution. If {@code null} then a single-threaded
+     * ad-hoc service is used. <B>Note:</B> the service will <U>not</U> be shutdown
+     * when the command is terminated - unless it is the ad-hoc service, which will be
+     * shutdown regardless
+     */
+    @Override
+    public void setExecutorService(ExecutorService service) {
+        executors = service;
+    }
+
+    @Override
+    public boolean isShutdownOnExit() {
+        return shutdownExecutor;
+    }
+
+    @Override
+    public void setShutdownOnExit(boolean shutdown) {
+        shutdownExecutor = shutdown;
+    }
+
+    public int getSendBufferSize() {
+        return sendBufferSize;
+    }
+
+    /**
+     * @param sendSize Size (in bytes) of buffer to use when sending files
+     * @see ScpHelper#MIN_SEND_BUFFER_SIZE
+     */
+    public void setSendBufferSize(int sendSize) {
+        if (sendSize < ScpHelper.MIN_SEND_BUFFER_SIZE) {
+            throw new IllegalArgumentException("<ScpCommandFactory>() send buffer size "
+                    + "(" + sendSize + ") below minimum required (" + ScpHelper.MIN_SEND_BUFFER_SIZE + ")");
+        }
+        sendBufferSize = sendSize;
+    }
+
+    public int getReceiveBufferSize() {
+        return receiveBufferSize;
+    }
+
+    /**
+     * @param receiveSize Size (in bytes) of buffer to use when receiving files
+     * @see ScpHelper#MIN_RECEIVE_BUFFER_SIZE
+     */
+    public void setReceiveBufferSize(int receiveSize) {
+        if (receiveSize < ScpHelper.MIN_RECEIVE_BUFFER_SIZE) {
+            throw new IllegalArgumentException("<ScpCommandFactory>() receive buffer size "
+                    + "(" + receiveSize + ") below minimum required (" + ScpHelper.MIN_RECEIVE_BUFFER_SIZE + ")");
+        }
+        receiveBufferSize = receiveSize;
+    }
+
+    /**
+     * @param listener The {@link ScpTransferEventListener} to add
+     * @return {@code true} if this is a <U>new</U> listener instance,
+     * {@code false} if the listener is already registered
+     * @throws IllegalArgumentException if {@code null} listener
+     */
+    public boolean addEventListener(ScpTransferEventListener listener) {
+        if (listener == null) {
+            throw new IllegalArgumentException("No listener instance");
+        }
+
+        return listeners.add(listener);
+    }
+
+    /**
+     * @param listener The {@link ScpTransferEventListener} to remove
+     * @return {@code true} if the listener was registered and removed,
+     * {@code false} if the listener was not registered to begin with
+     * @throws IllegalArgumentException if {@code null} listener
+     */
+    public boolean removeEventListener(ScpTransferEventListener listener) {
+        if (listener == null) {
+            throw new IllegalArgumentException("No listener instance");
+        }
+
+        return listeners.remove(listener);
+    }
+
+    /**
+     * Parses a command string and verifies that the basic syntax is
+     * correct. If parsing fails the responsibility is delegated to
+     * the configured {@link CommandFactory} instance; if one exist.
+     *
+     * @param command command to parse
+     * @return configured {@link Command} instance
+     * @throws IllegalArgumentException if not an SCP command and no
+     *                                  delegate command factory is available
+     * @see ScpHelper#SCP_COMMAND_PREFIX
+     */
+    @Override
+    public Command createCommand(String command) {
+        if (command.startsWith(ScpHelper.SCP_COMMAND_PREFIX)) {
+            return new ScpCommand(command,
+                    getExecutorService(), isShutdownOnExit(),
+                    getSendBufferSize(), getReceiveBufferSize(),
+                    getScpFileOpener(), listenerProxy);
+        }
+
+        CommandFactory factory = getDelegateCommandFactory();
+        if (factory != null) {
+            return factory.createCommand(command);
+        }
+
+        throw new IllegalArgumentException("Unknown command, does not begin with '" + ScpHelper.SCP_COMMAND_PREFIX + "': " + command);
+    }
+
+    @Override
+    public ScpCommandFactory clone() {
+        try {
+            ScpCommandFactory other = getClass().cast(super.clone());
+            // clone the listeners set as well
+            other.listeners = new CopyOnWriteArraySet<>(this.listeners);
+            other.listenerProxy = EventListenerUtils.proxyWrapper(ScpTransferEventListener.class, getClass().getClassLoader(), other.listeners);
+            return other;
+        } catch (CloneNotSupportedException e) {
+            throw new RuntimeException(e);    // un-expected...
+        }
+    }
+}

http://git-wip-us.apache.org/repos/asf/mina-sshd/blob/af415e5f/sshd-scp/src/test/java/org/apache/sshd/client/scp/ScpTest.java
----------------------------------------------------------------------
diff --git a/sshd-scp/src/test/java/org/apache/sshd/client/scp/ScpTest.java b/sshd-scp/src/test/java/org/apache/sshd/client/scp/ScpTest.java
new file mode 100644
index 0000000..7b7d2e8
--- /dev/null
+++ b/sshd-scp/src/test/java/org/apache/sshd/client/scp/ScpTest.java
@@ -0,0 +1,1203 @@
+/*
+ * 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.scp;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.channels.FileChannel;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.OpenOption;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.nio.file.attribute.PosixFilePermission;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Date;
+import java.util.EnumSet;
+import java.util.Set;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import com.jcraft.jsch.ChannelExec;
+import com.jcraft.jsch.JSch;
+import com.jcraft.jsch.JSchException;
+
+import org.apache.sshd.client.SshClient;
+import org.apache.sshd.client.session.ClientSession;
+import org.apache.sshd.common.Factory;
+import org.apache.sshd.common.channel.Channel;
+import org.apache.sshd.common.file.FileSystemFactory;
+import org.apache.sshd.common.file.virtualfs.VirtualFileSystemFactory;
+import org.apache.sshd.common.random.Random;
+import org.apache.sshd.common.scp.ScpException;
+import org.apache.sshd.common.scp.ScpFileOpener;
+import org.apache.sshd.common.scp.ScpHelper;
+import org.apache.sshd.common.scp.ScpTransferEventListener;
+import org.apache.sshd.common.scp.helpers.DefaultScpFileOpener;
+import org.apache.sshd.common.session.Session;
+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.server.Command;
+import org.apache.sshd.server.ExitCallback;
+import org.apache.sshd.server.SshServer;
+import org.apache.sshd.server.scp.ScpCommand;
+import org.apache.sshd.server.scp.ScpCommandFactory;
+import org.apache.sshd.util.test.BaseTestSupport;
+import org.apache.sshd.util.test.JSchLogger;
+import org.apache.sshd.util.test.SimpleUserInfo;
+import org.apache.sshd.util.test.Utils;
+import org.junit.AfterClass;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.FixMethodOrder;
+import org.junit.Ignore;
+import org.junit.Test;
+import org.junit.runners.MethodSorters;
+
+import ch.ethz.ssh2.Connection;
+import ch.ethz.ssh2.ConnectionInfo;
+import ch.ethz.ssh2.SCPClient;
+
+/**
+ * Test for SCP support.
+ *
+ * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
+ */
+@FixMethodOrder(MethodSorters.NAME_ASCENDING)
+public class ScpTest extends BaseTestSupport {
+    private static final ScpTransferEventListener DEBUG_LISTENER = new ScpTransferEventListener() {
+        @Override
+        public void startFolderEvent(FileOperation op, Path file, Set<PosixFilePermission> perms) {
+            logEvent("starFolderEvent", op, file, false, -1L, perms, null);
+        }
+
+        @Override
+        public void startFileEvent(FileOperation op, Path file, long length, Set<PosixFilePermission> perms) {
+            logEvent("startFileEvent", op, file, true, length, perms, null);
+
+        }
+
+        @Override
+        public void endFolderEvent(FileOperation op, Path file, Set<PosixFilePermission> perms, Throwable thrown) {
+            logEvent("endFolderEvent", op, file, false, -1L, perms, thrown);
+        }
+
+        @Override
+        public void endFileEvent(FileOperation op, Path file, long length, Set<PosixFilePermission> perms, Throwable thrown) {
+            logEvent("endFileEvent", op, file, true, length, perms, thrown);
+        }
+
+        private void logEvent(String type, FileOperation op, Path path, boolean isFile, long length, Collection<PosixFilePermission> perms, Throwable t) {
+            if (!OUTPUT_DEBUG_MESSAGES) {
+                return; // just in case
+            }
+            StringBuilder sb = new StringBuilder(Byte.MAX_VALUE);
+            sb.append('\t').append(type)
+                    .append('[').append(op).append(']')
+                    .append(' ').append(isFile ? "File" : "Directory").append('=').append(path)
+                    .append(' ').append("length=").append(length)
+                    .append(' ').append("perms=").append(perms);
+            if (t != null) {
+                sb.append(' ').append("ERROR=").append(t.getClass().getSimpleName()).append(": ").append(t.getMessage());
+            }
+            outputDebugMessage(sb.toString());
+        }
+    };
+
+    private static SshServer sshd;
+    private static int port;
+    private static SshClient client;
+    private final FileSystemFactory fileSystemFactory;
+
+    public ScpTest() throws IOException {
+        Path targetPath = detectTargetFolder();
+        Path parentPath = targetPath.getParent();
+        fileSystemFactory = new VirtualFileSystemFactory(parentPath);
+    }
+
+    @BeforeClass
+    public static void setupClientAndServer() throws Exception {
+        JSchLogger.init();
+        sshd = Utils.setupTestServer(ScpTest.class);
+        sshd.setCommandFactory(new ScpCommandFactory());
+        sshd.start();
+        port = sshd.getPort();
+
+        client = Utils.setupTestClient(ScpTest.class);
+        client.start();
+    }
+
+    @AfterClass
+    public static void tearDownClientAndServer() throws Exception {
+        if (sshd != null) {
+            try {
+                sshd.stop(true);
+            } finally {
+                sshd = null;
+            }
+        }
+
+        if (client != null) {
+            try {
+                client.stop();
+            } finally {
+                client = null;
+            }
+        }
+    }
+
+    @Before
+    public void setUp() throws Exception {
+        sshd.setFileSystemFactory(fileSystemFactory);
+    }
+
+    @Test
+    public void testNormalizedScpRemotePaths() throws Exception {
+        Path targetPath = detectTargetFolder();
+        Path parentPath = targetPath.getParent();
+        Path scpRoot = Utils.resolve(targetPath, ScpHelper.SCP_COMMAND_PREFIX, getClass().getSimpleName(), getCurrentTestName());
+        Utils.deleteRecursive(scpRoot);
+
+        Path localDir = assertHierarchyTargetFolderExists(scpRoot.resolve("local"));
+        Path localFile = localDir.resolve("file.txt");
+        byte[] data = Utils.writeFile(localFile, getClass().getName() + "#" + getCurrentTestName() + IoUtils.EOL);
+
+        Path remoteDir = assertHierarchyTargetFolderExists(scpRoot.resolve("remote"));
+        Path remoteFile = remoteDir.resolve(localFile.getFileName().toString());
+        String localPath = localFile.toString();
+        String remotePath = Utils.resolveRelativeRemotePath(parentPath, remoteFile);
+        String[] remoteComps = GenericUtils.split(remotePath, '/');
+        Factory<? extends Random> factory = client.getRandomFactory();
+        Random rnd = factory.create();
+        try (ClientSession session = client.connect(getCurrentTestName(), TEST_LOCALHOST, port).verify(7L, TimeUnit.SECONDS).getSession()) {
+            session.addPasswordIdentity(getCurrentTestName());
+            session.auth().verify(5L, TimeUnit.SECONDS);
+
+            ScpClient scp = createScpClient(session);
+            StringBuilder sb = new StringBuilder(remotePath.length() + Long.SIZE);
+            for (int i = 0; i < Math.max(Long.SIZE, remoteComps.length); i++) {
+                if (sb.length() > 0) {
+                    sb.setLength(0);    // start again
+                }
+
+                sb.append(remoteComps[0]);
+                for (int j = 1; j < remoteComps.length; j++) {
+                    String name = remoteComps[j];
+                    slashify(sb, rnd);
+                    sb.append(name);
+                }
+                slashify(sb, rnd);
+
+                String path = sb.toString();
+                scp.upload(localPath, path);
+                assertTrue("Remote file not ready for " + path, waitForFile(remoteFile, data.length, TimeUnit.SECONDS.toMillis(5L)));
+
+                byte[] actual = Files.readAllBytes(remoteFile);
+                assertArrayEquals("Mismatched uploaded data for " + path, data, actual);
+                Files.delete(remoteFile);
+                assertFalse("Remote file (" + remoteFile + ") not deleted for " + path, Files.exists(remoteFile));
+            }
+        }
+    }
+
+    private static int slashify(StringBuilder sb, Random rnd) {
+        int slashes = 1 /* at least one slash */ + rnd.random(Byte.SIZE);
+        for (int k = 0; k < slashes; k++) {
+            sb.append('/');
+        }
+
+        return slashes;
+    }
+
+    @Test
+    public void testUploadAbsoluteDriveLetter() throws Exception {
+        Path targetPath = detectTargetFolder();
+        Path parentPath = targetPath.getParent();
+        Path scpRoot = Utils.resolve(targetPath, ScpHelper.SCP_COMMAND_PREFIX, getClass().getSimpleName(), getCurrentTestName());
+        Utils.deleteRecursive(scpRoot);
+
+        Path localDir = assertHierarchyTargetFolderExists(scpRoot.resolve("local"));
+        Path localFile = localDir.resolve("file-1.txt");
+        byte[] data = Utils.writeFile(localFile, getClass().getName() + "#" + getCurrentTestName() + IoUtils.EOL);
+
+        Path remoteDir = assertHierarchyTargetFolderExists(scpRoot.resolve("remote"));
+        Path remoteFile = remoteDir.resolve(localFile.getFileName().toString());
+        String localPath = localFile.toString();
+        String remotePath = Utils.resolveRelativeRemotePath(parentPath, remoteFile);
+        try (ClientSession session = client.connect(getCurrentTestName(), TEST_LOCALHOST, port).verify(7L, TimeUnit.SECONDS).getSession()) {
+            session.addPasswordIdentity(getCurrentTestName());
+            session.auth().verify(5L, TimeUnit.SECONDS);
+
+            ScpClient scp = createScpClient(session);
+            scp.upload(localPath, remotePath);
+            assertFileLength(remoteFile, data.length, TimeUnit.SECONDS.toMillis(5L));
+
+            Path secondRemote = remoteDir.resolve("file-2.txt");
+            String secondPath = Utils.resolveRelativeRemotePath(parentPath, secondRemote);
+            scp.upload(localPath, secondPath);
+            assertFileLength(secondRemote, data.length, TimeUnit.SECONDS.toMillis(5L));
+
+            Path pathRemote = remoteDir.resolve("file-path.txt");
+            String pathPath = Utils.resolveRelativeRemotePath(parentPath, pathRemote);
+            scp.upload(localFile, pathPath);
+            assertFileLength(pathRemote, data.length, TimeUnit.SECONDS.toMillis(5L));
+        }
+    }
+
+    @Test
+    public void testScpUploadOverwrite() throws Exception {
+        try (ClientSession session = client.connect(getCurrentTestName(), TEST_LOCALHOST, port).verify(7L, TimeUnit.SECONDS).getSession()) {
+            session.addPasswordIdentity(getCurrentTestName());
+            session.auth().verify(5L, TimeUnit.SECONDS);
+
+            ScpClient scp = createScpClient(session);
+            String data = getClass().getName() + "#" + getCurrentTestName() + IoUtils.EOL;
+
+            Path targetPath = detectTargetFolder();
+            Path parentPath = targetPath.getParent();
+            Path scpRoot = Utils.resolve(targetPath, ScpHelper.SCP_COMMAND_PREFIX, getClass().getSimpleName(), getCurrentTestName());
+            Utils.deleteRecursive(scpRoot);
+
+            Path localDir = assertHierarchyTargetFolderExists(scpRoot.resolve("local"));
+            Path localFile = localDir.resolve("file.txt");
+            Utils.writeFile(localFile, data);
+
+            Path remoteDir = assertHierarchyTargetFolderExists(scpRoot.resolve("remote"));
+            Path remoteFile = remoteDir.resolve(localFile.getFileName());
+            Utils.writeFile(remoteFile, data + data);
+
+            String remotePath = Utils.resolveRelativeRemotePath(parentPath, remoteFile);
+            scp.upload(localFile.toString(), remotePath);
+            assertFileLength(remoteFile, data.length(), TimeUnit.SECONDS.toMillis(5L));
+        }
+    }
+
+    @Test
+    public void testScpUploadZeroLengthFile() throws Exception {
+        Path targetPath = detectTargetFolder();
+        Path scpRoot = Utils.resolve(targetPath, ScpHelper.SCP_COMMAND_PREFIX, getClass().getSimpleName(), getCurrentTestName());
+        Path localDir = assertHierarchyTargetFolderExists(scpRoot.resolve("local"));
+        Path remoteDir = assertHierarchyTargetFolderExists(scpRoot.resolve("remote"));
+        Path zeroLocal = localDir.resolve("zero.txt");
+
+        try (FileChannel fch = FileChannel.open(zeroLocal, StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)) {
+            if (fch.size() > 0L) {
+                fch.truncate(0L);
+            }
+        }
+        assertEquals("Non-zero size for local file=" + zeroLocal, 0L, Files.size(zeroLocal));
+
+        Path zeroRemote = remoteDir.resolve(zeroLocal.getFileName());
+        if (Files.exists(zeroRemote)) {
+            Files.delete(zeroRemote);
+        }
+
+        try (ClientSession session = client.connect(getCurrentTestName(), TEST_LOCALHOST, port).verify(7L, TimeUnit.SECONDS).getSession()) {
+            session.addPasswordIdentity(getCurrentTestName());
+            session.auth().verify(5L, TimeUnit.SECONDS);
+
+            ScpClient scp = createScpClient(session);
+            String remotePath = Utils.resolveRelativeRemotePath(targetPath.getParent(), zeroRemote);
+            scp.upload(zeroLocal.toString(), remotePath);
+            assertFileLength(zeroRemote, 0L, TimeUnit.SECONDS.toMillis(5L));
+        }
+    }
+
+    @Test
+    public void testScpDownloadZeroLengthFile() throws Exception {
+        Path targetPath = detectTargetFolder();
+        Path scpRoot = Utils.resolve(targetPath, ScpHelper.SCP_COMMAND_PREFIX, getClass().getSimpleName(), getCurrentTestName());
+        Path localDir = assertHierarchyTargetFolderExists(scpRoot.resolve("local"));
+        Path remoteDir = assertHierarchyTargetFolderExists(scpRoot.resolve("remote"));
+        Path zeroLocal = localDir.resolve(getCurrentTestName());
+        if (Files.exists(zeroLocal)) {
+            Files.delete(zeroLocal);
+        }
+
+        Path zeroRemote = remoteDir.resolve(zeroLocal.getFileName());
+        try (FileChannel fch = FileChannel.open(zeroRemote, StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)) {
+            if (fch.size() > 0L) {
+                fch.truncate(0L);
+            }
+        }
+        assertEquals("Non-zero size for remote file=" + zeroRemote, 0L, Files.size(zeroRemote));
+
+        try (ClientSession session = client.connect(getCurrentTestName(), TEST_LOCALHOST, port).verify(7L, TimeUnit.SECONDS).getSession()) {
+            session.addPasswordIdentity(getCurrentTestName());
+            session.auth().verify(5L, TimeUnit.SECONDS);
+
+            ScpClient scp = createScpClient(session);
+            String remotePath = Utils.resolveRelativeRemotePath(targetPath.getParent(), zeroRemote);
+            scp.download(remotePath, zeroLocal.toString());
+            assertFileLength(zeroLocal, 0L, TimeUnit.SECONDS.toMillis(5L));
+        }
+    }
+
+    @Test
+    @Ignore("TODO investigate why this fails often")
+    public void testScpNativeOnSingleFile() throws Exception {
+        String data = getClass().getName() + "#" + getCurrentTestName() + IoUtils.EOL;
+
+        Path targetPath = detectTargetFolder();
+        Path parentPath = targetPath.getParent();
+        Path scpRoot = Utils.resolve(targetPath, ScpHelper.SCP_COMMAND_PREFIX, getClass().getSimpleName(), getCurrentTestName());
+        Utils.deleteRecursive(scpRoot);
+
+        Path localDir = assertHierarchyTargetFolderExists(scpRoot.resolve("local"));
+        Path localOutFile = localDir.resolve("file-1.txt");
+        Path remoteDir = scpRoot.resolve("remote");
+        Path remoteOutFile = remoteDir.resolve(localOutFile.getFileName());
+
+        try (ClientSession session = client.connect(getCurrentTestName(), TEST_LOCALHOST, port).verify(13L, TimeUnit.SECONDS).getSession()) {
+            session.addPasswordIdentity(getCurrentTestName());
+            session.auth().verify(11L, TimeUnit.SECONDS);
+
+            ScpClient scp = createScpClient(session);
+            Utils.writeFile(localOutFile, data);
+
+            assertFalse("Remote folder already exists: " + remoteDir, Files.exists(remoteDir));
+
+            String localOutPath = localOutFile.toString();
+            String remoteOutPath = Utils.resolveRelativeRemotePath(parentPath, remoteOutFile);
+            outputDebugMessage("Expect upload failure %s => %s", localOutPath, remoteOutPath);
+            try {
+                scp.upload(localOutPath, remoteOutPath);
+                fail("Expected IOException for 1st time " + remoteOutPath);
+            } catch (IOException e) {
+                // ok
+            }
+
+            assertHierarchyTargetFolderExists(remoteDir);
+            outputDebugMessage("Expect upload success %s => %s", localOutPath, remoteOutPath);
+            scp.upload(localOutPath, remoteOutPath);
+            assertFileLength(remoteOutFile, data.length(), TimeUnit.SECONDS.toMillis(11L));
+
+            Path secondLocal = localDir.resolve(localOutFile.getFileName());
+            String downloadTarget = Utils.resolveRelativeRemotePath(parentPath, secondLocal);
+            outputDebugMessage("Expect download success %s => %s", remoteOutPath, downloadTarget);
+            scp.download(remoteOutPath, downloadTarget);
+            assertFileLength(secondLocal, data.length(), TimeUnit.SECONDS.toMillis(11L));
+
+            Path localPath = localDir.resolve("file-path.txt");
+            downloadTarget = Utils.resolveRelativeRemotePath(parentPath, localPath);
+            outputDebugMessage("Expect download success %s => %s", remoteOutPath, downloadTarget);
+            scp.download(remoteOutPath, downloadTarget);
+            assertFileLength(localPath, data.length(), TimeUnit.SECONDS.toMillis(11L));
+        }
+    }
+
+    @Test
+    public void testScpNativeOnMultipleFiles() throws Exception {
+        try (ClientSession session = client.connect(getCurrentTestName(), TEST_LOCALHOST, port).verify(13L, TimeUnit.SECONDS).getSession()) {
+            session.addPasswordIdentity(getCurrentTestName());
+            session.auth().verify(11L, TimeUnit.SECONDS);
+
+            ScpClient scp = createScpClient(session);
+            Path targetPath = detectTargetFolder();
+            Path parentPath = targetPath.getParent();
+            Path scpRoot = Utils.resolve(targetPath, ScpHelper.SCP_COMMAND_PREFIX, getClass().getSimpleName(), getCurrentTestName());
+            Utils.deleteRecursive(scpRoot);
+
+            Path localDir = assertHierarchyTargetFolderExists(scpRoot.resolve("local"));
+            Path local1 = localDir.resolve("file-1.txt");
+            byte[] data = Utils.writeFile(local1, getClass().getName() + "#" + getCurrentTestName() + IoUtils.EOL);
+
+            Path local2 = localDir.resolve("file-2.txt");
+            Files.write(local2, data);
+
+            Path remoteDir = assertHierarchyTargetFolderExists(scpRoot.resolve("remote"));
+            Path remote1 = remoteDir.resolve(local1.getFileName());
+            String remote1Path = Utils.resolveRelativeRemotePath(parentPath, remote1);
+            String[] locals = {local1.toString(), local2.toString()};
+            try {
+                scp.upload(locals, remote1Path);
+                fail("Unexpected upload success to missing remote file: " + remote1Path);
+            } catch (IOException e) {
+                // Ok
+            }
+
+            Files.write(remote1, data);
+            try {
+                scp.upload(locals, remote1Path);
+                fail("Unexpected upload success to existing remote file: " + remote1Path);
+            } catch (IOException e) {
+                // Ok
+            }
+
+            Path remoteSubDir = assertHierarchyTargetFolderExists(remoteDir.resolve("dir"));
+            scp.upload(locals, Utils.resolveRelativeRemotePath(parentPath, remoteSubDir));
+
+            Path remoteSub1 = remoteSubDir.resolve(local1.getFileName());
+            assertFileLength(remoteSub1, data.length, TimeUnit.SECONDS.toMillis(11L));
+
+            Path remoteSub2 = remoteSubDir.resolve(local2.getFileName());
+            assertFileLength(remoteSub2, data.length, TimeUnit.SECONDS.toMillis(11L));
+
+            String[] remotes = {
+                Utils.resolveRelativeRemotePath(parentPath, remoteSub1),
+                Utils.resolveRelativeRemotePath(parentPath, remoteSub2),
+            };
+
+            try {
+                scp.download(remotes, Utils.resolveRelativeRemotePath(parentPath, local1));
+                fail("Unexpected download success to existing local file: " + local1);
+            } catch (IOException e) {
+                // Ok
+            }
+
+            Path localSubDir = localDir.resolve("dir");
+            try {
+                scp.download(remotes, localSubDir);
+                fail("Unexpected download success to non-existing folder: " + localSubDir);
+            } catch (IOException e) {
+                // Ok
+            }
+
+            assertHierarchyTargetFolderExists(localSubDir);
+            scp.download(remotes, localSubDir);
+
+            assertFileLength(localSubDir.resolve(remoteSub1.getFileName()), data.length, TimeUnit.SECONDS.toMillis(11L));
+            assertFileLength(localSubDir.resolve(remoteSub2.getFileName()), data.length, TimeUnit.SECONDS.toMillis(11L));
+        }
+    }
+
+    @Test
+    public void testScpNativeOnRecursiveDirs() throws Exception {
+        try (ClientSession session = client.connect(getCurrentTestName(), TEST_LOCALHOST, port).verify(13L, TimeUnit.SECONDS).getSession()) {
+            session.addPasswordIdentity(getCurrentTestName());
+            session.auth().verify(11L, TimeUnit.SECONDS);
+
+            ScpClient scp = createScpClient(session);
+            Path targetPath = detectTargetFolder();
+            Path parentPath = targetPath.getParent();
+            Path scpRoot = Utils.resolve(targetPath, ScpHelper.SCP_COMMAND_PREFIX, getClass().getSimpleName(), getCurrentTestName());
+            Utils.deleteRecursive(scpRoot);
+
+            Path localDir = scpRoot.resolve("local");
+            Path localSubDir = assertHierarchyTargetFolderExists(localDir.resolve("dir"));
+            Path localSub1 = localSubDir.resolve("file-1.txt");
+            byte[] data = Utils.writeFile(localSub1, getClass().getName() + "#" + getCurrentTestName() + IoUtils.EOL);
+            Path localSub2 = localSubDir.resolve("file-2.txt");
+            Files.write(localSub2, data);
+
+            Path remoteDir = assertHierarchyTargetFolderExists(scpRoot.resolve("remote"));
+            scp.upload(localSubDir, Utils.resolveRelativeRemotePath(parentPath, remoteDir), ScpClient.Option.Recursive);
+
+            Path remoteSubDir = remoteDir.resolve(localSubDir.getFileName());
+            assertFileLength(remoteSubDir.resolve(localSub1.getFileName()), data.length, TimeUnit.SECONDS.toMillis(11L));
+            assertFileLength(remoteSubDir.resolve(localSub2.getFileName()), data.length, TimeUnit.SECONDS.toMillis(11L));
+
+            Utils.deleteRecursive(localSubDir);
+
+            scp.download(Utils.resolveRelativeRemotePath(parentPath, remoteSubDir), localDir, ScpClient.Option.Recursive);
+            assertFileLength(localSub1, data.length, TimeUnit.SECONDS.toMillis(11L));
+            assertFileLength(localSub2, data.length, TimeUnit.SECONDS.toMillis(11L));
+        }
+    }
+
+    @Test
+    public void testScpNativeOnDirWithPattern() throws Exception {
+        try (ClientSession session = client.connect(getCurrentTestName(), TEST_LOCALHOST, port).verify(13L, TimeUnit.SECONDS).getSession()) {
+            session.addPasswordIdentity(getCurrentTestName());
+            session.auth().verify(11L, TimeUnit.SECONDS);
+
+            ScpClient scp = createScpClient(session);
+            Path targetPath = detectTargetFolder();
+            Path parentPath = targetPath.getParent();
+            Path scpRoot = Utils.resolve(targetPath, ScpHelper.SCP_COMMAND_PREFIX, getClass().getSimpleName(), getCurrentTestName());
+            Utils.deleteRecursive(scpRoot);
+
+            Path localDir = assertHierarchyTargetFolderExists(scpRoot.resolve("local"));
+            Path local1 = localDir.resolve("file-1.txt");
+            byte[] data = Utils.writeFile(local1, getClass().getName() + "#" + getCurrentTestName() + IoUtils.EOL);
+            Path local2 = localDir.resolve("file-2.txt");
+            Files.write(local2, data);
+
+            Path remoteDir = assertHierarchyTargetFolderExists(scpRoot.resolve("remote"));
+            String remotePath = Utils.resolveRelativeRemotePath(parentPath, remoteDir);
+            scp.upload(localDir.toString() + File.separator + "*", remotePath);
+            assertFileLength(remoteDir.resolve(local1.getFileName()), data.length, TimeUnit.SECONDS.toMillis(11L));
+            assertFileLength(remoteDir.resolve(local2.getFileName()), data.length, TimeUnit.SECONDS.toMillis(11L));
+
+            Files.delete(local1);
+            Files.delete(local2);
+            scp.download(remotePath + "/*", localDir);
+            assertFileLength(local1, data.length, TimeUnit.SECONDS.toMillis(11L));
+            assertFileLength(local2, data.length, TimeUnit.SECONDS.toMillis(11L));
+        }
+    }
+
+    @Test
+    public void testScpNativeOnMixedDirAndFiles() throws Exception {
+        try (ClientSession session = client.connect(getCurrentTestName(), TEST_LOCALHOST, port).verify(13L, TimeUnit.SECONDS).getSession()) {
+            session.addPasswordIdentity(getCurrentTestName());
+            session.auth().verify(11L, TimeUnit.SECONDS);
+
+            ScpClient scp = createScpClient(session);
+            Path targetPath = detectTargetFolder();
+            Path parentPath = targetPath.getParent();
+            Path scpRoot = Utils.resolve(targetPath, ScpHelper.SCP_COMMAND_PREFIX, getClass().getSimpleName(), getCurrentTestName());
+            Utils.deleteRecursive(scpRoot);
+
+            Path localDir = scpRoot.resolve("local");
+            Path localSubDir = assertHierarchyTargetFolderExists(localDir.resolve("dir"));
+            Path local1 = localDir.resolve("file-1.txt");
+            byte[] data = Utils.writeFile(local1, getClass().getName() + "#" + getCurrentTestName() + IoUtils.EOL);
+            Path localSub2 = localSubDir.resolve("file-2.txt");
+            Files.write(localSub2, data);
+
+            Path remoteDir = assertHierarchyTargetFolderExists(scpRoot.resolve("remote"));
+            String remotePath = Utils.resolveRelativeRemotePath(parentPath, remoteDir);
+            scp.upload(localDir.toString() + File.separator + "*", remotePath, ScpClient.Option.Recursive);
+            assertFileLength(remoteDir.resolve(local1.getFileName()), data.length, TimeUnit.SECONDS.toMillis(11L));
+
+            Path remoteSubDir = remoteDir.resolve(localSubDir.getFileName());
+            assertFileLength(remoteSubDir.resolve(localSub2.getFileName()), data.length, TimeUnit.SECONDS.toMillis(11L));
+
+            Files.delete(local1);
+            Utils.deleteRecursive(localSubDir);
+
+            scp.download(remotePath + "/*", localDir);
+            assertFileLength(local1, data.length, TimeUnit.SECONDS.toMillis(11L));
+            assertFalse("Unexpected recursive local file: " + localSub2, Files.exists(localSub2));
+
+            Files.delete(local1);
+            scp.download(remotePath + "/*", localDir, ScpClient.Option.Recursive);
+            assertFileLength(local1, data.length, TimeUnit.SECONDS.toMillis(5L));
+            assertFileLength(localSub2, data.length, TimeUnit.SECONDS.toMillis(5L));
+        }
+    }
+
+    @Test
+    public void testScpNativePreserveAttributes() throws Exception {
+        try (ClientSession session = client.connect(getCurrentTestName(), TEST_LOCALHOST, port).verify(13L, TimeUnit.SECONDS).getSession()) {
+            session.addPasswordIdentity(getCurrentTestName());
+            session.auth().verify(11L, TimeUnit.SECONDS);
+
+            ScpClient scp = createScpClient(session);
+            Path targetPath = detectTargetFolder();
+            Path parentPath = targetPath.getParent();
+            Path scpRoot = Utils.resolve(targetPath, ScpHelper.SCP_COMMAND_PREFIX, getClass().getSimpleName(), getCurrentTestName());
+            Utils.deleteRecursive(scpRoot);
+
+            Path localDir = scpRoot.resolve("local");
+            Path localSubDir = assertHierarchyTargetFolderExists(localDir.resolve("dir"));
+            // convert everything to seconds since this is the SCP timestamps granularity
+            final long lastModMillis = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(1);
+            final long lastModSecs = TimeUnit.MILLISECONDS.toSeconds(lastModMillis);
+            Path local1 = localDir.resolve("file-1.txt");
+            byte[] data = Utils.writeFile(local1, getClass().getName() + "#" + getCurrentTestName() + IoUtils.EOL);
+
+            File lclFile1 = local1.toFile();
+            boolean lcl1ModSet = lclFile1.setLastModified(lastModMillis);
+            lclFile1.setExecutable(true, true);
+            lclFile1.setWritable(false, false);
+
+            Path localSub2 = localSubDir.resolve("file-2.txt");
+            Files.write(localSub2, data);
+            File lclSubFile2 = localSub2.toFile();
+            boolean lclSub2ModSet = lclSubFile2.setLastModified(lastModMillis);
+
+            Path remoteDir = assertHierarchyTargetFolderExists(scpRoot.resolve("remote"));
+            String remotePath = Utils.resolveRelativeRemotePath(parentPath, remoteDir);
+            scp.upload(localDir.toString() + File.separator + "*", remotePath, ScpClient.Option.Recursive, ScpClient.Option.PreserveAttributes);
+
+            Path remote1 = remoteDir.resolve(local1.getFileName());
+            assertFileLength(remote1, data.length, TimeUnit.SECONDS.toMillis(11L));
+
+            File remFile1 = remote1.toFile();
+            assertLastModifiedTimeEquals(remFile1, lcl1ModSet, lastModSecs);
+
+            Path remoteSubDir = remoteDir.resolve(localSubDir.getFileName());
+            Path remoteSub2 = remoteSubDir.resolve(localSub2.getFileName());
+            assertFileLength(remoteSub2, data.length, TimeUnit.SECONDS.toMillis(11L));
+
+            File remSubFile2 = remoteSub2.toFile();
+            assertLastModifiedTimeEquals(remSubFile2, lclSub2ModSet, lastModSecs);
+
+            Utils.deleteRecursive(localDir);
+            assertHierarchyTargetFolderExists(localDir);
+
+            scp.download(remotePath + "/*", localDir, ScpClient.Option.Recursive, ScpClient.Option.PreserveAttributes);
+            assertFileLength(local1, data.length, TimeUnit.SECONDS.toMillis(11L));
+            assertLastModifiedTimeEquals(lclFile1, lcl1ModSet, lastModSecs);
+            assertFileLength(localSub2, data.length, TimeUnit.SECONDS.toMillis(11L));
+            assertLastModifiedTimeEquals(lclSubFile2, lclSub2ModSet, lastModSecs);
+        }
+    }
+
+    @Test
+    public void testStreamsUploadAndDownload() throws Exception {
+        try (ClientSession session = client.connect(getCurrentTestName(), TEST_LOCALHOST, port).verify(13L, TimeUnit.SECONDS).getSession()) {
+            session.addPasswordIdentity(getCurrentTestName());
+            session.auth().verify(11L, TimeUnit.SECONDS);
+
+            ScpClient scp = createScpClient(session);
+            Path targetPath = detectTargetFolder();
+            Path parentPath = targetPath.getParent();
+            Path scpRoot = Utils.resolve(targetPath, ScpHelper.SCP_COMMAND_PREFIX, getClass().getSimpleName(), getCurrentTestName());
+            Utils.deleteRecursive(scpRoot);
+
+            Path remoteDir = assertHierarchyTargetFolderExists(scpRoot.resolve("remote"));
+            Path remoteFile = remoteDir.resolve("file.txt");
+            String remotePath = Utils.resolveRelativeRemotePath(parentPath, remoteFile);
+            byte[] data = (getClass().getName() + "#" + getCurrentTestName()).getBytes(StandardCharsets.UTF_8);
+            outputDebugMessage("Upload data to %s", remotePath);
+            scp.upload(data, remotePath, EnumSet.allOf(PosixFilePermission.class), null);
+            assertFileLength(remoteFile, data.length, TimeUnit.SECONDS.toMillis(11L));
+
+            byte[] uploaded = Files.readAllBytes(remoteFile);
+            assertArrayEquals("Mismatched uploaded data", data, uploaded);
+
+            outputDebugMessage("Download data from %s", remotePath);
+            byte[] downloaded = scp.downloadBytes(remotePath);
+            assertArrayEquals("Mismatched downloaded data", uploaded, downloaded);
+        }
+    }
+
+    @Test   // see SSHD-649
+    public void testScpFileOpener() throws Exception {
+        class TrackingFileOpener extends DefaultScpFileOpener {
+            private final AtomicInteger readCount = new AtomicInteger(0);
+            private final AtomicInteger writeCount = new AtomicInteger(0);
+
+            TrackingFileOpener() {
+                super();
+            }
+
+            public AtomicInteger getReadCount() {
+                return readCount;
+            }
+
+            public AtomicInteger getWriteCount() {
+                return writeCount;
+            }
+
+            @Override
+            public InputStream openRead(Session session, Path file, OpenOption... options) throws IOException {
+                int count = readCount.incrementAndGet();
+                outputDebugMessage("openRead(%s)[%s] count=%d", session, file, count);
+                return super.openRead(session, file, options);
+            }
+
+            @Override
+            public OutputStream openWrite(Session session, Path file, OpenOption... options) throws IOException {
+                int count = writeCount.incrementAndGet();
+                outputDebugMessage("openWrite(%s)[%s] count=%d", session, file, count);
+                return super.openWrite(session, file, options);
+            }
+        }
+
+        ScpCommandFactory factory = (ScpCommandFactory) sshd.getCommandFactory();
+        ScpFileOpener opener = factory.getScpFileOpener();
+        TrackingFileOpener serverOpener = new TrackingFileOpener();
+        factory.setScpFileOpener(serverOpener);
+        try (ClientSession session = client.connect(getCurrentTestName(), TEST_LOCALHOST, port).verify(13L, TimeUnit.SECONDS).getSession()) {
+            session.addPasswordIdentity(getCurrentTestName());
+            session.auth().verify(11L, TimeUnit.SECONDS);
+
+            TrackingFileOpener clientOpener = new TrackingFileOpener();
+            ScpClientCreator creator = ScpClientCreator.instance();
+            ScpClient scp = creator.createScpClient(session, clientOpener);
+
+            Path targetPath = detectTargetFolder();
+            Path parentPath = targetPath.getParent();
+            Path scpRoot = Utils.resolve(targetPath, ScpHelper.SCP_COMMAND_PREFIX, getClass().getSimpleName(), getCurrentTestName());
+            Utils.deleteRecursive(scpRoot);
+
+            Path remoteDir = assertHierarchyTargetFolderExists(scpRoot);
+            Path localFile = remoteDir.resolve("data.txt");
+            byte[] data = (getClass().getName() + "#" + getCurrentTestName()).getBytes(StandardCharsets.UTF_8);
+            Files.write(localFile, data);
+
+            Path remoteFile = remoteDir.resolve("upload.txt");
+            String remotePath = Utils.resolveRelativeRemotePath(parentPath, remoteFile);
+            outputDebugMessage("Upload data to %s", remotePath);
+            scp.upload(localFile, remotePath);
+            assertFileLength(remoteFile, data.length, TimeUnit.SECONDS.toMillis(11L));
+
+            AtomicInteger serverRead = serverOpener.getReadCount();
+            assertEquals("Mismatched server upload open read count", 0, serverRead.get());
+
+            AtomicInteger serverWrite = serverOpener.getWriteCount();
+            assertEquals("Mismatched server upload write count", 1, serverWrite.getAndSet(0));
+
+            AtomicInteger clientRead = clientOpener.getReadCount();
+            assertEquals("Mismatched client upload read count", 1, clientRead.getAndSet(0));
+
+            AtomicInteger clientWrite = clientOpener.getWriteCount();
+            assertEquals("Mismatched client upload write count", 0, clientWrite.get());
+
+            Files.delete(localFile);
+            scp.download(remotePath, localFile);
+            assertFileLength(localFile, data.length, TimeUnit.SECONDS.toMillis(11L));
+
+            assertEquals("Mismatched server download open read count", 1, serverRead.getAndSet(0));
+            assertEquals("Mismatched server download write count", 0, serverWrite.get());
+            assertEquals("Mismatched client download read count", 0, clientRead.get());
+            assertEquals("Mismatched client download write count", 1, clientWrite.getAndSet(0));
+        } finally {
+            factory.setScpFileOpener(opener);
+        }
+    }
+
+    @Test   // see SSHD-628
+    public void testScpExitStatusPropagation() throws Exception {
+        final int testExitValue = 7365;
+        class InternalScpCommand extends ScpCommand implements ExitCallback {
+            private ExitCallback delegate;
+
+            InternalScpCommand(String command, ExecutorService executorService, boolean shutdownOnExit,
+                    int sendSize, int receiveSize, ScpFileOpener opener, ScpTransferEventListener eventListener) {
+                super(command, executorService, shutdownOnExit, sendSize, receiveSize, opener, eventListener);
+            }
+
+            @Override
+            protected void writeCommandResponseMessage(String command, int exitValue, String exitMessage) throws IOException {
+                outputDebugMessage("writeCommandResponseMessage(%s) status=%d", command, exitValue);
+                super.writeCommandResponseMessage(command, testExitValue, exitMessage);
+            }
+
+            @Override
+            public void setExitCallback(ExitCallback callback) {
+                delegate = callback;
+                super.setExitCallback(this);
+            }
+
+            @Override
+            public void onExit(int exitValue) {
+                onExit(exitValue, Integer.toString(exitValue));
+            }
+
+            @Override
+            public void onExit(int exitValue, String exitMessage) {
+                outputDebugMessage("onExit(%s) status=%d", this, exitValue);
+                if (exitValue == ScpHelper.OK) {
+                    delegate.onExit(testExitValue, exitMessage);
+                } else {
+                    delegate.onExit(exitValue, exitMessage);
+                }
+            }
+        }
+
+        ScpCommandFactory factory = (ScpCommandFactory) sshd.getCommandFactory();
+        sshd.setCommandFactory(new ScpCommandFactory() {
+            @Override
+            public Command createCommand(String command) {
+                ValidateUtils.checkTrue(command.startsWith(ScpHelper.SCP_COMMAND_PREFIX), "Bad SCP command: %s", command);
+                return new InternalScpCommand(command,
+                        getExecutorService(), isShutdownOnExit(),
+                        getSendBufferSize(), getReceiveBufferSize(),
+                        DefaultScpFileOpener.INSTANCE,
+                        ScpTransferEventListener.EMPTY);
+            }
+        });
+
+        try (ClientSession session = client.connect(getCurrentTestName(), TEST_LOCALHOST, port).verify(13L, TimeUnit.SECONDS).getSession()) {
+            session.addPasswordIdentity(getCurrentTestName());
+            session.auth().verify(11L, TimeUnit.SECONDS);
+
+            ScpClientCreator creator = ScpClientCreator.instance();
+            ScpClient scp = creator.createScpClient(session);
+            Path targetPath = detectTargetFolder();
+            Path parentPath = targetPath.getParent();
+            Path scpRoot = Utils.resolve(targetPath, ScpHelper.SCP_COMMAND_PREFIX, getClass().getSimpleName(), getCurrentTestName());
+            Utils.deleteRecursive(scpRoot);
+
+            Path remoteDir = assertHierarchyTargetFolderExists(scpRoot.resolve("remote"));
+            Path remoteFile = remoteDir.resolve("file.txt");
+            String remotePath = Utils.resolveRelativeRemotePath(parentPath, remoteFile);
+            byte[] data = (getClass().getName() + "#" + getCurrentTestName()).getBytes(StandardCharsets.UTF_8);
+            outputDebugMessage("Upload data to %s", remotePath);
+            try {
+                scp.upload(data, remotePath, EnumSet.allOf(PosixFilePermission.class), null);
+                outputDebugMessage("Upload success to %s", remotePath);
+            } catch (ScpException e) {
+                Integer exitCode = e.getExitStatus();
+                assertNotNull("No upload exit status", exitCode);
+                assertEquals("Mismatched upload exit status", testExitValue, exitCode.intValue());
+            }
+
+            if (Files.deleteIfExists(remoteFile)) {
+                outputDebugMessage("Deleted remote file %s", remoteFile);
+            }
+
+            try (OutputStream out = Files.newOutputStream(remoteFile)) {
+                out.write(data);
+            }
+
+            try {
+                byte[] downloaded = scp.downloadBytes(remotePath);
+                outputDebugMessage("Download success to %s: %s", remotePath, new String(downloaded, StandardCharsets.UTF_8));
+            } catch (ScpException e) {
+                Integer exitCode = e.getExitStatus();
+                assertNotNull("No download exit status", exitCode);
+                assertEquals("Mismatched download exit status", testExitValue, exitCode.intValue());
+            }
+        } finally {
+            sshd.setCommandFactory(factory);
+        }
+    }
+
+    // see http://stackoverflow.com/questions/2717936/file-createnewfile-creates-files-with-last-modified-time-before-actual-creatio
+    // See https://msdn.microsoft.com/en-us/library/ms724290(VS.85).aspx
+    private static void assertLastModifiedTimeEquals(File file, boolean modSuccess, long expectedSeconds) {
+        long expectedMillis = TimeUnit.SECONDS.toMillis(expectedSeconds);
+        long actualMillis = file.lastModified();
+        long actualSeconds = TimeUnit.MILLISECONDS.toSeconds(actualMillis);
+        // if failed to set the local file time, don't expect it to be the same
+        if (!modSuccess) {
+            System.err.append("Failed to set last modified time of ").append(file.getAbsolutePath())
+                      .append(" to ").append(String.valueOf(expectedMillis))
+                      .append(" - ").println(new Date(expectedMillis));
+            System.err.append("\t\t").append("Current value: ").append(String.valueOf(actualMillis))
+                      .append(" - ").println(new Date(actualMillis));
+            return;
+        }
+
+        if (OsUtils.isWin32()) {
+            // The NTFS file system delays updates to the last access time for a file by up to 1 hour after the last access
+            if (expectedSeconds != actualSeconds) {
+                System.err.append("Mismatched last modified time for ").append(file.getAbsolutePath())
+                          .append(" - expected=").append(String.valueOf(expectedSeconds))
+                          .append('[').append(new Date(expectedMillis).toString()).append(']')
+                          .append(", actual=").append(String.valueOf(actualSeconds))
+                          .append('[').append(new Date(actualMillis).toString()).append(']')
+                          .println();
+            }
+        } else {
+            assertEquals("Mismatched last modified time for " + file.getAbsolutePath(), expectedSeconds, actualSeconds);
+        }
+    }
+
+    @Test
+    public void testJschScp() throws Exception {
+        com.jcraft.jsch.Session session = getJschSession();
+        try {
+            String data = getCurrentTestName() + "\n";
+
+            String unixDir = "target/scp";
+            String fileName = getCurrentTestName() + ".txt";
+            String unixPath = unixDir + "/" + fileName;
+            File root = new File(unixDir);
+            File target = new File(unixPath);
+            Utils.deleteRecursive(root);
+            root.mkdirs();
+            assertTrue("Failed to ensure existence of " + root, root.exists());
+
+            target.delete();
+            assertFalse("Failed to delete 1st time: " + target, target.exists());
+            sendFile(session, unixPath, target, data);
+            assertFileLength(target, data.length(), TimeUnit.SECONDS.toMillis(11L));
+
+            target.delete();
+            assertFalse("Failed to delete 2nd time: " + target, target.exists());
+            sendFile(session, unixDir, target, data);
+            assertFileLength(target, data.length(), TimeUnit.SECONDS.toMillis(11L));
+
+            sendFileError(session, "target", ScpHelper.SCP_COMMAND_PREFIX, data);
+
+            readFileError(session, unixDir);
+
+            assertEquals("Mismatched file data", data, readFile(session, unixPath, target));
+            assertEquals("Mismatched dir data", data, readDir(session, unixDir, target));
+
+            target.delete();
+            root.delete();
+
+            sendDir(session, "target", ScpHelper.SCP_COMMAND_PREFIX, fileName, data);
+            assertFileLength(target, data.length(), TimeUnit.SECONDS.toMillis(11L));
+        } finally {
+            session.disconnect();
+        }
+    }
+
+    protected com.jcraft.jsch.Session getJschSession() throws JSchException {
+        JSch sch = new JSch();
+        com.jcraft.jsch.Session session = sch.getSession(getCurrentTestName(), TEST_LOCALHOST, port);
+        session.setUserInfo(new SimpleUserInfo(getCurrentTestName()));
+        session.connect();
+        return session;
+    }
+
+    @Test
+    public void testWithGanymede() throws Exception {
+        Path targetPath = detectTargetFolder();
+        Path parentPath = targetPath.getParent();
+        Path scpRoot = Utils.resolve(targetPath, ScpHelper.SCP_COMMAND_PREFIX, getClass().getSimpleName(), getCurrentTestName());
+        Utils.deleteRecursive(scpRoot);
+
+        byte[] expected = (getClass().getName() + "#" + getCurrentTestName()).getBytes(StandardCharsets.UTF_8);
+        Path remoteDir = assertHierarchyTargetFolderExists(scpRoot.resolve("remote"));
+        String remotePath = Utils.resolveRelativeRemotePath(parentPath, remoteDir);
+        String fileName = "file.txt";
+        Path remoteFile = remoteDir.resolve(fileName);
+        String mode = ScpHelper.getOctalPermissions(EnumSet.of(
+                PosixFilePermission.OTHERS_READ, PosixFilePermission.OTHERS_WRITE,
+                PosixFilePermission.GROUP_READ, PosixFilePermission.GROUP_WRITE,
+                PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE
+        ));
+
+        ch.ethz.ssh2.log.Logger.enabled = true;
+        Connection conn = new Connection(TEST_LOCALHOST, port);
+        try {
+            ConnectionInfo info = conn.connect(null, (int) TimeUnit.SECONDS.toMillis(5L), (int) TimeUnit.SECONDS.toMillis(13L));
+            outputDebugMessage("Connected: kex=%s, key-type=%s, c2senc=%s, s2cenc=%s, c2mac=%s, s2cmac=%s",
+                    info.keyExchangeAlgorithm, info.serverHostKeyAlgorithm,
+                    info.clientToServerCryptoAlgorithm, info.serverToClientCryptoAlgorithm,
+                    info.clientToServerMACAlgorithm, info.serverToClientMACAlgorithm);
+            assertTrue("Failed to authenticate", conn.authenticateWithPassword(getCurrentTestName(), getCurrentTestName()));
+
+            SCPClient scpClient = new SCPClient(conn);
+            try (OutputStream output = scpClient.put(fileName, expected.length, remotePath, mode)) {
+                output.write(expected);
+            }
+
+            assertTrue("Remote file not created: " + remoteFile, Files.exists(remoteFile));
+            byte[] remoteData = Files.readAllBytes(remoteFile);
+            assertArrayEquals("Mismatched remote put data", expected, remoteData);
+
+            Arrays.fill(remoteData, (byte) 0);  // make sure we start with a clean slate
+            try (InputStream input = scpClient.get(remotePath + "/" + fileName)) {
+                int readLen = input.read(remoteData);
+                assertEquals("Mismatched remote get data size", expected.length, readLen);
+                // make sure we reached EOF
+                assertEquals("Unexpected extra data after read expected size", -1, input.read());
+            }
+
+            assertArrayEquals("Mismatched remote get data", expected, remoteData);
+        } finally {
+            conn.close();
+        }
+    }
+
+    protected String readFile(com.jcraft.jsch.Session session, String path, File target) throws Exception {
+        ChannelExec c = (ChannelExec) session.openChannel(Channel.CHANNEL_EXEC);
+        c.setCommand("scp -f " + path);
+        c.connect();
+
+        String fileName = target.getName();
+        try (OutputStream os = c.getOutputStream();
+             InputStream is = c.getInputStream()) {
+
+            os.write(0);
+            os.flush();
+
+            String header = readLine(is);
+            String expHeader = "C" + ScpHelper.DEFAULT_FILE_OCTAL_PERMISSIONS + " " + target.length() + " " + fileName;
+            assertEquals("Mismatched header for " + path, expHeader, header);
+
+            String lenValue = header.substring(6, header.indexOf(' ', 6));
+            int length = Integer.parseInt(lenValue);
+            os.write(0);
+            os.flush();
+
+            byte[] buffer = new byte[length];
+            length = is.read(buffer, 0, buffer.length);
+            assertEquals("Mismatched read data length for " + path, length, buffer.length);
+            assertAckReceived(is, "Read data of " + path);
+
+            os.write(0);
+            os.flush();
+
+            return new String(buffer, StandardCharsets.UTF_8);
+        } finally {
+            c.disconnect();
+        }
+    }
+
+    protected String readDir(com.jcraft.jsch.Session session, String path, File target) throws Exception {
+        ChannelExec c = (ChannelExec) session.openChannel(Channel.CHANNEL_EXEC);
+        c.setCommand("scp -r -f " + path);
+        c.connect();
+
+        try (OutputStream os = c.getOutputStream();
+             InputStream is = c.getInputStream()) {
+            os.write(0);
+            os.flush();
+
+            String header = readLine(is);
+            String expPrefix = "D" + ScpHelper.DEFAULT_DIR_OCTAL_PERMISSIONS + " 0 ";
+            assertTrue("Bad header prefix for " + path + ": " + header, header.startsWith(expPrefix));
+            os.write(0);
+            os.flush();
+
+            header = readLine(is);
+            String expHeader = "C" + ScpHelper.DEFAULT_FILE_OCTAL_PERMISSIONS + " " + target.length() + " " + target.getName();
+            assertEquals("Mismatched dir header for " + path, expHeader, header);
+            int length = Integer.parseInt(header.substring(6, header.indexOf(' ', 6)));
+            os.write(0);
+            os.flush();
+
+            byte[] buffer = new byte[length];
+            length = is.read(buffer, 0, buffer.length);
+            assertEquals("Mismatched read buffer size for " + path, length, buffer.length);
+            assertAckReceived(is, "Read date of " + path);
+
+            os.write(0);
+            os.flush();
+
+            header = readLine(is);
+            assertEquals("Mismatched end value for " + path, "E", header);
+            os.write(0);
+            os.flush();
+
+            return new String(buffer, StandardCharsets.UTF_8);
+        } finally {
+            c.disconnect();
+        }
+    }
+
+    protected void readFileError(com.jcraft.jsch.Session session, String path) throws Exception {
+        ChannelExec c = (ChannelExec) session.openChannel(Channel.CHANNEL_EXEC);
+        String command = "scp -f " + path;
+        c.setCommand(command);
+        c.connect();
+
+        try (OutputStream os = c.getOutputStream();
+             InputStream is = c.getInputStream()) {
+
+            os.write(0);
+            os.flush();
+            assertEquals("Mismatched response for command: " + command, 2, is.read());
+        } finally {
+            c.disconnect();
+        }
+    }
+
+    protected void sendFile(com.jcraft.jsch.Session session, String path, File target, String data) throws Exception {
+        ChannelExec c = (ChannelExec) session.openChannel(Channel.CHANNEL_EXEC);
+        String command = "scp -t " + path;
+        c.setCommand(command);
+        c.connect();
+
+        try (OutputStream os = c.getOutputStream();
+             InputStream is = c.getInputStream()) {
+
+            assertAckReceived(is, command);
+
+            File parent = target.getParentFile();
+            Collection<PosixFilePermission> perms = IoUtils.getPermissions(parent.toPath());
+            String octalPerms = ScpHelper.getOctalPermissions(perms);
+            String name = target.getName();
+            assertAckReceived(os, is, "C" + octalPerms + " " + data.length() + " " + name);
+
+            os.write(data.getBytes(StandardCharsets.UTF_8));
+            os.flush();
+            assertAckReceived(is, "Sent data (length=" + data.length() + ") for " + path + "[" + name + "]");
+
+            os.write(0);
+            os.flush();
+
+            Thread.sleep(100);
+        } finally {
+            c.disconnect();
+        }
+    }
+
+    protected void assertAckReceived(OutputStream os, InputStream is, String command) throws IOException {
+        os.write((command + "\n").getBytes(StandardCharsets.UTF_8));
+        os.flush();
+        assertAckReceived(is, command);
+    }
+
+    protected void assertAckReceived(InputStream is, String command) throws IOException {
+        assertEquals("No ACK for command=" + command, 0, is.read());
+    }
+
+    protected void sendFileError(com.jcraft.jsch.Session session, String path, String name, String data) throws Exception {
+        ChannelExec c = (ChannelExec) session.openChannel(Channel.CHANNEL_EXEC);
+        String command = "scp -t " + path;
+        c.setCommand(command);
+        c.connect();
+
+        try (OutputStream os = c.getOutputStream();
+             InputStream is = c.getInputStream()) {
+
+            assertAckReceived(is, command);
+
+            command = "C7777 " + data.length() + " " + name;
+            os.write((command + "\n").getBytes(StandardCharsets.UTF_8));
+            os.flush();
+            assertEquals("Mismatched response for command=" + command, 2, is.read());
+        } finally {
+            c.disconnect();
+        }
+    }
+
+    protected void sendDir(com.jcraft.jsch.Session session, String path, String dirName, String fileName, String data) throws Exception {
+        ChannelExec c = (ChannelExec) session.openChannel(Channel.CHANNEL_EXEC);
+        String command = "scp -t -r " + path;
+        c.setCommand(command);
+        c.connect();
+
+        try (OutputStream os = c.getOutputStream();
+             InputStream is = c.getInputStream()) {
+
+            assertAckReceived(is, command);
+            assertAckReceived(os, is, "D0755 0 " + dirName);
+            assertAckReceived(os, is, "C7777 " + data.length() + " " + fileName);
+
+            os.write(data.getBytes(StandardCharsets.UTF_8));
+            os.flush();
+            assertAckReceived(is, "Send data of " + path);
+
+            os.write(0);
+            os.flush();
+
+            os.write("E\n".getBytes(StandardCharsets.UTF_8));
+            os.flush();
+            assertAckReceived(is, "Signal end of " + path);
+        } finally {
+            c.disconnect();
+        }
+    }
+
+    private static String readLine(InputStream in) throws IOException {
+        try (OutputStream baos = new ByteArrayOutputStream()) {
+            for (;;) {
+                int c = in.read();
+                if (c == '\n') {
+                    return baos.toString();
+                } else if (c == -1) {
+                    throw new IOException("End of stream");
+                } else {
+                    baos.write(c);
+                }
+            }
+        }
+    }
+
+    private static ScpClient createScpClient(ClientSession session) {
+        ScpClientCreator creator = ScpClientCreator.instance();
+        ScpTransferEventListener listener = getScpTransferEventListener(session);
+        return creator.createScpClient(session, listener);
+    }
+
+    private static ScpTransferEventListener getScpTransferEventListener(ClientSession session) {
+        return OUTPUT_DEBUG_MESSAGES ? DEBUG_LISTENER : ScpTransferEventListener.EMPTY;
+    }
+}

http://git-wip-us.apache.org/repos/asf/mina-sshd/blob/af415e5f/sshd-scp/src/test/java/org/apache/sshd/client/scp/SimpleScpClientTest.java
----------------------------------------------------------------------
diff --git a/sshd-scp/src/test/java/org/apache/sshd/client/scp/SimpleScpClientTest.java b/sshd-scp/src/test/java/org/apache/sshd/client/scp/SimpleScpClientTest.java
new file mode 100644
index 0000000..d9b9b08
--- /dev/null
+++ b/sshd-scp/src/test/java/org/apache/sshd/client/scp/SimpleScpClientTest.java
@@ -0,0 +1,121 @@
+/*
+ * 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.scp;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import org.apache.sshd.common.file.FileSystemFactory;
+import org.apache.sshd.common.file.virtualfs.VirtualFileSystemFactory;
+import org.apache.sshd.common.scp.ScpHelper;
+import org.apache.sshd.common.session.Session;
+import org.apache.sshd.common.util.io.IoUtils;
+import org.apache.sshd.server.scp.ScpCommandFactory;
+import org.apache.sshd.util.test.Utils;
+import org.apache.sshd.util.test.client.simple.BaseSimpleClientTestSupport;
+import org.junit.FixMethodOrder;
+import org.junit.Test;
+import org.junit.runners.MethodSorters;
+
+/**
+ * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
+ */
+@FixMethodOrder(MethodSorters.NAME_ASCENDING)
+public class SimpleScpClientTest extends BaseSimpleClientTestSupport {
+    private final Path targetPath;
+    private final Path parentPath;
+    private final FileSystemFactory fileSystemFactory;
+    private SimpleScpClient scpClient;
+
+    public SimpleScpClientTest() throws Exception {
+        targetPath = detectTargetFolder();
+        parentPath = targetPath.getParent();
+        fileSystemFactory = new VirtualFileSystemFactory(parentPath);
+    }
+
+    @Override
+    public void setUp() throws Exception {
+        super.setUp();
+        sshd.setCommandFactory(new ScpCommandFactory());
+        sshd.setFileSystemFactory(fileSystemFactory);
+        client.start();
+        scpClient = new SimpleScpClientImpl(simple);
+    }
+
+    @Test
+    public void testSessionClosedWhenClientClosed() throws Exception {
+        try (CloseableScpClient scp = login()) {
+            assertTrue("SCP not open", scp.isOpen());
+
+            Session session = scp.getClientSession();
+            assertTrue("Session not open", session.isOpen());
+
+            scp.close();
+            assertFalse("Session not closed", session.isOpen());
+            assertFalse("SCP not closed", scp.isOpen());
+        }
+    }
+
+    @Test
+    public void testScpUploadProxy() throws Exception {
+        try (CloseableScpClient scp = login()) {
+            Path scpRoot = Utils.resolve(targetPath, ScpHelper.SCP_COMMAND_PREFIX, getClass().getSimpleName(), getCurrentTestName());
+            Utils.deleteRecursive(scpRoot);
+
+            Path localDir = assertHierarchyTargetFolderExists(scpRoot.resolve("local"));
+            Path localFile = localDir.resolve("file.txt");
+            String data = getClass().getName() + "#" + getCurrentTestName() + IoUtils.EOL;
+            byte[] written = Utils.writeFile(localFile, data);
+
+            Path remoteDir = assertHierarchyTargetFolderExists(scpRoot.resolve("remote"));
+            Path remoteFile = remoteDir.resolve(localFile.getFileName());
+            String remotePath = Utils.resolveRelativeRemotePath(parentPath, remoteFile);
+            scp.upload(localFile, remotePath);
+
+            byte[] uploaded = Files.readAllBytes(remoteFile);
+            assertArrayEquals("Mismatched uploaded data", written, uploaded);
+        }
+    }
+
+    @Test
+    public void testScpDownloadProxy() throws Exception {
+        try (CloseableScpClient scp = login()) {
+            Path scpRoot = Utils.resolve(targetPath, ScpHelper.SCP_COMMAND_PREFIX, getClass().getSimpleName(), getCurrentTestName());
+            Utils.deleteRecursive(scpRoot);
+
+            Path remoteDir = assertHierarchyTargetFolderExists(scpRoot.resolve("remote"));
+            Path remoteFile = remoteDir.resolve("file.txt");
+            String data = getClass().getName() + "#" + getCurrentTestName() + IoUtils.EOL;
+            byte[] written = Utils.writeFile(remoteFile, data);
+            Path localDir = assertHierarchyTargetFolderExists(scpRoot.resolve("local"));
+            Path localFile = localDir.resolve(remoteFile.getFileName());
+            String remotePath = Utils.resolveRelativeRemotePath(parentPath, remoteFile);
+            scp.download(remotePath, localFile);
+
+            byte[] downloaded = Files.readAllBytes(localFile);
+            assertArrayEquals("Mismatched downloaded data", written, downloaded);
+        }
+    }
+
+    private CloseableScpClient login() throws IOException {
+        return scpClient.scpLogin(TEST_LOCALHOST, port, getCurrentTestName(), getCurrentTestName());
+    }
+}

http://git-wip-us.apache.org/repos/asf/mina-sshd/blob/af415e5f/sshd-scp/src/test/java/org/apache/sshd/server/scp/ScpCommandFactoryTest.java
----------------------------------------------------------------------
diff --git a/sshd-scp/src/test/java/org/apache/sshd/server/scp/ScpCommandFactoryTest.java b/sshd-scp/src/test/java/org/apache/sshd/server/scp/ScpCommandFactoryTest.java
new file mode 100644
index 0000000..0cd1e91
--- /dev/null
+++ b/sshd-scp/src/test/java/org/apache/sshd/server/scp/ScpCommandFactoryTest.java
@@ -0,0 +1,113 @@
+/*
+ * 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.server.scp;
+
+import java.util.concurrent.ExecutorService;
+
+import org.apache.sshd.common.scp.ScpHelper;
+import org.apache.sshd.server.CommandFactory;
+import org.apache.sshd.util.test.BaseTestSupport;
+import org.apache.sshd.util.test.NoIoTestCase;
+import org.junit.FixMethodOrder;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+import org.junit.runners.MethodSorters;
+import org.mockito.Mockito;
+
+/**
+ * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
+ */
+@FixMethodOrder(MethodSorters.NAME_ASCENDING)
+@Category({ NoIoTestCase.class })
+public class ScpCommandFactoryTest extends BaseTestSupport {
+    public ScpCommandFactoryTest() {
+        super();
+    }
+
+    /**
+     * Make sure that the builder returns a factory with the default values
+     * if no {@code withXXX} method is invoked
+     */
+    @Test
+    public void testBuilderDefaultFactoryValues() {
+        ScpCommandFactory factory = new ScpCommandFactory.Builder().build();
+        assertNull("Mismatched delegate", factory.getDelegateCommandFactory());
+        assertNull("Mismatched executor", factory.getExecutorService());
+        assertEquals("Mismatched send size", ScpHelper.MIN_SEND_BUFFER_SIZE, factory.getSendBufferSize());
+        assertEquals("Mismatched receive size", ScpHelper.MIN_RECEIVE_BUFFER_SIZE, factory.getReceiveBufferSize());
+        assertFalse("Mismatched shutdown state", factory.isShutdownOnExit());
+    }
+
+    /**
+     * Make sure that the builder initializes correctly the built factory
+     */
+    @Test
+    public void testBuilderCorrectlyInitializesFactory() {
+        CommandFactory delegate = dummyFactory();
+        ExecutorService service = dummyExecutor();
+        int receiveSize = Short.MAX_VALUE;
+        int sendSize = receiveSize + Long.SIZE;
+        ScpCommandFactory factory = new ScpCommandFactory.Builder()
+                .withDelegate(delegate)
+                .withExecutorService(service)
+                .withSendBufferSize(sendSize)
+                .withReceiveBufferSize(receiveSize)
+                .withShutdownOnExit(true)
+                .build();
+        assertSame("Mismatched delegate", delegate, factory.getDelegateCommandFactory());
+        assertSame("Mismatched executor", service, factory.getExecutorService());
+        assertEquals("Mismatched send size", sendSize, factory.getSendBufferSize());
+        assertEquals("Mismatched receive size", receiveSize, factory.getReceiveBufferSize());
+        assertTrue("Mismatched shutdown state", factory.isShutdownOnExit());
+    }
+
+    /**
+     * <UL>
+     * <LI>
+     * Make sure the builder returns new instances on every call to
+     * {@link org.apache.sshd.server.scp.ScpCommandFactory.Builder#build()} method
+     * </LI>
+     *
+     * <LI>
+     * Make sure values are preserved between successive invocations
+     * of the {@link org.apache.sshd.server.scp.ScpCommandFactory.Builder#build()} method
+     * </LI>
+     * </UL
+     */
+    @Test
+    public void testBuilderUniqueInstance() {
+        ScpCommandFactory.Builder builder = new ScpCommandFactory.Builder();
+        ScpCommandFactory f1 = builder.withDelegate(dummyFactory()).build();
+        ScpCommandFactory f2 = builder.build();
+        assertNotSame("No new instance built", f1, f2);
+        assertSame("Mismatched delegate", f1.getDelegateCommandFactory(), f2.getDelegateCommandFactory());
+
+        ScpCommandFactory f3 = builder.withDelegate(dummyFactory()).build();
+        assertNotSame("Delegate not changed", f1.getDelegateCommandFactory(), f3.getDelegateCommandFactory());
+    }
+
+    private static ExecutorService dummyExecutor() {
+        return Mockito.mock(ExecutorService.class);
+    }
+
+    private static CommandFactory dummyFactory() {
+        return Mockito.mock(CommandFactory.class);
+    }
+}

http://git-wip-us.apache.org/repos/asf/mina-sshd/blob/af415e5f/sshd-sftp/src/main/java/org/apache/sshd/client/simple/SimpleSftpClient.java
----------------------------------------------------------------------
diff --git a/sshd-sftp/src/main/java/org/apache/sshd/client/simple/SimpleSftpClient.java b/sshd-sftp/src/main/java/org/apache/sshd/client/simple/SimpleSftpClient.java
deleted file mode 100644
index 1034046..0000000
--- a/sshd-sftp/src/main/java/org/apache/sshd/client/simple/SimpleSftpClient.java
+++ /dev/null
@@ -1,179 +0,0 @@
-/*
- * 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.simple;
-
-import java.io.IOException;
-import java.net.InetAddress;
-import java.net.InetSocketAddress;
-import java.net.SocketAddress;
-import java.nio.channels.Channel;
-import java.security.KeyPair;
-import java.util.Objects;
-
-import org.apache.sshd.client.subsystem.sftp.SftpClient;
-import org.apache.sshd.common.util.ValidateUtils;
-
-/**
- * A simplified <U>synchronous</U> API for obtaining SFTP sessions.
- *
- * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
- */
-public interface SimpleSftpClient extends Channel {
-    /**
-     * Creates an SFTP session on the default port and logs in using the provided credentials
-     *
-     * @param host The target host name or address
-     * @param username Username
-     * @param password Password
-     * @return Created {@link SftpClient} - <B>Note:</B> closing the client also closes its
-     * underlying session
-     * @throws IOException If failed to login or authenticate
-     */
-    default SftpClient sftpLogin(String host, String username, String password) throws IOException {
-        return sftpLogin(host, SimpleClientConfigurator.DEFAULT_PORT, username, password);
-    }
-
-    /**
-     * Creates an SFTP session using the provided credentials
-     *
-     * @param host The target host name or address
-     * @param port The target port
-     * @param username Username
-     * @param password Password
-     * @return Created {@link SftpClient} - <B>Note:</B> closing the client also closes its
-     * underlying session
-     * @throws IOException If failed to login or authenticate
-     */
-    default SftpClient sftpLogin(String host, int port, String username, String password) throws IOException {
-        return sftpLogin(InetAddress.getByName(ValidateUtils.checkNotNullAndNotEmpty(host, "No host")), port, username, password);
-    }
-
-    /**
-     * Creates an SFTP session on the default port and logs in using the provided credentials
-     *
-     * @param host The target host name or address
-     * @param username Username
-     * @param identity The {@link KeyPair} identity
-     * @return Created {@link SftpClient} - <B>Note:</B> closing the client also closes its
-     * underlying session
-     * @throws IOException If failed to login or authenticate
-     */
-    default SftpClient sftpLogin(String host, String username, KeyPair identity) throws IOException {
-        return sftpLogin(host, SimpleClientConfigurator.DEFAULT_PORT, username, identity);
-    }
-
-    /**
-     * Creates an SFTP session using the provided credentials
-     *
-     * @param host The target host name or address
-     * @param port The target port
-     * @param username Username
-     * @param identity The {@link KeyPair} identity
-     * @return Created {@link SftpClient} - <B>Note:</B> closing the client also closes its
-     * underlying session
-     * @throws IOException If failed to login or authenticate
-     */
-    default SftpClient sftpLogin(String host, int port, String username, KeyPair identity) throws IOException {
-        return sftpLogin(InetAddress.getByName(ValidateUtils.checkNotNullAndNotEmpty(host, "No host")), port, username, identity);
-    }
-
-    /**
-     * Creates an SFTP session on the default port and logs in using the provided credentials
-     *
-     * @param host The target host {@link InetAddress}
-     * @param username Username
-     * @param password Password
-     * @return Created {@link SftpClient} - <B>Note:</B> closing the client also closes its
-     * underlying session
-     * @throws IOException If failed to login or authenticate
-     */
-    default SftpClient sftpLogin(InetAddress host, String username, String password) throws IOException {
-        return sftpLogin(host, SimpleClientConfigurator.DEFAULT_PORT, username, password);
-    }
-
-    /**
-     * Creates an SFTP session using the provided credentials
-     *
-     * @param host The target host {@link InetAddress}
-     * @param port The target port
-     * @param username Username
-     * @param password Password
-     * @return Created {@link SftpClient} - <B>Note:</B> closing the client also closes its
-     * underlying session
-     * @throws IOException If failed to login or authenticate
-     */
-    default SftpClient sftpLogin(InetAddress host, int port, String username, String password) throws IOException {
-        return sftpLogin(new InetSocketAddress(Objects.requireNonNull(host, "No host address"), port), username, password);
-    }
-
-    /**
-     * Creates an SFTP session on the default port and logs in using the provided credentials
-     *
-     * @param host The target host {@link InetAddress}
-     * @param username Username
-     * @param identity The {@link KeyPair} identity
-     * @return Created {@link SftpClient} - <B>Note:</B> closing the client also closes its
-     * underlying session
-     * @throws IOException If failed to login or authenticate
-     */
-    default SftpClient sftpLogin(InetAddress host, String username, KeyPair identity) throws IOException {
-        return sftpLogin(host, SimpleClientConfigurator.DEFAULT_PORT, username, identity);
-    }
-
-    /**
-     * Creates an SFTP session using the provided credentials
-     *
-     * @param host The target host {@link InetAddress}
-     * @param port The target port
-     * @param username Username
-     * @param identity The {@link KeyPair} identity
-     * @return Created {@link SftpClient} - <B>Note:</B> closing the client also closes its
-     * underlying session
-     * @throws IOException If failed to login or authenticate
-     */
-    default SftpClient sftpLogin(InetAddress host, int port, String username, KeyPair identity) throws IOException {
-        return sftpLogin(new InetSocketAddress(Objects.requireNonNull(host, "No host address"), port), username, identity);
-    }
-
-    /**
-     * Creates an SFTP session using the provided credentials
-     *
-     * @param target The target {@link SocketAddress}
-     * @param username Username
-     * @param password Password
-     * @return Created {@link SftpClient} - <B>Note:</B> closing the client also closes its
-     * underlying session
-     * @throws IOException If failed to login or authenticate
-     */
-    SftpClient sftpLogin(SocketAddress target, String username, String password) throws IOException;
-
-    /**
-     * Creates an SFTP session using the provided credentials
-     *
-     * @param target The target {@link SocketAddress}
-     * @param username Username
-     * @param identity The {@link KeyPair} identity
-     * @return Created {@link SftpClient} - <B>Note:</B> closing the client also closes its
-     * underlying session
-     * @throws IOException If failed to login or authenticate
-     */
-    SftpClient sftpLogin(SocketAddress target, String username, KeyPair identity) throws IOException;
-
-}