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 2020/08/21 07:15:15 UTC

[mina-sshd] branch master updated (1c0683e -> 9fe278d)

This is an automated email from the ASF dual-hosted git repository.

lgoldstein pushed a change to branch master
in repository https://gitbox.apache.org/repos/asf/mina-sshd.git.


    from 1c0683e  [SSHD-1060] Do not store logger level in fields
     new 36cddc4  [SSHD-1056] Modified ClientFactoryManager hierarchy to include ClientSessionCreator
     new 7374753  [SSHD-1056] Added configurable default auth/connect timeouts to client CLI sessions
     new 68f65a7  [SSHD-1056] Re-structure ScpRemote2RemoteTransferHelper to be more modular
     new d1c18fe  [SSHD-1056] Add SCP remote-to-remote transfer of directories
     new 9fe278d  [SSHD-1056] Added 3-way CLI option to SCP command

The 5 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 CHANGES.md                                         |   3 +-
 docs/cli.md                                        |  24 +-
 .../cli/client/CliClientModuleProperties.java}     |  29 +-
 .../org/apache/sshd/cli/client/ScpCommandMain.java | 397 ++++++++++++++++-----
 .../sshd/cli/client/SshClientCliSupport.java       |   8 +-
 .../apache/sshd/common/CommonModuleProperties.java |   3 +-
 .../org/apache/sshd/common/util/SelectorUtils.java |  39 +-
 ...herNameTest.java => PathsConcatentionTest.java} |  58 +--
 .../apache/sshd/common/util/SelectorUtilsTest.java |   8 +
 .../apache/sshd/client/ClientFactoryManager.java   |   2 +
 .../java/org/apache/sshd/client/SshClient.java     |   7 +-
 .../sshd/client/session/ClientUserAuthService.java |   3 +-
 .../org/apache/sshd/core/CoreModuleProperties.java |   3 +-
 .../org/apache/sshd/scp/ScpModuleProperties.java   |   2 +-
 .../java/org/apache/sshd/scp/client/ScpClient.java |  56 ++-
 .../scp/client/ScpRemote2RemoteTransferHelper.java | 357 ++++++++++++++----
 .../client/ScpRemote2RemoteTransferListener.java   |  37 ++
 .../java/org/apache/sshd/scp/common/ScpHelper.java | 213 ++++++++---
 .../org/apache/sshd/scp/common/ScpLocation.java    | 141 ++++++--
 .../apache/sshd/scp/common/helpers/ScpAckInfo.java | 130 +++++++
 .../common/helpers/ScpDirEndCommandDetails.java    |  14 +
 .../apache/sshd/scp/common/helpers/ScpIoUtils.java | 216 +----------
 .../common/helpers/ScpTimestampCommandDetails.java |   2 +-
 .../org/apache/sshd/scp/server/ScpCommand.java     |  12 +-
 .../java/org/apache/sshd/scp/server/ScpShell.java  |  10 +-
 .../client/ScpRemote2RemoteTransferHelperTest.java | 221 +++++++++++-
 .../java/org/apache/sshd/scp/client/ScpTest.java   |  23 +-
 .../sshd/scp/common/ScpLocationParsingTest.java    |  63 ++--
 .../org/apache/sshd/sftp/SftpModuleProperties.java |   2 +-
 29 files changed, 1478 insertions(+), 605 deletions(-)
 copy sshd-cli/src/{test/java/org/apache/sshd/cli/client/SshKeyScanMainDevelopment.java => main/java/org/apache/sshd/cli/client/CliClientModuleProperties.java} (55%)
 copy sshd-common/src/test/java/org/apache/sshd/common/util/{security/SecurityProviderRegistrarCipherNameTest.java => PathsConcatentionTest.java} (56%)
 create mode 100644 sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/ScpAckInfo.java
 copy sshd-common/src/test/java/org/apache/sshd/common/PropertyResolverParseBooleanTest.java => sshd-scp/src/test/java/org/apache/sshd/scp/common/ScpLocationParsingTest.java (54%)


[mina-sshd] 03/05: [SSHD-1056] Re-structure ScpRemote2RemoteTransferHelper to be more modular

Posted by lg...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

lgoldstein pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/mina-sshd.git

commit 68f65a7d9863232b985ede367de9640459bb2cde
Author: Lyor Goldstein <lg...@apache.org>
AuthorDate: Tue Aug 18 14:19:21 2020 +0300

    [SSHD-1056] Re-structure ScpRemote2RemoteTransferHelper to be more modular
---
 .../scp/client/ScpRemote2RemoteTransferHelper.java | 58 ++++++++++++++--------
 1 file changed, 38 insertions(+), 20 deletions(-)

diff --git a/sshd-scp/src/main/java/org/apache/sshd/scp/client/ScpRemote2RemoteTransferHelper.java b/sshd-scp/src/main/java/org/apache/sshd/scp/client/ScpRemote2RemoteTransferHelper.java
index d558576..2f88fc3 100644
--- a/sshd-scp/src/main/java/org/apache/sshd/scp/client/ScpRemote2RemoteTransferHelper.java
+++ b/sshd-scp/src/main/java/org/apache/sshd/scp/client/ScpRemote2RemoteTransferHelper.java
@@ -88,28 +88,38 @@ public class ScpRemote2RemoteTransferHelper extends AbstractLoggingBean {
     public void transferFile(String source, String destination, boolean preserveAttributes) throws IOException {
         Collection<Option> options = preserveAttributes
                 ? Collections.unmodifiableSet(EnumSet.of(Option.PreserveAttributes))
-                : Collections.emptySet();
-        String srcCmd = ScpClient.createReceiveCommand(source, options);
+                : Collections.emptySet()
+                ;
+        executeTransfer(source, options, destination, options);
+    }
+
+    protected void executeTransfer(
+            String source, Collection<Option> srcOptions,
+            String destination, Collection<Option> dstOptions)
+            throws IOException {
+        String srcCmd = ScpClient.createReceiveCommand(source, srcOptions);
         ClientSession srcSession = getSourceSession();
         ClientSession dstSession = getDestinationSession();
         boolean debugEnabled = log.isDebugEnabled();
         if (debugEnabled) {
-            log.debug("transferFile({})[srcCmd='{}']) {} => {}",
+            log.debug("executeTransfer({})[srcCmd='{}']) {} => {}",
                     this, srcCmd, source, destination);
         }
 
         ChannelExec srcChannel = ScpIoUtils.openCommandChannel(srcSession, srcCmd, log);
         try (InputStream srcIn = srcChannel.getInvertedOut();
              OutputStream srcOut = srcChannel.getInvertedIn()) {
-            String dstCmd = ScpClient.createSendCommand(destination, options);
+            String dstCmd = ScpClient.createSendCommand(destination, dstOptions);
             if (debugEnabled) {
-                log.debug("transferFile({})[dstCmd='{}'} {} => {}",
+                log.debug("executeTransfer({})[dstCmd='{}'} {} => {}",
                         this, dstCmd, source, destination);
             }
 
             ChannelExec dstChannel = ScpIoUtils.openCommandChannel(dstSession, dstCmd, log);
             try (InputStream dstIn = dstChannel.getInvertedOut();
                  OutputStream dstOut = dstChannel.getInvertedIn()) {
+                int statusCode = transferStatusCode("XFER-CMD", dstIn, srcOut);
+                ScpIoUtils.validateCommandStatusCode("XFER-CMD", "executeTransfer", statusCode, false);
                 redirectReceivedFile(source, srcIn, srcOut, destination, dstIn, dstOut);
             } finally {
                 dstChannel.close(false);
@@ -117,15 +127,13 @@ public class ScpRemote2RemoteTransferHelper extends AbstractLoggingBean {
         } finally {
             srcChannel.close(false);
         }
+
     }
 
     protected long redirectReceivedFile(
             String source, InputStream srcIn, OutputStream srcOut,
             String destination, InputStream dstIn, OutputStream dstOut)
             throws IOException {
-        int statusCode = transferStatusCode("XFER-FILE", dstIn, srcOut);
-        ScpIoUtils.validateCommandStatusCode("XFER-FILE", "redirectReceivedFile", statusCode, false);
-
         boolean debugEnabled = log.isDebugEnabled();
         String header = ScpIoUtils.readLine(srcIn, false);
         if (debugEnabled) {
@@ -137,18 +145,8 @@ public class ScpRemote2RemoteTransferHelper extends AbstractLoggingBean {
         if (cmdName == ScpTimestampCommandDetails.COMMAND_NAME) {
             // Pass along the "T<mtime> 0 <atime> 0" and wait for response
             time = ScpTimestampCommandDetails.parseTime(header);
-            signalReceivedCommand(time);
-
-            ScpIoUtils.writeLine(dstOut, header);
-            statusCode = transferStatusCode(header, dstIn, srcOut);
-            ScpIoUtils.validateCommandStatusCode("[DST] " + header, "redirectReceivedFile", statusCode, false);
-
             // Read the next command - which must be a 'C' command
-            header = ScpIoUtils.readLine(srcIn, false);
-            if (debugEnabled) {
-                log.debug("redirectReceivedFile({}) header={}", this, header);
-            }
-
+            header = transferTimestampCommand(source, srcIn, srcOut, destination, dstIn, dstOut, time);
             cmdName = header.charAt(0);
         }
 
@@ -161,7 +159,7 @@ public class ScpRemote2RemoteTransferHelper extends AbstractLoggingBean {
 
         // Pass along the "Cmmmm <length> <filename" command and wait for ACK
         ScpIoUtils.writeLine(dstOut, header);
-        statusCode = transferStatusCode(header, dstIn, srcOut);
+        int statusCode = transferStatusCode(header, dstIn, srcOut);
         ScpIoUtils.validateCommandStatusCode("[DST] " + header, "redirectReceivedFile", statusCode, false);
         // Wait with ACK ready for transfer until ready to transfer data
         long xferCount = transferFileData(source, srcIn, srcOut, destination, dstIn, dstOut, time, details);
@@ -176,6 +174,26 @@ public class ScpRemote2RemoteTransferHelper extends AbstractLoggingBean {
         return xferCount;
     }
 
+    protected String transferTimestampCommand(
+            String source, InputStream srcIn, OutputStream srcOut,
+            String destination, InputStream dstIn, OutputStream dstOut,
+            ScpTimestampCommandDetails time)
+            throws IOException {
+        signalReceivedCommand(time);
+
+        String header = time.toHeader();
+        ScpIoUtils.writeLine(dstOut, header);
+        int statusCode = transferStatusCode(header, dstIn, srcOut);
+        ScpIoUtils.validateCommandStatusCode("[DST] " + header, "transferTimestampCommand", statusCode, false);
+
+        header = ScpIoUtils.readLine(srcIn, false);
+        if (log.isDebugEnabled()) {
+            log.debug("transferTimestampCommand({}) header={}", this, header);
+        }
+
+        return header;
+    }
+
     protected int transferStatusCode(Object logHint, InputStream in, OutputStream out) throws IOException {
         int statusCode = in.read();
         if (statusCode == -1) {


[mina-sshd] 02/05: [SSHD-1056] Added configurable default auth/connect timeouts to client CLI sessions

Posted by lg...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

lgoldstein pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/mina-sshd.git

commit 737475319b914915306575e8b072e7ed94e94cdd
Author: Lyor Goldstein <lg...@apache.org>
AuthorDate: Thu Aug 20 15:29:17 2020 +0300

    [SSHD-1056] Added configurable default auth/connect timeouts to client CLI sessions
---
 .../sshd/cli/client/CliClientModuleProperties.java | 48 ++++++++++++++++++++++
 .../sshd/cli/client/SshClientCliSupport.java       |  5 +--
 .../apache/sshd/common/CommonModuleProperties.java |  3 +-
 .../org/apache/sshd/core/CoreModuleProperties.java |  3 +-
 .../org/apache/sshd/scp/ScpModuleProperties.java   |  2 +-
 .../org/apache/sshd/sftp/SftpModuleProperties.java |  2 +-
 6 files changed, 54 insertions(+), 9 deletions(-)

diff --git a/sshd-cli/src/main/java/org/apache/sshd/cli/client/CliClientModuleProperties.java b/sshd-cli/src/main/java/org/apache/sshd/cli/client/CliClientModuleProperties.java
new file mode 100644
index 0000000..1287784
--- /dev/null
+++ b/sshd-cli/src/main/java/org/apache/sshd/cli/client/CliClientModuleProperties.java
@@ -0,0 +1,48 @@
+/*
+ * 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.cli.client;
+
+import java.time.Duration;
+
+import org.apache.sshd.common.Property;
+
+/**
+ * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
+ */
+public final class CliClientModuleProperties {
+    /**
+     * Key used to retrieve the value of the timeout after which it will abort the connection if the connection
+     * has not been established - in milliseconds.
+     */
+    public static final Property<Duration> CONECT_TIMEOUT
+        = Property.duration("cli-connect-timeout", Duration.ofMinutes(2));
+
+    /**
+     * Key used to retrieve the value of the timeout after which it will close the connection if the other side has not
+     * been authenticated - in milliseconds.
+     */
+    public static final Property<Duration> AUTH_TIMEOUT
+        = Property.duration("cli-auth-timeout", Duration.ofMinutes(2));
+
+
+    private CliClientModuleProperties() {
+        throw new UnsupportedOperationException("No instance");
+    }
+}
diff --git a/sshd-cli/src/main/java/org/apache/sshd/cli/client/SshClientCliSupport.java b/sshd-cli/src/main/java/org/apache/sshd/cli/client/SshClientCliSupport.java
index 6b17d17..685c812 100644
--- a/sshd-cli/src/main/java/org/apache/sshd/cli/client/SshClientCliSupport.java
+++ b/sshd-cli/src/main/java/org/apache/sshd/cli/client/SshClientCliSupport.java
@@ -82,7 +82,6 @@ import org.apache.sshd.common.util.ValidateUtils;
 import org.apache.sshd.common.util.io.NoCloseOutputStream;
 import org.apache.sshd.common.util.net.SshdSocketAddress;
 import org.apache.sshd.common.util.threads.ThreadUtils;
-import org.apache.sshd.core.CoreModuleProperties;
 
 /**
  * TODO Add javadoc
@@ -259,13 +258,13 @@ public abstract class SshClientCliSupport extends CliSupport {
             HostConfigEntry entry = resolveHost(client, login, host, port, proxyJump);
             // TODO use a configurable wait time
             ClientSession session = client.connect(entry, null, null)
-                    .verify()
+                    .verify(CliClientModuleProperties.CONECT_TIMEOUT.getRequired(client))
                     .getSession();
             try {
                 if (GenericUtils.length(password) > 0) {
                     session.addPasswordIdentity(password);
                 }
-                session.auth().verify(CoreModuleProperties.AUTH_TIMEOUT.getRequired(session));
+                session.auth().verify(CliClientModuleProperties.AUTH_TIMEOUT.getRequired(session));
                 return session;
             } catch (Exception e) {
                 session.close(true);
diff --git a/sshd-common/src/main/java/org/apache/sshd/common/CommonModuleProperties.java b/sshd-common/src/main/java/org/apache/sshd/common/CommonModuleProperties.java
index c3da143..0b76b0f 100644
--- a/sshd-common/src/main/java/org/apache/sshd/common/CommonModuleProperties.java
+++ b/sshd-common/src/main/java/org/apache/sshd/common/CommonModuleProperties.java
@@ -69,7 +69,6 @@ public final class CommonModuleProperties {
             = Property.duration("sshd-close-wait-time", Duration.ofSeconds(15L));
 
     private CommonModuleProperties() {
-        // private
+        throw new UnsupportedOperationException("No instance");
     }
-
 }
diff --git a/sshd-core/src/main/java/org/apache/sshd/core/CoreModuleProperties.java b/sshd-core/src/main/java/org/apache/sshd/core/CoreModuleProperties.java
index 0746249..828bf72 100644
--- a/sshd-core/src/main/java/org/apache/sshd/core/CoreModuleProperties.java
+++ b/sshd-core/src/main/java/org/apache/sshd/core/CoreModuleProperties.java
@@ -679,7 +679,6 @@ public final class CoreModuleProperties {
             = Property.string("x11-fwd-bind-host", SshdSocketAddress.LOCALHOST_IPV4);
 
     private CoreModuleProperties() {
-        // private
+        throw new UnsupportedOperationException("No instance");
     }
-
 }
diff --git a/sshd-scp/src/main/java/org/apache/sshd/scp/ScpModuleProperties.java b/sshd-scp/src/main/java/org/apache/sshd/scp/ScpModuleProperties.java
index 3c6d5f3..4821f34 100644
--- a/sshd-scp/src/main/java/org/apache/sshd/scp/ScpModuleProperties.java
+++ b/sshd-scp/src/main/java/org/apache/sshd/scp/ScpModuleProperties.java
@@ -60,6 +60,6 @@ public final class ScpModuleProperties {
             = Property.charset("scp-shell-name-encoding-charset", StandardCharsets.UTF_8);
 
     private ScpModuleProperties() {
-        // private
+        throw new UnsupportedOperationException("No instance");
     }
 }
diff --git a/sshd-sftp/src/main/java/org/apache/sshd/sftp/SftpModuleProperties.java b/sshd-sftp/src/main/java/org/apache/sshd/sftp/SftpModuleProperties.java
index a719bdf..3144884 100644
--- a/sshd-sftp/src/main/java/org/apache/sshd/sftp/SftpModuleProperties.java
+++ b/sshd-sftp/src/main/java/org/apache/sshd/sftp/SftpModuleProperties.java
@@ -218,7 +218,7 @@ public final class SftpModuleProperties {
             = SshServerConfigFileReader.SFTP_FORCED_VERSION_PROP;
 
     private SftpModuleProperties() {
-        // private
+        throw new UnsupportedOperationException("No instance");
     }
 
 }


[mina-sshd] 01/05: [SSHD-1056] Modified ClientFactoryManager hierarchy to include ClientSessionCreator

Posted by lg...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

lgoldstein pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/mina-sshd.git

commit 36cddc41e8c1bfc2665685fb1072757ed78cc580
Author: Lyor Goldstein <lg...@apache.org>
AuthorDate: Thu Aug 20 15:10:39 2020 +0300

    [SSHD-1056] Modified ClientFactoryManager hierarchy to include ClientSessionCreator
---
 .../main/java/org/apache/sshd/cli/client/SshClientCliSupport.java  | 3 ++-
 .../src/main/java/org/apache/sshd/client/ClientFactoryManager.java | 2 ++
 sshd-core/src/main/java/org/apache/sshd/client/SshClient.java      | 7 +++----
 3 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/sshd-cli/src/main/java/org/apache/sshd/cli/client/SshClientCliSupport.java b/sshd-cli/src/main/java/org/apache/sshd/cli/client/SshClientCliSupport.java
index fca0403..6b17d17 100644
--- a/sshd-cli/src/main/java/org/apache/sshd/cli/client/SshClientCliSupport.java
+++ b/sshd-cli/src/main/java/org/apache/sshd/cli/client/SshClientCliSupport.java
@@ -278,7 +278,8 @@ public abstract class SshClientCliSupport extends CliSupport {
     }
     // CHECKSTYLE:ON
 
-    public static HostConfigEntry resolveHost(SshClient client, String username, String host, int port, String proxyJump)
+    public static HostConfigEntry resolveHost(
+            ClientFactoryManager client, String username, String host, int port, String proxyJump)
             throws IOException {
         HostConfigEntryResolver resolver = client.getHostConfigEntryResolver();
         HostConfigEntry entry = resolver.resolveEffectiveHost(host, port, null, username, proxyJump, null);
diff --git a/sshd-core/src/main/java/org/apache/sshd/client/ClientFactoryManager.java b/sshd-core/src/main/java/org/apache/sshd/client/ClientFactoryManager.java
index 727e31b..95139d8 100644
--- a/sshd-core/src/main/java/org/apache/sshd/client/ClientFactoryManager.java
+++ b/sshd-core/src/main/java/org/apache/sshd/client/ClientFactoryManager.java
@@ -21,6 +21,7 @@ package org.apache.sshd.client;
 import org.apache.sshd.client.config.hosts.HostConfigEntryResolver;
 import org.apache.sshd.client.config.keys.ClientIdentityLoaderManager;
 import org.apache.sshd.client.session.ClientProxyConnectorHolder;
+import org.apache.sshd.client.session.ClientSessionCreator;
 import org.apache.sshd.common.FactoryManager;
 import org.apache.sshd.common.config.keys.FilePasswordProviderManager;
 
@@ -32,6 +33,7 @@ import org.apache.sshd.common.config.keys.FilePasswordProviderManager;
  */
 public interface ClientFactoryManager
         extends FactoryManager,
+        ClientSessionCreator,
         ClientProxyConnectorHolder,
         FilePasswordProviderManager,
         ClientIdentityLoaderManager,
diff --git a/sshd-core/src/main/java/org/apache/sshd/client/SshClient.java b/sshd-core/src/main/java/org/apache/sshd/client/SshClient.java
index 724018a..d5873eb 100644
--- a/sshd-core/src/main/java/org/apache/sshd/client/SshClient.java
+++ b/sshd-core/src/main/java/org/apache/sshd/client/SshClient.java
@@ -62,7 +62,6 @@ import org.apache.sshd.client.session.AbstractClientSession;
 import org.apache.sshd.client.session.ClientConnectionServiceFactory;
 import org.apache.sshd.client.session.ClientProxyConnector;
 import org.apache.sshd.client.session.ClientSession;
-import org.apache.sshd.client.session.ClientSessionCreator;
 import org.apache.sshd.client.session.ClientUserAuthServiceFactory;
 import org.apache.sshd.client.session.SessionFactory;
 import org.apache.sshd.client.session.forward.ExplicitPortForwardingTracker;
@@ -148,15 +147,15 @@ import org.apache.sshd.core.CoreModuleProperties;
  * one instance of {@code SshClient} for the application and then use throughout - including for multi-threading. As
  * long as the {@code SshClient} is not re-configured it should be multi-thread safe regardless of the target session
  * being created.
- * 
+ *
  * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
  */
-public class SshClient extends AbstractFactoryManager implements ClientFactoryManager, ClientSessionCreator, Closeable {
+public class SshClient extends AbstractFactoryManager implements ClientFactoryManager, Closeable {
     public static final Factory<SshClient> DEFAULT_SSH_CLIENT_FACTORY = SshClient::new;
 
     /**
      * Default user authentication preferences if not set
-     * 
+     *
      * @see <A HREF="http://linux.die.net/man/5/ssh_config">ssh_config(5) - PreferredAuthentications</A>
      */
     public static final List<UserAuthFactory> DEFAULT_USER_AUTH_FACTORIES = Collections.unmodifiableList(


[mina-sshd] 05/05: [SSHD-1056] Added 3-way CLI option to SCP command

Posted by lg...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

lgoldstein pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/mina-sshd.git

commit 9fe278dc34e167f649df5a92f150bc5a0e0d63f4
Author: Lyor Goldstein <lg...@apache.org>
AuthorDate: Thu Aug 20 15:11:57 2020 +0300

    [SSHD-1056] Added 3-way CLI option to SCP command
---
 CHANGES.md                                         |   3 +-
 docs/cli.md                                        |  24 +-
 .../sshd/cli/client/CliClientModuleProperties.java |   9 +-
 .../org/apache/sshd/cli/client/ScpCommandMain.java | 397 ++++++++++++++++-----
 .../sshd/client/session/ClientUserAuthService.java |   3 +-
 .../java/org/apache/sshd/scp/client/ScpClient.java |  56 ++-
 .../scp/client/ScpRemote2RemoteTransferHelper.java |  99 +++--
 .../java/org/apache/sshd/scp/common/ScpHelper.java | 156 +++++---
 .../org/apache/sshd/scp/common/ScpLocation.java    | 141 ++++++--
 .../apache/sshd/scp/common/helpers/ScpAckInfo.java | 130 +++++++
 .../apache/sshd/scp/common/helpers/ScpIoUtils.java | 145 +-------
 .../org/apache/sshd/scp/server/ScpCommand.java     |  12 +-
 .../java/org/apache/sshd/scp/server/ScpShell.java  |  10 +-
 .../java/org/apache/sshd/scp/client/ScpTest.java   |  23 +-
 .../sshd/scp/common/ScpLocationParsingTest.java    |  92 +++++
 15 files changed, 891 insertions(+), 409 deletions(-)

diff --git a/CHANGES.md b/CHANGES.md
index e13a19e..1a8973e 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -24,6 +24,7 @@ or `-key-file` command line option.
 * [SSHD-1030](https://issues.apache.org/jira/browse/SSHD-1030) Added a NoneFileSystemFactory implementation
 * [SSHD-1042](https://issues.apache.org/jira/browse/SSHD-1042) Added more callbacks to SftpEventListener
 * [SSHD-1040](https://issues.apache.org/jira/browse/SSHD-1040) Make server key available after KEX completed.
+* [SSHD-1060](https://issues.apache.org/jira/browse/SSHD-1060) Do not store logger level in fields.
 
 ## Behavioral changes and enhancements
 
@@ -41,5 +42,5 @@ or `-key-file` command line option.
 * [SSHD-1047](https://issues.apache.org/jira/browse/SSHD-1047) Support for SSH jumps.
 * [SSHD-1048](https://issues.apache.org/jira/browse/SSHD-1048) Wrap instead of rethrow IOException in Future.
 * [SSHD-1050](https://issues.apache.org/jira/browse/SSHD-1050) Fixed race condition in AuthFuture if exception caught before authentication started.
+* [SSHD-1056](https://issues.apache.org/jira/browse/SSHD-1005) Added support for SCP remote-to-remote directory transfer - including '-3' option of SCP command CLI.
 * [SSHD-1058](https://issues.apache.org/jira/browse/SSHD-1058) Improve exception logging strategy.
-* [SSHD-1060](https://issues.apache.org/jira/browse/SSHD-1060) Do not store logger level in fields.
diff --git a/docs/cli.md b/docs/cli.md
index bc57426..f232bb7 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -97,7 +97,26 @@ to **disable** an option one must use `-o PtyMode=WHATEVER=0`.
 
 ### `ScpCommandMain`
 
-Reminiscent of the [scp(1)](https://linux.die.net/man/1/scp) CLI client.
+Reminiscent of the [scp(1)](https://man7.org/linux/man-pages/man1/scp.1.html) CLI client - including support for "3-way" copy
+(a.k.a. remote-to-remote) option:
+
+```
+scp -p -r -3 user1@server1:source user2@server2:destination
+```
+
+In this context, it is worth mentioning that the CLI also supports URI locations having the format `scp://[user@]host[:port][/path]`
+
+```
+# If port is omitted then 22 is assumed
+scp -p scp://user1@server1:2222/source/file /home/user2/destination
+
+# Note: same effect can be achieved with -P option
+
+scp -p -P 2222 user1@server1:source/file /home/user2/destination
+
+# the URI is better suited for remote-to-remote transfers
+scp -p -r -3 scp://user1@server1:2222/source scp://user2@server2:3333/destination
+```
 
 ### `SshServerMain`
 
@@ -122,3 +141,6 @@ provided when subsystems are auto-detected and/or filtered.
 * **Shell** - unless otherwise instructed, the default SSH server uses an internal shell (see `InteractiveProcessShellFactory`). The shell can be overridden
 or disabled by specifying a `-o ShellFactory=XXX` option where the value can either be `none` to specify that no shell is to be used, or the fully-qualified
 name of a class that implements the `ShellFactory` interface. The implementation must be public and have a public no-args constructor for instantiating it.
+
+**Note:** A special value of `scp` can be used to use the built-in `ScpShell` instead of the interactive one (reminder: the SCP "shell" is a limited shell that provides
+a good enough functionality for *WinScp*).
diff --git a/sshd-cli/src/main/java/org/apache/sshd/cli/client/CliClientModuleProperties.java b/sshd-cli/src/main/java/org/apache/sshd/cli/client/CliClientModuleProperties.java
index 1287784..2af0c44 100644
--- a/sshd-cli/src/main/java/org/apache/sshd/cli/client/CliClientModuleProperties.java
+++ b/sshd-cli/src/main/java/org/apache/sshd/cli/client/CliClientModuleProperties.java
@@ -28,19 +28,18 @@ import org.apache.sshd.common.Property;
  */
 public final class CliClientModuleProperties {
     /**
-     * Key used to retrieve the value of the timeout after which it will abort the connection if the connection
-     * has not been established - in milliseconds.
+     * Key used to retrieve the value of the timeout after which it will abort the connection if the connection has not
+     * been established - in milliseconds.
      */
     public static final Property<Duration> CONECT_TIMEOUT
-        = Property.duration("cli-connect-timeout", Duration.ofMinutes(2));
+            = Property.duration("cli-connect-timeout", Duration.ofMinutes(2));
 
     /**
      * Key used to retrieve the value of the timeout after which it will close the connection if the other side has not
      * been authenticated - in milliseconds.
      */
     public static final Property<Duration> AUTH_TIMEOUT
-        = Property.duration("cli-auth-timeout", Duration.ofMinutes(2));
-
+            = Property.duration("cli-auth-timeout", Duration.ofMinutes(2));
 
     private CliClientModuleProperties() {
         throw new UnsupportedOperationException("No instance");
diff --git a/sshd-cli/src/main/java/org/apache/sshd/cli/client/ScpCommandMain.java b/sshd-cli/src/main/java/org/apache/sshd/cli/client/ScpCommandMain.java
index a23eff5..cfaf2d8 100644
--- a/sshd-cli/src/main/java/org/apache/sshd/cli/client/ScpCommandMain.java
+++ b/sshd-cli/src/main/java/org/apache/sshd/cli/client/ScpCommandMain.java
@@ -20,21 +20,29 @@
 package org.apache.sshd.cli.client;
 
 import java.io.BufferedReader;
+import java.io.IOException;
 import java.io.InputStreamReader;
 import java.io.OutputStream;
 import java.io.PrintStream;
 import java.nio.charset.Charset;
 import java.nio.file.Path;
 import java.nio.file.attribute.PosixFilePermission;
+import java.security.KeyPair;
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.EnumSet;
+import java.util.Iterator;
 import java.util.List;
 import java.util.Set;
 import java.util.logging.Level;
 
 import org.apache.sshd.cli.CliSupport;
+import org.apache.sshd.client.ClientFactoryManager;
+import org.apache.sshd.client.auth.AuthenticationIdentitiesProvider;
+import org.apache.sshd.client.config.hosts.HostConfigEntry;
 import org.apache.sshd.client.session.ClientSession;
+import org.apache.sshd.common.SshConstants;
 import org.apache.sshd.common.session.Session;
 import org.apache.sshd.common.util.GenericUtils;
 import org.apache.sshd.common.util.io.NoCloseInputStream;
@@ -42,12 +50,16 @@ import org.apache.sshd.common.util.threads.ThreadUtils;
 import org.apache.sshd.scp.client.ScpClient;
 import org.apache.sshd.scp.client.ScpClient.Option;
 import org.apache.sshd.scp.client.ScpClientCreator;
+import org.apache.sshd.scp.client.ScpRemote2RemoteTransferHelper;
+import org.apache.sshd.scp.client.ScpRemote2RemoteTransferListener;
 import org.apache.sshd.scp.common.ScpLocation;
 import org.apache.sshd.scp.common.ScpTransferEventListener;
+import org.apache.sshd.scp.common.helpers.ScpReceiveDirCommandDetails;
+import org.apache.sshd.scp.common.helpers.ScpReceiveFileCommandDetails;
+import org.apache.sshd.scp.common.helpers.ScpTimestampCommandDetails;
 
 /**
- * TODO Add javadoc
- *
+ * @see    <A HREF="https://man7.org/linux/man-pages/man1/scp.1.html">SCP(1) - manual page</A>
  * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
  */
 public class ScpCommandMain extends SshClientCliSupport {
@@ -56,11 +68,16 @@ public class ScpCommandMain extends SshClientCliSupport {
      */
     public static final String SCP_PORT_OPTION = "-P";
 
+    /**
+     * Copies between two remote hosts are transferred through the local host
+     */
+    public static final String SCP_REMOTE_TO_REMOTE_OPTION = "-3";
+
     public ScpCommandMain() {
         super(); // in case someone wants to extend it
     }
 
-    //////////////////////////////////////////////////////////////////////////
+    /* -------------------------------------------------------------------------------- */
 
     public static String[] normalizeCommandArguments(PrintStream stdout, PrintStream stderr, String... args) {
         int numArgs = GenericUtils.length(args);
@@ -70,6 +87,7 @@ public class ScpCommandMain extends SshClientCliSupport {
 
         List<String> effective = new ArrayList<>(numArgs);
         boolean error = false;
+        boolean threeWay = false;
         for (int index = 0; (index < numArgs) && (!error); index++) {
             String argName = args[index];
             // handled by 'setupClientSession'
@@ -86,6 +104,9 @@ public class ScpCommandMain extends SshClientCliSupport {
                     || "-q".equals(argName) || "-C".equals(argName)
                     || "-v".equals(argName) || "-vv".equals(argName) || "-vvv".equals(argName)) {
                 effective.add(argName);
+            } else if (SCP_REMOTE_TO_REMOTE_OPTION.equals(argName)) {
+                threeWay = true;
+                effective.add(argName);
             } else if (argName.charAt(0) == '-') {
                 error = showError(stderr, "Unknown option: " + argName);
                 break;
@@ -103,15 +124,22 @@ public class ScpCommandMain extends SshClientCliSupport {
                     break;
                 }
 
-                if (source.isLocal() == target.isLocal()) {
-                    error = showError(stderr, "Both targets are either remote or local");
-                    break;
-                }
+                if (threeWay) {
+                    if (source.isLocal() || target.isLocal()) {
+                        error = showError(stderr, "Both targets must be remote for the 3-way copy option");
+                        break;
+                    }
+
+                    adjustRemoteTargetArguments(source, source, target, effective);
+                } else {
+                    if (source.isLocal() == target.isLocal()) {
+                        error = showError(stderr, "Both targets are either remote or local");
+                        break;
+                    }
 
-                ScpLocation remote = source.isLocal() ? target : source;
-                effective.add(remote.resolveUsername() + "@" + remote.getHost());
-                effective.add(source.toString());
-                effective.add(target.toString());
+                    ScpLocation remote = source.isLocal() ? target : source;
+                    adjustRemoteTargetArguments(remote, source, target, effective);
+                }
                 break;
             }
         }
@@ -123,6 +151,23 @@ public class ScpCommandMain extends SshClientCliSupport {
         return effective.toArray(new String[effective.size()]);
     }
 
+    /* -------------------------------------------------------------------------------- */
+
+    private static void adjustRemoteTargetArguments(
+            ScpLocation remote, ScpLocation source, ScpLocation target, Collection<String> effective) {
+        int port = remote.resolvePort();
+        if (port != SshConstants.DEFAULT_PORT) {
+            effective.add(SCP_PORT_OPTION);
+            effective.add(Integer.toString(port));
+        }
+
+        effective.add(remote.resolveUsername() + "@" + remote.getHost());
+        effective.add(source.toString());
+        effective.add(target.toString());
+    }
+
+    /* -------------------------------------------------------------------------------- */
+
     public static ScpClientCreator resolveScpClientCreator(PrintStream stderr, String... args) {
         String className = null;
         for (int index = 0, numArgs = GenericUtils.length(args); index < numArgs; index++) {
@@ -159,6 +204,233 @@ public class ScpCommandMain extends SshClientCliSupport {
         }
     }
 
+    /* -------------------------------------------------------------------------------- */
+
+    public static Set<Option> parseCopyOptions(String[] args) {
+        if (GenericUtils.isEmpty(args)) {
+            return Collections.emptySet();
+        }
+
+        Set<Option> options = EnumSet.noneOf(Option.class);
+        for (String argName : args) {
+            if ("-r".equals(argName)) {
+                options.add(Option.TargetIsDirectory);
+                options.add(Option.Recursive);
+            } else if ("-p".equals(argName)) {
+                options.add(Option.PreserveAttributes);
+            }
+        }
+
+        return options;
+    }
+
+    /* -------------------------------------------------------------------------------- */
+
+    public static void showUsageMessage(PrintStream stderr) {
+        stderr.println("usage: scp [" + SCP_PORT_OPTION + " port] [-i identity] [-io nio2|mina|netty]"
+                       + " [" + SCP_REMOTE_TO_REMOTE_OPTION + "]"
+                       + " [" + Option.Recursive.getOptionValue() + "]"
+                       + " [" + Option.PreserveAttributes.getOptionValue() + "]"
+                       + " [-v[v][v]] [-E logoutput] [-q] [-o option=value] [-o creator=class name]"
+                       + " [-c cipherlist] [-m maclist] [-J proxyJump] [-w password] [-C] <source> <target>");
+        stderr.println();
+        stderr.println("Where <source> or <target> are either 'user@host:file' or a local file path");
+        stderr.println("NOTE: exactly ONE of the source or target must be remote and the other one local");
+        stderr.println("    or both remote if " + SCP_REMOTE_TO_REMOTE_OPTION + " specified");
+    }
+
+    /* -------------------------------------------------------------------------------- */
+
+    @SuppressWarnings("checkstyle:ParameterNumber")
+    public static void xferLocalToRemote(
+            BufferedReader stdin, PrintStream stdout, PrintStream stderr, String[] args,
+            ScpLocation source, ScpLocation target, Collection<Option> options,
+            OutputStream logStream, Level level, boolean quiet)
+            throws Exception {
+        ScpClientCreator creator = resolveScpClientCreator(stderr, args);
+        ClientSession session = ((logStream == null) || (creator == null) || GenericUtils.isEmpty(args))
+                ? null : setupClientSession(SCP_PORT_OPTION, stdin, level, stdout, stderr, args);
+        if (session == null) {
+            showUsageMessage(stderr);
+            System.exit(-1);
+            return; // not that we really need it...
+        }
+
+        try {
+            if (!quiet) {
+                creator.setScpTransferEventListener(new ScpTransferEventListener() {
+                    @Override
+                    public void startFolderEvent(
+                            Session session, FileOperation op, Path file, Set<PosixFilePermission> perms) {
+                        logEvent("startFolderEvent", session, op, file, -1L, perms, null);
+                    }
+
+                    @Override
+                    public void endFolderEvent(
+                            Session session, FileOperation op, Path file, Set<PosixFilePermission> perms,
+                            Throwable thrown) {
+                        logEvent("endFolderEvent", session, op, file, -1L, perms, thrown);
+                    }
+
+                    @Override
+                    public void startFileEvent(
+                            Session session, FileOperation op, Path file, long length, Set<PosixFilePermission> perms) {
+                        logEvent("startFileEvent", session, op, file, length, perms, null);
+                    }
+
+                    @Override
+                    public void endFileEvent(
+                            Session session, FileOperation op, Path file, long length, Set<PosixFilePermission> perms,
+                            Throwable thrown) {
+                        logEvent("endFileEvent", session, op, file, length, perms, thrown);
+                    }
+
+                    private void logEvent(
+                            String name, Session session, FileOperation op, Path file, long length,
+                            Collection<PosixFilePermission> perms, Throwable thrown) {
+                        PrintStream ps = (thrown == null) ? stdout : stderr;
+                        ps.append("    ").append(name)
+                                .append('[').append(session.toString()).append(']')
+                                .append('[').append(op.name()).append(']')
+                                .append(' ').append(file.toString());
+                        if (length > 0L) {
+                            ps.append(' ').append("length=").append(Long.toString(length));
+                        }
+                        ps.append(' ').append(String.valueOf(perms));
+
+                        if (thrown != null) {
+                            ps.append(" - ").append(thrown.getClass().getSimpleName()).append(": ")
+                                    .append(thrown.getMessage());
+                        }
+                        ps.println();
+                    }
+                });
+            }
+
+            ScpClient client = creator.createScpClient(session);
+            if (source.isLocal()) {
+                client.upload(source.getPath(), target.getPath(), options);
+            } else {
+                client.download(source.getPath(), target.getPath(), options);
+            }
+        } finally {
+            session.close();
+        }
+
+    }
+
+    /* -------------------------------------------------------------------------------- */
+
+    @SuppressWarnings("checkstyle:ParameterNumber")
+    public static void xferRemoteToRemote(
+            BufferedReader stdin, PrintStream stdout, PrintStream stderr, String[] args,
+            ScpLocation source, ScpLocation target, Collection<Option> options,
+            OutputStream logStream, Level level, boolean quiet)
+            throws Exception {
+        ClientSession srcSession = ((logStream == null) || GenericUtils.isEmpty(args))
+                ? null : setupClientSession(SCP_PORT_OPTION, stdin, level, stdout, stderr, args);
+        if (srcSession == null) {
+            showUsageMessage(stderr);
+            System.exit(-1);
+            return; // not that we really need it...
+        }
+
+        try {
+            ClientFactoryManager manager = srcSession.getFactoryManager();
+            // TODO see if there is a way to specify a different port or proxy jump for the target
+            HostConfigEntry entry = resolveHost(
+                    manager, target.resolveUsername(), target.getHost(), target.resolvePort(), null);
+            // TODO use a configurable wait time
+            ClientSession dstSession = manager.connect(entry, null, null)
+                    .verify(CliClientModuleProperties.CONECT_TIMEOUT.getRequired(srcSession))
+                    .getSession();
+            try {
+                // TODO see if there is a way to specify different password/key for target
+                // copy non-default identities from source session
+                AuthenticationIdentitiesProvider provider = srcSession.getRegisteredIdentities();
+                Iterable<?> ids = (provider == null) ? null : provider.loadIdentities();
+                Iterator<?> iter = (ids == null) ? null : ids.iterator();
+                while ((iter != null) && iter.hasNext()) {
+                    Object v = iter.next();
+                    if (v instanceof String) {
+                        dstSession.addPasswordIdentity((String) v);
+                    } else if (v instanceof KeyPair) {
+                        dstSession.addPublicKeyIdentity((KeyPair) v);
+                    } else {
+                        throw new UnsupportedOperationException("Unsupported source identity: " + v);
+                    }
+                }
+
+                dstSession.auth().verify(CliClientModuleProperties.AUTH_TIMEOUT.getRequired(dstSession));
+
+                ScpRemote2RemoteTransferListener listener = quiet ? null : new ScpRemote2RemoteTransferListener() {
+                    @Override
+                    public void startDirectFileTransfer(
+                            ClientSession srcSession, String source,
+                            ClientSession dstSession, String destination,
+                            ScpTimestampCommandDetails timestamp, ScpReceiveFileCommandDetails details)
+                            throws IOException {
+                        logEvent("FILE-START: ", source, destination, null);
+                    }
+
+                    @Override
+                    public void startDirectDirectoryTransfer(
+                            ClientSession srcSession, String source,
+                            ClientSession dstSession, String destination,
+                            ScpTimestampCommandDetails timestamp, ScpReceiveDirCommandDetails details)
+                            throws IOException {
+                        logEvent("DIR-START: ", source, destination, null);
+                    }
+
+                    @Override
+                    public void endDirectFileTransfer(
+                            ClientSession srcSession, String source,
+                            ClientSession dstSession, String destination,
+                            ScpTimestampCommandDetails timestamp, ScpReceiveFileCommandDetails details,
+                            long xferSize, Throwable thrown)
+                            throws IOException {
+                        logEvent("FILE-END: ", source, destination, thrown);
+                    }
+
+                    @Override
+                    public void endDirectDirectoryTransfer(
+                            ClientSession srcSession, String source,
+                            ClientSession dstSession, String destination,
+                            ScpTimestampCommandDetails timestamp, ScpReceiveDirCommandDetails details,
+                            Throwable thrown)
+                            throws IOException {
+                        logEvent("DIR-END: ", source, destination, thrown);
+                    }
+
+                    private void logEvent(String event, String src, String dst, Throwable thrown) {
+                        PrintStream ps = (thrown == null) ? stdout : stderr;
+                        ps.append("    ").append(event)
+                                .append(' ').append(src).append(" ==> ").append(dst);
+                        if (thrown != null) {
+                            ps.append(" - ").append(thrown.getClass().getSimpleName()).append(": ")
+                                    .append(thrown.getMessage());
+                        }
+                        ps.println();
+                    }
+                };
+                ScpRemote2RemoteTransferHelper helper = new ScpRemote2RemoteTransferHelper(srcSession, dstSession, listener);
+                boolean preserveAttributes = GenericUtils.isNotEmpty(options) && options.contains(Option.PreserveAttributes);
+                if (GenericUtils.isNotEmpty(options)
+                        && (options.contains(Option.Recursive) || options.contains(Option.TargetIsDirectory))) {
+                    helper.transferDirectory(source.getPath(), target.getPath(), preserveAttributes);
+                } else {
+                    helper.transferFile(source.getPath(), target.getPath(), preserveAttributes);
+                }
+            } finally {
+                dstSession.close();
+            }
+        } finally {
+            srcSession.close();
+        }
+    }
+
+    //////////////////////////////////////////////////////////////////////////
+
     public static void main(String[] args) throws Exception {
         PrintStream stdout = System.out;
         PrintStream stderr = System.err;
@@ -178,95 +450,26 @@ public class ScpCommandMain extends SshClientCliSupport {
                 }
             }
 
-            ScpClientCreator creator = resolveScpClientCreator(stderr, args);
-            ClientSession session = ((logStream == null) || (creator == null) || GenericUtils.isEmpty(args))
-                    ? null : setupClientSession(SCP_PORT_OPTION, stdin, level, stdout, stderr, args);
-            if (session == null) {
-                stderr.println("usage: scp [" + SCP_PORT_OPTION + " port] [-i identity] [-io nio2|mina|netty]"
-                               + " [-v[v][v]] [-E logoutput] [-r] [-p] [-q] [-o option=value] [-o creator=class name]"
-                               + " [-c cipherlist] [-m maclist] [-J proxyJump] [-w password] [-C] <source> <target>");
-                stderr.println();
-                stderr.println("Where <source> or <target> are either 'user@host:file' or a local file path");
-                stderr.println("NOTE: exactly ONE of the source or target must be remote and the other one local");
-                System.exit(-1);
-                return; // not that we really need it...
-            }
-
-            try {
-                // see the way normalizeCommandArguments works...
-                Collection<Option> options = EnumSet.noneOf(Option.class);
-                boolean quiet = false;
-                for (int index = 0; index < numArgs; index++) {
-                    String argName = args[index];
-                    if ("-r".equals(argName)) {
-                        options.add(Option.Recursive);
-                    } else if ("-p".equals(argName)) {
-                        options.add(Option.PreserveAttributes);
-                    } else if ("-q".equals(argName)) {
-                        quiet = true;
-                    }
-                }
-
-                if (!quiet) {
-                    creator.setScpTransferEventListener(new ScpTransferEventListener() {
-                        @Override
-                        public void startFolderEvent(
-                                Session session, FileOperation op, Path file, Set<PosixFilePermission> perms) {
-                            logEvent("startFolderEvent", session, op, file, -1L, perms, null);
-                        }
-
-                        @Override
-                        public void endFolderEvent(
-                                Session session, FileOperation op, Path file, Set<PosixFilePermission> perms,
-                                Throwable thrown) {
-                            logEvent("endFolderEvent", session, op, file, -1L, perms, thrown);
-                        }
-
-                        @Override
-                        public void startFileEvent(
-                                Session session, FileOperation op, Path file, long length, Set<PosixFilePermission> perms) {
-                            logEvent("startFileEvent", session, op, file, length, perms, null);
-                        }
-
-                        @Override
-                        public void endFileEvent(
-                                Session session, FileOperation op, Path file, long length, Set<PosixFilePermission> perms,
-                                Throwable thrown) {
-                            logEvent("endFileEvent", session, op, file, length, perms, thrown);
-                        }
+            // see the way normalizeCommandArguments works...
+            ScpLocation source = (numArgs >= 2) ? new ScpLocation(args[numArgs - 2]) : null;
+            ScpLocation target = (numArgs >= 2) ? new ScpLocation(args[numArgs - 1]) : null;
 
-                        private void logEvent(
-                                String name, Session session, FileOperation op, Path file, long length,
-                                Collection<PosixFilePermission> perms, Throwable thrown) {
-                            PrintStream ps = (thrown == null) ? stdout : stderr;
-                            ps.append("    ").append(name)
-                                    .append('[').append(session.toString()).append(']')
-                                    .append('[').append(op.name()).append(']')
-                                    .append(' ').append(file.toString());
-                            if (length > 0L) {
-                                ps.append(' ').append("length=").append(Long.toString(length));
-                            }
-                            ps.append(' ').append(String.valueOf(perms));
-
-                            if (thrown != null) {
-                                ps.append(" - ").append(thrown.getClass().getSimpleName()).append(": ")
-                                        .append(thrown.getMessage());
-                            }
-                            ps.println();
-                        }
-                    });
+            Collection<Option> options = parseCopyOptions(args);
+            boolean quiet = false;
+            boolean threeWay = false;
+            for (int index = 0; index < numArgs; index++) {
+                String argName = args[index];
+                if ("-q".equals(argName)) {
+                    quiet = true;
+                } else if (SCP_REMOTE_TO_REMOTE_OPTION.equals(argName)) {
+                    threeWay = true;
                 }
+            }
 
-                ScpClient client = creator.createScpClient(session);
-                ScpLocation source = new ScpLocation(args[numArgs - 2]);
-                ScpLocation target = new ScpLocation(args[numArgs - 1]);
-                if (source.isLocal()) {
-                    client.upload(source.getPath(), target.getPath(), options);
-                } else {
-                    client.download(source.getPath(), target.getPath(), options);
-                }
-            } finally {
-                session.close();
+            if (threeWay) {
+                xferRemoteToRemote(stdin, stdout, stderr, args, source, target, options, logStream, level, quiet);
+            } else {
+                xferLocalToRemote(stdin, stdout, stderr, args, source, target, options, logStream, level, quiet);
             }
         } finally {
             if ((logStream != stdout) && (logStream != stderr)) {
diff --git a/sshd-core/src/main/java/org/apache/sshd/client/session/ClientUserAuthService.java b/sshd-core/src/main/java/org/apache/sshd/client/session/ClientUserAuthService.java
index e328eec..52410b7 100644
--- a/sshd-core/src/main/java/org/apache/sshd/client/session/ClientUserAuthService.java
+++ b/sshd-core/src/main/java/org/apache/sshd/client/session/ClientUserAuthService.java
@@ -279,9 +279,8 @@ public class ClientUserAuthService extends AbstractCloseable implements Service,
 
     protected void tryNext(int cmd) throws Exception {
         ClientSession session = getClientSession();
-        boolean debugEnabled = log.isDebugEnabled();
         // Loop until we find something to try
-        while (true) {
+        for (boolean debugEnabled = log.isDebugEnabled();; debugEnabled = log.isDebugEnabled()) {
             if (userAuth == null) {
                 if (debugEnabled) {
                     log.debug("tryNext({}) starting authentication mechanisms: client={}, server={}",
diff --git a/sshd-scp/src/main/java/org/apache/sshd/scp/client/ScpClient.java b/sshd-scp/src/main/java/org/apache/sshd/scp/client/ScpClient.java
index ef781bc..c262ad6 100644
--- a/sshd-scp/src/main/java/org/apache/sshd/scp/client/ScpClient.java
+++ b/sshd-scp/src/main/java/org/apache/sshd/scp/client/ScpClient.java
@@ -40,9 +40,23 @@ import org.apache.sshd.scp.common.helpers.ScpTimestampCommandDetails;
  */
 public interface ScpClient extends SessionHolder<ClientSession>, ClientSessionHolder {
     enum Option {
-        Recursive,
-        PreserveAttributes,
-        TargetIsDirectory
+        Recursive("-r"),
+        PreserveAttributes("-p"),
+        TargetIsDirectory("-d"),
+        ;
+
+        private final String optionValue;
+
+        Option(String optionValue) {
+            this.optionValue = optionValue;
+        }
+
+        /**
+         * @return The option value to use in the issued SCP command
+         */
+        public String getOptionValue() {
+            return optionValue;
+        }
     }
 
     @Override
@@ -136,15 +150,9 @@ public interface ScpClient extends SessionHolder<ClientSession>, ClientSessionHo
 
     static String createSendCommand(String remote, Collection<Option> options) {
         StringBuilder sb = new StringBuilder(remote.length() + Long.SIZE).append(ScpHelper.SCP_COMMAND_PREFIX);
-        if (options.contains(Option.Recursive)) {
-            sb.append(" -r");
-        }
-        if (options.contains(Option.TargetIsDirectory)) {
-            sb.append(" -d");
-        }
-        if (options.contains(Option.PreserveAttributes)) {
-            sb.append(" -p");
-        }
+        appendCommandOption(sb, options, Option.TargetIsDirectory);
+        appendCommandOption(sb, options, Option.Recursive);
+        appendCommandOption(sb, options, Option.PreserveAttributes);
 
         sb.append(" -t").append(" --").append(' ').append(remote);
         return sb.toString();
@@ -153,14 +161,26 @@ public interface ScpClient extends SessionHolder<ClientSession>, ClientSessionHo
     static String createReceiveCommand(String remote, Collection<Option> options) {
         ValidateUtils.checkNotNullAndNotEmpty(remote, "No remote location specified");
         StringBuilder sb = new StringBuilder(remote.length() + Long.SIZE).append(ScpHelper.SCP_COMMAND_PREFIX);
-        if (options.contains(Option.Recursive)) {
-            sb.append(" -r");
-        }
-        if (options.contains(Option.PreserveAttributes)) {
-            sb.append(" -p");
-        }
+        appendCommandOption(sb, options, Option.Recursive);
+        appendCommandOption(sb, options, Option.PreserveAttributes);
 
         sb.append(" -f").append(" --").append(' ').append(remote);
         return sb.toString();
     }
+
+    /**
+     * Appends the specified option command value if appears in provided options collection
+     *
+     * @param  sb      The {@link StringBuilder} target
+     * @param  options The command options - ignored if {@code null}
+     * @param  opt     The required option
+     * @return         The updated builder
+     */
+    static StringBuilder appendCommandOption(StringBuilder sb, Collection<Option> options, Option opt) {
+        if (GenericUtils.isNotEmpty(options) && options.contains(opt)) {
+            sb.append(' ').append(opt.getOptionValue());
+        }
+
+        return sb;
+    }
 }
diff --git a/sshd-scp/src/main/java/org/apache/sshd/scp/client/ScpRemote2RemoteTransferHelper.java b/sshd-scp/src/main/java/org/apache/sshd/scp/client/ScpRemote2RemoteTransferHelper.java
index 07a8ae2..387ebbd 100644
--- a/sshd-scp/src/main/java/org/apache/sshd/scp/client/ScpRemote2RemoteTransferHelper.java
+++ b/sshd-scp/src/main/java/org/apache/sshd/scp/client/ScpRemote2RemoteTransferHelper.java
@@ -38,6 +38,7 @@ import org.apache.sshd.common.util.io.LimitInputStream;
 import org.apache.sshd.common.util.logging.AbstractLoggingBean;
 import org.apache.sshd.scp.client.ScpClient.Option;
 import org.apache.sshd.scp.common.helpers.AbstractScpCommandDetails;
+import org.apache.sshd.scp.common.helpers.ScpAckInfo;
 import org.apache.sshd.scp.common.helpers.ScpDirEndCommandDetails;
 import org.apache.sshd.scp.common.helpers.ScpIoUtils;
 import org.apache.sshd.scp.common.helpers.ScpPathCommandDetailsSupport;
@@ -143,8 +144,8 @@ public class ScpRemote2RemoteTransferHelper extends AbstractLoggingBean {
             ChannelExec dstChannel = ScpIoUtils.openCommandChannel(dstSession, dstCmd, log);
             try (InputStream dstIn = dstChannel.getInvertedOut();
                  OutputStream dstOut = dstChannel.getInvertedIn()) {
-                int statusCode = transferStatusCode("XFER-CMD", dstIn, srcOut);
-                ScpIoUtils.validateCommandStatusCode("XFER-CMD", "executeTransfer", statusCode, false);
+                ScpAckInfo ackInfo = transferStatusCode("XFER-CMD", dstIn, srcOut);
+                ackInfo.validateCommandStatusCode("XFER-CMD", "executeTransfer");
 
                 if (srcOptions.contains(Option.TargetIsDirectory) || dstOptions.contains(Option.TargetIsDirectory)) {
                     redirectDirectoryTransfer(source, srcIn, srcOut, destination, dstIn, dstOut, 0);
@@ -163,8 +164,13 @@ public class ScpRemote2RemoteTransferHelper extends AbstractLoggingBean {
             String source, InputStream srcIn, OutputStream srcOut,
             String destination, InputStream dstIn, OutputStream dstOut)
             throws IOException {
+        Object data = receiveNextCmd("redirectFileTransfer", srcIn);
+        if (data instanceof ScpAckInfo) {
+            throw new StreamCorruptedException("Unexpected ACK instead of header: " + data);
+        }
+
         boolean debugEnabled = log.isDebugEnabled();
-        String header = ScpIoUtils.readLine(srcIn, false);
+        String header = (String) data;
         if (debugEnabled) {
             log.debug("redirectFileTransfer({}) {} => {}: header={}", this, source, destination, header);
         }
@@ -194,8 +200,8 @@ public class ScpRemote2RemoteTransferHelper extends AbstractLoggingBean {
         }
 
         ScpIoUtils.writeLine(dstOut, header);
-        int statusCode = transferStatusCode(header, dstIn, srcOut);
-        ScpIoUtils.validateCommandStatusCode("[DST] " + header, "handleFileTransferRequest", statusCode, false);
+        ScpAckInfo ackInfo = transferStatusCode(header, dstIn, srcOut);
+        ackInfo.validateCommandStatusCode("[DST] " + header, "handleFileTransferRequest");
 
         ScpReceiveFileCommandDetails fileDetails = new ScpReceiveFileCommandDetails(header);
         signalReceivedCommand(fileDetails);
@@ -228,8 +234,13 @@ public class ScpRemote2RemoteTransferHelper extends AbstractLoggingBean {
             String destination, InputStream dstIn, OutputStream dstOut,
             int depth)
             throws IOException {
+        Object data = receiveNextCmd("redirectDirectoryTransfer", srcIn);
+        if (data instanceof ScpAckInfo) {
+            throw new StreamCorruptedException("Unexpected ACK instead of header: " + data);
+        }
+
+        String header = (String) data;
         boolean debugEnabled = log.isDebugEnabled();
-        String header = ScpIoUtils.readLine(srcIn, false);
         if (debugEnabled) {
             log.debug("redirectDirectoryTransfer({})[depth={}] {} => {}: header={}",
                     this, depth, source, destination, header);
@@ -262,9 +273,8 @@ public class ScpRemote2RemoteTransferHelper extends AbstractLoggingBean {
         }
 
         ScpIoUtils.writeLine(dstOut, header);
-        int statusCode = transferStatusCode(header, dstIn, srcOut);
-        ScpIoUtils.validateCommandStatusCode("[DST@" + depth + "] " + header, "handleDirectoryTransferRequest", statusCode,
-                false);
+        ScpAckInfo ackInfo = transferStatusCode(header, dstIn, srcOut);
+        ackInfo.validateCommandStatusCode("[DST@" + depth + "] " + header, "handleDirectoryTransferRequest");
 
         ScpReceiveDirCommandDetails dirDetails = new ScpReceiveDirCommandDetails(header);
         signalReceivedCommand(dirDetails);
@@ -284,7 +294,12 @@ public class ScpRemote2RemoteTransferHelper extends AbstractLoggingBean {
             for (boolean debugEnabled = log.isDebugEnabled(), dirEndSignal = false;
                  !dirEndSignal;
                  debugEnabled = log.isDebugEnabled()) {
-                header = ScpIoUtils.readLine(srcIn, false);
+                Object data = receiveNextCmd("handleDirectoryTransferRequest", srcIn);
+                if (data instanceof ScpAckInfo) {
+                    throw new StreamCorruptedException("Unexpected ACK instead of header: " + data);
+                }
+
+                header = (String) data;
                 if (debugEnabled) {
                     log.debug("handleDirectoryTransferRequest({})[depth={}] {} => {}: header={}",
                             this, depth, source, destination, header);
@@ -325,9 +340,8 @@ public class ScpRemote2RemoteTransferHelper extends AbstractLoggingBean {
 
                     case ScpDirEndCommandDetails.COMMAND_NAME: {
                         ScpIoUtils.writeLine(dstOut, header);
-                        statusCode = transferStatusCode(header, dstIn, srcOut);
-                        ScpIoUtils.validateCommandStatusCode("[DST@" + depth + "] " + header, "handleDirectoryTransferRequest",
-                                statusCode, false);
+                        ackInfo = transferStatusCode(header, dstIn, srcOut);
+                        ackInfo.validateCommandStatusCode("[DST@" + depth + "] " + header, "handleDirectoryTransferRequest");
 
                         ScpDirEndCommandDetails details = ScpDirEndCommandDetails.parse(header);
                         signalReceivedCommand(details);
@@ -363,7 +377,7 @@ public class ScpRemote2RemoteTransferHelper extends AbstractLoggingBean {
 
         long xferCount;
         try (InputStream inputStream = new LimitInputStream(srcIn, length)) {
-            ScpIoUtils.ack(srcOut); // ready to receive the data from source
+            ScpAckInfo.sendOk(srcOut); // ready to receive the data from source
             xferCount = IoUtils.copy(inputStream, dstOut);
             dstOut.flush(); // make sure all data sent to destination
         }
@@ -374,12 +388,12 @@ public class ScpRemote2RemoteTransferHelper extends AbstractLoggingBean {
         }
 
         // wait for source to signal data finished and pass it along
-        int statusCode = transferStatusCode("SRC-EOF", srcIn, dstOut);
-        ScpIoUtils.validateCommandStatusCode("[SRC-EOF] " + header, "transferSimpleFile", statusCode, false);
+        ScpAckInfo ackInfo = transferStatusCode("SRC-EOF", srcIn, dstOut);
+        ackInfo.validateCommandStatusCode("[SRC-EOF] " + header, "transferSimpleFile");
 
         // wait for destination to signal data received
-        statusCode = ScpIoUtils.readAck(dstIn, false, log, "DST-EOF");
-        ScpIoUtils.validateCommandStatusCode("[DST-EOF] " + header, "transferSimpleFile", statusCode, false);
+        ackInfo = ScpAckInfo.readAck(dstIn, false);
+        ackInfo.validateCommandStatusCode("[DST-EOF] " + header, "transferSimpleFile");
         return xferCount;
     }
 
@@ -389,35 +403,48 @@ public class ScpRemote2RemoteTransferHelper extends AbstractLoggingBean {
             String header)
             throws IOException {
         ScpIoUtils.writeLine(dstOut, header);
-        int statusCode = transferStatusCode(header, dstIn, srcOut);
-        ScpIoUtils.validateCommandStatusCode("[DST] " + header, "transferTimestampCommand", statusCode, false);
+        ScpAckInfo ackInfo = transferStatusCode(header, dstIn, srcOut);
+        ackInfo.validateCommandStatusCode("[DST] " + header, "transferTimestampCommand");
 
-        header = ScpIoUtils.readLine(srcIn, false);
-        return header;
+        Object data = receiveNextCmd("transferTimestampCommand", srcIn);
+        if (data instanceof ScpAckInfo) {
+            throw new StreamCorruptedException("Unexpected ACK instead of header: " + data);
+        }
+        return (String) data;
     }
 
-    protected int transferStatusCode(Object logHint, InputStream in, OutputStream out) throws IOException {
-        int statusCode = in.read();
-        if (statusCode == -1) {
-            throw new EOFException("readAck(" + logHint + ") - EOF before ACK");
+    protected ScpAckInfo transferStatusCode(Object logHint, InputStream in, OutputStream out) throws IOException {
+        ScpAckInfo ackInfo = ScpAckInfo.readAck(in, false);
+        if (log.isDebugEnabled()) {
+            log.debug("transferStatusCode({})[{}] {}", this, logHint, ackInfo);
+        }
+        ackInfo.send(out);
+        return ackInfo;
+    }
+
+    // NOTE: we rely on the fact that an SCP command does not start with an ACK code
+    protected Object receiveNextCmd(Object logHint, InputStream in) throws IOException {
+        int c = in.read();
+        if (c == -1) {
+            throw new EOFException(logHint + " - premature EOF while waiting for next command");
         }
 
-        if (statusCode != ScpIoUtils.OK) {
-            String line = ScpIoUtils.readLine(in);
+        if (c == ScpAckInfo.OK) {
             if (log.isDebugEnabled()) {
-                log.debug("transferStatusCode({})[{}] status={}, line='{}'", this, logHint, statusCode, line);
+                log.debug("receiveNextCmd({})[{}] - ACK={}", this, logHint, c);
             }
-            out.write(statusCode);
-            ScpIoUtils.writeLine(out, line);
-        } else {
+            return new ScpAckInfo(c);
+        }
+
+        String line = ScpIoUtils.readLine(in, false);
+        if ((c == ScpAckInfo.WARNING) || (c == ScpAckInfo.ERROR)) {
             if (log.isDebugEnabled()) {
-                log.debug("transferStatusCode({})[{}] status={}", this, logHint, statusCode);
+                log.debug("receiveNextCmd({})[{}] - ACK={}", this, logHint, new ScpAckInfo(c, line));
             }
-            out.write(statusCode);
-            out.flush();
+            return new ScpAckInfo(c, line);
         }
 
-        return statusCode;
+        return Character.toString((char) c) + line;
     }
 
     // Useful "hook" for implementors
diff --git a/sshd-scp/src/main/java/org/apache/sshd/scp/common/ScpHelper.java b/sshd-scp/src/main/java/org/apache/sshd/scp/common/ScpHelper.java
index 1a68d86..5db850c 100644
--- a/sshd-scp/src/main/java/org/apache/sshd/scp/common/ScpHelper.java
+++ b/sshd-scp/src/main/java/org/apache/sshd/scp/common/ScpHelper.java
@@ -18,6 +18,7 @@
  */
 package org.apache.sshd.scp.common;
 
+import java.io.EOFException;
 import java.io.File;
 import java.io.IOException;
 import java.io.InputStream;
@@ -46,6 +47,7 @@ import org.apache.sshd.common.util.io.LimitInputStream;
 import org.apache.sshd.common.util.logging.AbstractLoggingBean;
 import org.apache.sshd.scp.common.ScpTransferEventListener.FileOperation;
 import org.apache.sshd.scp.common.helpers.DefaultScpFileOpener;
+import org.apache.sshd.scp.common.helpers.ScpAckInfo;
 import org.apache.sshd.scp.common.helpers.ScpDirEndCommandDetails;
 import org.apache.sshd.scp.common.helpers.ScpIoUtils;
 import org.apache.sshd.scp.common.helpers.ScpPathCommandDetailsSupport;
@@ -167,14 +169,15 @@ public class ScpHelper extends AbstractLoggingBean implements SessionHolder<Sess
      * @throws IOException If failed to read/write
      */
     protected void receive(ScpReceiveLineHandler handler) throws IOException {
-        ack();
+        sendOk();
 
         boolean debugEnabled = log.isDebugEnabled();
         Session session = getSession();
         for (ScpTimestampCommandDetails time = null;; debugEnabled = log.isDebugEnabled()) {
             String line;
             boolean isDir = false;
-            int c = readAck(true);
+
+            int c = receiveNextCmd();
             switch (c) {
                 case -1:
                     return;
@@ -200,7 +203,7 @@ public class ScpHelper extends AbstractLoggingBean implements SessionHolder<Sess
                         log.debug("receive({}) - Received 'T' header: {}", this, line);
                     }
                     time = ScpTimestampCommandDetails.parse(line);
-                    ack();
+                    sendOk();
                     continue;
                 case ScpDirEndCommandDetails.COMMAND_NAME:
                     line = ScpIoUtils.readLine(in);
@@ -208,7 +211,7 @@ public class ScpHelper extends AbstractLoggingBean implements SessionHolder<Sess
                     if (debugEnabled) {
                         log.debug("receive({}) - Received 'E' header: {}", this, line);
                     }
-                    ack();
+                    sendOk();
                     return;
                 default:
                     // a real ack that has been acted upon already
@@ -223,6 +226,27 @@ public class ScpHelper extends AbstractLoggingBean implements SessionHolder<Sess
         }
     }
 
+    // NOTE: we rely on the fact that an SCP command does not start with an ACK code
+    protected int receiveNextCmd() throws IOException {
+        int c = in.read();
+        if (c == -1) {
+            return c;
+        }
+
+        if (c == ScpAckInfo.OK) {
+            return c;
+        }
+
+        if ((c == ScpAckInfo.WARNING) || (c == ScpAckInfo.ERROR)) {
+            String line = ScpIoUtils.readLine(in, true);
+            if (log.isDebugEnabled()) {
+                log.debug("receiveNextCmd - ACK={}", new ScpAckInfo(c, line));
+            }
+        }
+
+        return c;
+    }
+
     public void receiveDir(String header, Path local, ScpTimestampCommandDetails time, boolean preserve, int bufferSize)
             throws IOException {
         Path path = Objects.requireNonNull(local, "No local path").normalize().toAbsolutePath();
@@ -243,7 +267,7 @@ public class ScpHelper extends AbstractLoggingBean implements SessionHolder<Sess
         Set<PosixFilePermission> perms = details.getPermissions();
         Path file = opener.resolveIncomingFilePath(session, path, name, preserve, perms, time);
 
-        ack();
+        sendOk();
 
         time = null;
 
@@ -263,11 +287,11 @@ public class ScpHelper extends AbstractLoggingBean implements SessionHolder<Sess
                     receiveDir(header, file, time, preserve, bufferSize);
                     time = null;
                 } else if (cmdChar == ScpDirEndCommandDetails.COMMAND_NAME) {
-                    ack();
+                    sendOk();
                     break;
                 } else if (cmdChar == ScpTimestampCommandDetails.COMMAND_NAME) {
                     time = ScpTimestampCommandDetails.parse(header);
-                    ack();
+                    sendOk();
                 } else {
                     throw new IOException("Unexpected message: '" + header + "'");
                 }
@@ -333,7 +357,7 @@ public class ScpHelper extends AbstractLoggingBean implements SessionHolder<Sess
         try (InputStream is = new LimitInputStream(this.in, length);
              OutputStream os = resolver.resolveTargetStream(session, name, length, perms,
                      IoUtils.EMPTY_OPEN_OPTIONS)) {
-            ack();
+            sendOk();
 
             Path file = resolver.getEventListenerFilePath();
             listener.startFileEvent(session, FileOperation.RECEIVE, file, length, perms);
@@ -349,13 +373,13 @@ public class ScpHelper extends AbstractLoggingBean implements SessionHolder<Sess
 
         resolver.postProcessReceivedData(name, preserve, perms, time);
 
-        ack();
+        sendOk();
 
-        int replyCode = readAck(false);
+        ScpAckInfo ackInfo = readAck(false);
         if (debugEnabled) {
-            log.debug("receiveStream({})[{}] ack reply code={}", this, resolver, replyCode);
+            log.debug("receiveStream({})[{}] ACK={}", this, resolver, ackInfo);
         }
-        validateAckReplyCode("receiveStream", resolver, replyCode, false);
+        validateAckReplyCode("receiveStream", resolver, ackInfo);
     }
 
     public String readLine() throws IOException {
@@ -367,12 +391,12 @@ public class ScpHelper extends AbstractLoggingBean implements SessionHolder<Sess
     }
 
     public void send(Collection<String> paths, boolean recursive, boolean preserve, int bufferSize) throws IOException {
-        int readyCode = readAck(false);
+        ScpAckInfo ackInfo = readAck(false);
         boolean debugEnabled = log.isDebugEnabled();
         if (debugEnabled) {
-            log.debug("send({}) ready code={}", paths, readyCode);
+            log.debug("send({}) ACK={}", paths, ackInfo);
         }
-        validateOperationReadyCode("send", "Paths", readyCode, false);
+        validateOperationReadyCode("send", "Paths", ackInfo);
 
         LinkOption[] options = IoUtils.getLinkOptions(true);
         for (String pattern : paths) {
@@ -420,11 +444,11 @@ public class ScpHelper extends AbstractLoggingBean implements SessionHolder<Sess
 
     public void sendPaths(Collection<? extends Path> paths, boolean recursive, boolean preserve, int bufferSize)
             throws IOException {
-        int readyCode = readAck(false);
+        ScpAckInfo ackInfo = readAck(false);
         if (log.isDebugEnabled()) {
-            log.debug("sendPaths({}) ready code={}", paths, readyCode);
+            log.debug("sendPaths({}) ACK={}", paths, ackInfo);
         }
-        validateOperationReadyCode("sendPaths", "Paths", readyCode, false);
+        validateOperationReadyCode("sendPaths", "Paths", ackInfo);
 
         LinkOption[] options = IoUtils.getLinkOptions(true);
         for (Path file : paths) {
@@ -513,13 +537,13 @@ public class ScpHelper extends AbstractLoggingBean implements SessionHolder<Sess
 
         ScpTimestampCommandDetails time = resolver.getTimestamp();
         if (preserve && (time != null)) {
-            int readyCode = ScpIoUtils.sendTimeCommand(in, out, time, log, this);
+            ScpAckInfo ackInfo = ScpIoUtils.sendAcknowledgedCommand(time, in, out);
             String cmd = time.toHeader();
             if (debugEnabled) {
-                log.debug("sendStream({})[{}] command='{}' ready code={}", this, resolver, cmd, readyCode);
+                log.debug("sendStream({})[{}] command='{}' ACK={}", this, resolver, cmd, ackInfo);
             }
 
-            validateAckReplyCode(cmd, resolver, readyCode, false);
+            validateAckReplyCode(cmd, resolver, ackInfo);
         }
 
         Set<PosixFilePermission> perms = EnumSet.copyOf(resolver.getPermissions());
@@ -532,12 +556,12 @@ public class ScpHelper extends AbstractLoggingBean implements SessionHolder<Sess
             log.debug("sendStream({})[{}] send 'C' command: {}", this, resolver, cmd);
         }
 
-        int readyCode = sendAcknowledgedCommand(cmd);
+        ScpAckInfo ackInfo = sendAcknowledgedCommand(cmd);
         if (debugEnabled) {
-            log.debug("sendStream({})[{}] command='{}' ready code={}", this, resolver,
-                    cmd.substring(0, cmd.length() - 1), readyCode);
+            log.debug("sendStream({})[{}] command='{}' ACK={}", this, resolver,
+                    cmd.substring(0, cmd.length() - 1), ackInfo);
         }
-        validateAckReplyCode(cmd, resolver, readyCode, false);
+        validateAckReplyCode(cmd, resolver, ackInfo);
 
         Session session = getSession();
         try (InputStream in = resolver.resolveSourceStream(session, fileSize, perms, IoUtils.EMPTY_OPEN_OPTIONS)) {
@@ -552,28 +576,50 @@ public class ScpHelper extends AbstractLoggingBean implements SessionHolder<Sess
             listener.endFileEvent(session, FileOperation.SEND, path, fileSize, perms, null);
             resolver.closeSourceStream(session, fileSize, perms, in);
         }
-        ack();
+        sendOk();
 
-        readyCode = readAck(false);
+        ackInfo = readAck(false);
         if (debugEnabled) {
-            log.debug("sendStream({})[{}] command='{}' reply code={}", this, resolver, cmd, readyCode);
+            log.debug("sendStream({})[{}] command='{}' ACK={}", this, resolver, cmd, ackInfo);
         }
-        validateAckReplyCode("sendStream", resolver, readyCode, false);
+        validateAckReplyCode("sendStream", resolver, ackInfo);
     }
 
-    protected void validateOperationReadyCode(String command, Object location, int readyCode, boolean eofAllowed)
+    protected void validateOperationReadyCode(String command, Object location, ScpAckInfo ackInfo)
             throws IOException {
-        validateCommandStatusCode(command, location, readyCode, eofAllowed);
+        validateCommandStatusCode(command, location, ackInfo, false);
     }
 
-    protected void validateAckReplyCode(String command, Object location, int replyCode, boolean eofAllowed)
+    protected void validateAckReplyCode(String command, Object location, ScpAckInfo ackInfo)
             throws IOException {
-        validateCommandStatusCode(command, location, replyCode, eofAllowed);
+        validateCommandStatusCode(command, location, ackInfo, false);
     }
 
-    protected void validateCommandStatusCode(String command, Object location, int statusCode, boolean eofAllowed)
+    protected void validateCommandStatusCode(String command, Object location, ScpAckInfo ackInfo, boolean eofAllowed)
             throws IOException {
-        ScpIoUtils.validateCommandStatusCode(command, location, statusCode, eofAllowed);
+        if (ackInfo == null) {
+            if (eofAllowed) {
+                return;
+            }
+
+            log.error("validateCommandStatusCode({})[{}] unexpected EOF while waiting on ACK for command={}",
+                    this, location, command);
+            throw new EOFException("EOF while waiting on ACK for command=" + command + " at " + location);
+        }
+
+        int statusCode = ackInfo.getStatusCode();
+        switch (statusCode) {
+            case ScpAckInfo.OK:
+                break;
+            case ScpAckInfo.WARNING:
+                log.warn("validateCommandStatusCode({})[{}] advisory ACK={} for command={}",
+                        this, location, ackInfo, command);
+                break;
+            default:
+                log.error("validateCommandStatusCode({})[{}] bad ACK={} for command={}",
+                        this, location, ackInfo, command);
+                ackInfo.validateCommandStatusCode(command, location);   // this actually throws an SCPException
+        }
     }
 
     public void sendDir(Path local, boolean preserve, int bufferSize) throws IOException {
@@ -597,13 +643,13 @@ public class ScpHelper extends AbstractLoggingBean implements SessionHolder<Sess
                         lastAccess, cmd);
             }
 
-            int readyCode = sendAcknowledgedCommand(cmd);
+            ScpAckInfo ackInfo = sendAcknowledgedCommand(cmd);
             if (debugEnabled) {
                 if (debugEnabled) {
-                    log.debug("sendDir({})[{}] command='{}' ready code={}", this, path, cmd, readyCode);
+                    log.debug("sendDir({})[{}] command='{}' ACK={}", this, path, cmd, ackInfo);
                 }
             }
-            validateAckReplyCode(cmd, path, readyCode, false);
+            validateAckReplyCode(cmd, path, ackInfo);
         }
 
         Set<PosixFilePermission> perms = opener.getLocalFilePermissions(session, path, options);
@@ -616,11 +662,11 @@ public class ScpHelper extends AbstractLoggingBean implements SessionHolder<Sess
             log.debug("sendDir({})[{}] send 'D' command: {}", this, path, cmd);
         }
 
-        int readyCode = sendAcknowledgedCommand(cmd);
+        ScpAckInfo ackInfo = sendAcknowledgedCommand(cmd);
         if (debugEnabled) {
-            log.debug("sendDir({})[{}] command='{}' ready code={}", this, path, cmd, readyCode);
+            log.debug("sendDir({})[{}] command='{}' ACK={}", this, path, cmd, ackInfo);
         }
-        validateAckReplyCode(cmd, path, readyCode, false);
+        validateAckReplyCode(cmd, path, ackInfo);
 
         try (DirectoryStream<Path> children = opener.getLocalFolderChildren(session, path)) {
             listener.startFolderEvent(session, FileOperation.SEND, path, perms);
@@ -645,35 +691,35 @@ public class ScpHelper extends AbstractLoggingBean implements SessionHolder<Sess
             log.debug("sendDir({})[{}] send 'E' command", this, path);
         }
 
-        readyCode = sendAcknowledgedCommand(ScpDirEndCommandDetails.HEADER);
+        ackInfo = sendAcknowledgedCommand(ScpDirEndCommandDetails.HEADER);
         if (debugEnabled) {
-            log.debug("sendDir({})[{}] 'E' command reply code=", this, path, readyCode);
+            log.debug("sendDir({})[{}] 'E' command ACK={}", this, path, ackInfo);
         }
-        validateAckReplyCode(ScpDirEndCommandDetails.HEADER, path, readyCode, false);
+        validateAckReplyCode(ScpDirEndCommandDetails.HEADER, path, ackInfo);
     }
 
-    protected int sendAcknowledgedCommand(String cmd) throws IOException {
-        return ScpIoUtils.sendAcknowledgedCommand(cmd, in, out, log);
+    protected ScpAckInfo sendAcknowledgedCommand(String cmd) throws IOException {
+        return ScpIoUtils.sendAcknowledgedCommand(cmd, in, out);
+    }
+
+    public void sendOk() throws IOException {
+        sendResponseMessage(ScpAckInfo.OK, null /* ignored */);
     }
 
     protected void sendWarning(String message) throws IOException {
-        sendResponseMessage(ScpIoUtils.WARNING, message);
+        sendResponseMessage(ScpAckInfo.WARNING, message);
     }
 
     protected void sendError(String message) throws IOException {
-        sendResponseMessage(ScpIoUtils.ERROR, message);
+        sendResponseMessage(ScpAckInfo.ERROR, message);
     }
 
     protected void sendResponseMessage(int level, String message) throws IOException {
-        ScpIoUtils.sendResponseMessage(out, level, message);
-    }
-
-    public void ack() throws IOException {
-        ScpIoUtils.ack(out);
+        ScpAckInfo.sendAck(out, level, message);
     }
 
-    public int readAck(boolean canEof) throws IOException {
-        return ScpIoUtils.readAck(in, canEof, log, this);
+    public ScpAckInfo readAck(boolean canEof) throws IOException {
+        return ScpAckInfo.readAck(in, canEof);
     }
 
     @Override
diff --git a/sshd-scp/src/main/java/org/apache/sshd/scp/common/ScpLocation.java b/sshd-scp/src/main/java/org/apache/sshd/scp/common/ScpLocation.java
index 9e0a04d..4605bbc 100644
--- a/sshd-scp/src/main/java/org/apache/sshd/scp/common/ScpLocation.java
+++ b/sshd-scp/src/main/java/org/apache/sshd/scp/common/ScpLocation.java
@@ -22,18 +22,21 @@ package org.apache.sshd.scp.common;
 import java.io.Serializable;
 import java.util.Objects;
 
+import org.apache.sshd.common.SshConstants;
 import org.apache.sshd.common.auth.MutableUserHolder;
 import org.apache.sshd.common.util.GenericUtils;
 import org.apache.sshd.common.util.OsUtils;
 import org.apache.sshd.common.util.ValidateUtils;
 
 /**
- * Represents a local or remote SCP location in the format {@code user@host:path} for a remote path and a simple path
- * for a local one. If user is omitted for a remote path then current user is used.
- * 
+ * Represents a local or remote SCP location in the format &quot;user@host:path&quot; or
+ * &quot;scp://[user@]host[:port][/path]&quot; for a remote path and a simple path for a local one. If user is omitted
+ * for a remote path then current user is used.
+ *
  * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
  */
 public class ScpLocation implements MutableUserHolder, Serializable, Cloneable {
+    public static final String SCHEME = "scp://";
     public static final char HOST_PART_SEPARATOR = ':';
     public static final char USERNAME_PART_SEPARATOR = '@';
 
@@ -42,6 +45,7 @@ public class ScpLocation implements MutableUserHolder, Serializable, Cloneable {
     private String host;
     private String username;
     private String path;
+    private int port;
 
     public ScpLocation() {
         this(null);
@@ -56,6 +60,17 @@ public class ScpLocation implements MutableUserHolder, Serializable, Cloneable {
         update(locSpec, this);
     }
 
+    public ScpLocation(String username, String host, String path) {
+        this(username, host, 0, path);
+    }
+
+    public ScpLocation(String username, String host, int port, String path) {
+        this.username = username;
+        this.host = host;
+        this.port = port;
+        this.path = path;
+    }
+
     public String getHost() {
         return host;
     }
@@ -68,6 +83,19 @@ public class ScpLocation implements MutableUserHolder, Serializable, Cloneable {
         return GenericUtils.isEmpty(getHost());
     }
 
+    public int getPort() {
+        return port;
+    }
+
+    public void setPort(int port) {
+        this.port = port;
+    }
+
+    public int resolvePort() {
+        int portValue = getPort();
+        return (portValue <= 0) ? SshConstants.DEFAULT_PORT : portValue;
+    }
+
     @Override
     public String getUsername() {
         return username;
@@ -104,7 +132,10 @@ public class ScpLocation implements MutableUserHolder, Serializable, Cloneable {
 
     @Override
     public int hashCode() {
-        return Objects.hash(getHost(), resolveUsername(), OsUtils.getComparablePath(getPath()));
+        return isLocal()
+                ? Objects.hashCode(OsUtils.getComparablePath(getPath()))
+                : Objects.hash(getHost(), resolveUsername(), OsUtils.getComparablePath(getPath()))
+                  + 31 * Integer.hashCode(resolvePort());
     }
 
     @Override
@@ -120,7 +151,8 @@ public class ScpLocation implements MutableUserHolder, Serializable, Cloneable {
         }
 
         ScpLocation other = (ScpLocation) obj;
-        if (this.isLocal() != other.isLocal()) {
+        boolean thisLocal = this.isLocal();
+        if (thisLocal != other.isLocal()) {
             return false;
         }
 
@@ -130,13 +162,14 @@ public class ScpLocation implements MutableUserHolder, Serializable, Cloneable {
             return false;
         }
 
-        if (isLocal()) {
+        if (thisLocal) {
             return true;
         }
 
         // we know other is also remote or we would not have reached this point
         return Objects.equals(resolveUsername(), other.resolveUsername())
-                && Objects.equals(getHost(), other.getHost());
+                && Objects.equals(getHost(), other.getHost())
+                && (resolvePort() == other.resolvePort());
     }
 
     @Override
@@ -155,15 +188,25 @@ public class ScpLocation implements MutableUserHolder, Serializable, Cloneable {
             return p;
         }
 
-        return resolveUsername()
-               + Character.toString(USERNAME_PART_SEPARATOR)
-               + getHost()
-               + Character.toString(HOST_PART_SEPARATOR)
-               + p;
+        int portValue = resolvePort();
+        String userValue = resolveUsername();
+        StringBuilder sb = new StringBuilder();
+        if (portValue != SshConstants.DEFAULT_PORT) {
+            sb.append(SCHEME);
+        }
+        sb.append(userValue).append(USERNAME_PART_SEPARATOR).append(getHost());
+        sb.append(HOST_PART_SEPARATOR);
+        if (portValue != SshConstants.DEFAULT_PORT) {
+            sb.append(portValue);
+        }
+        sb.append(p);
+
+        return sb.toString();
     }
 
     /**
-     * Parses a local or remote SCP location in the format {@code user@host:path}
+     * Parses a local or remote SCP location in the format &quot;user@host:path&quot; or
+     * &quot;scp://[user@]host[:port][/path]&quot;
      *
      * @param  locSpec                  The location specification - ignored if {@code null}/empty
      * @return                          The {@link ScpLocation} or {@code null} if no specification provider
@@ -175,52 +218,72 @@ public class ScpLocation implements MutableUserHolder, Serializable, Cloneable {
     }
 
     /**
-     * Parses a local or remote SCP location in the format {@code user@host:path}
+     * Parses a local or remote SCP location in the format &quot;user@host:path&quot; or
+     * &quot;scp://[user@]host[:port][/path]&quot;
      *
      * @param  <L>                      Type of {@link ScpLocation} being updated
-     * @param  locSpec                  The location specification - ignored if {@code null}/empty
+     * @param  spec                     The location specification - ignored if {@code null}/empty
      * @param  location                 The {@link ScpLocation} to update - never {@code null}
      * @return                          The updated location (unless no specification)
      * @throws IllegalArgumentException if invalid specification
      */
-    public static <L extends ScpLocation> L update(String locSpec, L location) {
+    public static <L extends ScpLocation> L update(String spec, L location) {
         Objects.requireNonNull(location, "No location to update");
-        if (GenericUtils.isEmpty(locSpec)) {
+        if (GenericUtils.isEmpty(spec)) {
             return location;
         }
 
         location.setHost(null);
         location.setUsername(null);
-
-        int pos = locSpec.indexOf(HOST_PART_SEPARATOR);
-        if (pos < 0) { // assume a local path
-            location.setPath(locSpec);
-            return location;
-        }
-
-        /*
-         * NOTE !!! in such a case there may be confusion with a host named 'a', but there is a limit to how smart we
-         * can be...
-         */
-        if ((pos == 1) && OsUtils.isWin32()) {
-            char drive = locSpec.charAt(0);
-            if (((drive >= 'a') && (drive <= 'z')) || ((drive >= 'A') && (drive <= 'Z'))) {
-                location.setPath(locSpec);
+        location.setPort(0);
+
+        String login;
+        if (spec.startsWith(SCHEME)) {
+            int pos = spec.indexOf('/', SCHEME.length());
+            ValidateUtils.checkTrue(pos > 0, "Invalid remote specification (missing path specification): %s", spec);
+
+            login = spec.substring(SCHEME.length(), pos);
+            location.setPath(spec.substring(pos));
+
+            pos = login.indexOf(HOST_PART_SEPARATOR);
+            ValidateUtils.checkTrue(pos != 0, "Invalid remote specification (malformed port specification): %s", spec);
+            if (pos > 0) {
+                ValidateUtils.checkTrue(pos < (login.length() - 1), "Invalid remote specification (no port specification): %s",
+                        spec);
+                location.setPort(Integer.parseInt(login.substring(pos + 1)));
+                login = login.substring(0, pos);
+            }
+        } else {
+            int pos = spec.indexOf(HOST_PART_SEPARATOR);
+            if (pos < 0) { // assume a local path
+                location.setPath(spec);
                 return location;
             }
-        }
 
-        String login = locSpec.substring(0, pos);
-        ValidateUtils.checkTrue(pos < (locSpec.length() - 1), "Invalid remote specification (missing path): %s", locSpec);
-        location.setPath(locSpec.substring(pos + 1));
+            /*
+             * NOTE !!! in such a case there may be confusion with a host named 'a', but there is a limit to how smart we
+             * can be...
+             */
+            if ((pos == 1) && OsUtils.isWin32()) {
+                char drive = spec.charAt(0);
+                if (((drive >= 'a') && (drive <= 'z')) || ((drive >= 'A') && (drive <= 'Z'))) {
+                    location.setPath(spec);
+                    return location;
+                }
+            }
+
+            login = spec.substring(0, pos);
+            ValidateUtils.checkTrue(pos < (spec.length() - 1), "Invalid remote specification (missing path): %s", spec);
+            location.setPath(spec.substring(pos + 1));
+        }
 
-        pos = login.indexOf(USERNAME_PART_SEPARATOR);
-        ValidateUtils.checkTrue(pos != 0, "Invalid remote specification (missing username): %s", locSpec);
+        int pos = login.indexOf(USERNAME_PART_SEPARATOR);
+        ValidateUtils.checkTrue(pos != 0, "Invalid remote specification (missing username): %s", spec);
         if (pos < 0) {
             location.setHost(login);
         } else {
             location.setUsername(login.substring(0, pos));
-            ValidateUtils.checkTrue(pos < (login.length() - 1), "Invalid remote specification (missing host): %s", locSpec);
+            ValidateUtils.checkTrue(pos < (login.length() - 1), "Invalid remote specification (missing host): %s", spec);
             location.setHost(login.substring(pos + 1));
         }
 
diff --git a/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/ScpAckInfo.java b/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/ScpAckInfo.java
new file mode 100644
index 0000000..b89e9db
--- /dev/null
+++ b/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/ScpAckInfo.java
@@ -0,0 +1,130 @@
+/*
+ * 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.scp.common.helpers;
+
+import java.io.EOFException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+import org.apache.sshd.common.util.GenericUtils;
+import org.apache.sshd.common.util.ValidateUtils;
+import org.apache.sshd.scp.common.ScpException;
+
+/**
+ * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
+ */
+public class ScpAckInfo {
+    // ACK status codes
+    public static final int OK = 0;
+    public static final int WARNING = 1;
+    public static final int ERROR = 2;
+
+    private final int statusCode;
+    private final String line;
+
+    public ScpAckInfo(int statusCode) {
+        this(statusCode, null);
+    }
+
+    public ScpAckInfo(int statusCode, String line) {
+        ValidateUtils.checkTrue(statusCode >= 0, "Invalid status code: %d", statusCode);
+
+        this.statusCode = statusCode;
+        this.line = line;
+    }
+
+    public int getStatusCode() {
+        return statusCode;
+    }
+
+    public String getLine() {
+        return line;
+    }
+
+    public <O extends OutputStream> O send(O out) throws IOException {
+        return sendAck(out, getStatusCode(), getLine());
+    }
+
+    public void validateCommandStatusCode(String command, Object location) throws IOException {
+        int code = getStatusCode();
+        if ((code != OK) && (code != WARNING)) {
+            throw new ScpException(
+                    "Bad reply code (" + code + ") for command='" + command + "' at " + location + ": " + getLine(), code);
+        }
+    }
+
+    @Override
+    public String toString() {
+        int code = getStatusCode();
+        String l = getLine();
+        // OK code has no line
+        if ((code == OK) || GenericUtils.isEmpty(l)) {
+            return Integer.toString(code);
+        } else {
+            return code + ": " + l;
+        }
+    }
+
+    public static ScpAckInfo readAck(InputStream in, boolean canEof) throws IOException {
+        int statusCode = in.read();
+        if (statusCode == -1) {
+            if (canEof) {
+                return null;
+            }
+            throw new EOFException("readAck - EOF before ACK");
+        }
+
+        if (statusCode == OK) {
+            return new ScpAckInfo(statusCode);  // OK status has no extra data
+        }
+
+        String line = ScpIoUtils.readLine(in);
+        return new ScpAckInfo(statusCode, line);
+    }
+
+    /**
+     * Sends {@link #OK} ACK code
+     *
+     * @param  out         The target {@link OutputStream}
+     * @throws IOException If failed to send the ACK code
+     */
+    public static void sendOk(OutputStream out) throws IOException {
+        sendAck(out, OK, null /* ignored */);
+    }
+
+    public static <O extends OutputStream> O sendWarning(O out, String message) throws IOException {
+        return sendAck(out, ScpAckInfo.WARNING, (message == null) ? "" : message);
+    }
+
+    public static <O extends OutputStream> O sendError(O out, String message) throws IOException {
+        return sendAck(out, ScpAckInfo.ERROR, (message == null) ? "" : message);
+    }
+
+    public static <O extends OutputStream> O sendAck(O out, int level, String message) throws IOException {
+        out.write(level);
+        if (level != OK) {
+            ScpIoUtils.writeLine(out, message); // this also flushes
+        } else {
+            out.flush();
+        }
+        return out;
+    }
+}
diff --git a/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/ScpIoUtils.java b/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/ScpIoUtils.java
index 775a502..4ad78f2 100644
--- a/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/ScpIoUtils.java
+++ b/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/ScpIoUtils.java
@@ -45,11 +45,6 @@ import org.slf4j.Logger;
  * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
  */
 public final class ScpIoUtils {
-    // ACK status codes
-    public static final int OK = 0;
-    public static final int WARNING = 1;
-    public static final int ERROR = 2;
-
     public static final Set<ClientChannelEvent> COMMAND_WAIT_EVENTS
             = Collections.unmodifiableSet(EnumSet.of(ClientChannelEvent.EXIT_STATUS, ClientChannelEvent.CLOSED));
 
@@ -80,137 +75,21 @@ public final class ScpIoUtils {
     }
 
     public static void writeLine(OutputStream out, String cmd) throws IOException {
-        out.write(cmd.getBytes(StandardCharsets.UTF_8));
+        if (cmd != null) {
+            out.write(cmd.getBytes(StandardCharsets.UTF_8));
+        }
         out.write('\n');
         out.flush();
     }
 
-    /**
-     * Sends the &quot;T...&quot; command and waits for ACK
-     *
-     * @param  in          The {@link InputStream} to read from
-     * @param  out         The target {@link OutputStream}
-     * @param  time        The {@link ScpTimestampCommandDetails} value to send
-     * @param  log         An optional {@link Logger} to use for issuing log messages - ignored if {@code null}
-     * @param  logHint     An optional hint to be used in the logged messages to identifier the caller's context
-     * @return             The read ACK value
-     * @throws IOException If failed to complete the read/write cyle
-     */
-    public static int sendTimeCommand(
-            InputStream in, OutputStream out, ScpTimestampCommandDetails time, Logger log, Object logHint)
+    public static ScpAckInfo sendAcknowledgedCommand(AbstractScpCommandDetails cmd, InputStream in, OutputStream out)
             throws IOException {
-        String cmd = time.toHeader();
-        if ((log != null) && log.isDebugEnabled()) {
-            log.debug("sendTimeCommand({}) send timestamp={} command: {}", logHint, time, cmd);
-        }
-        writeLine(out, cmd);
-
-        return readAck(in, false, log, logHint);
-    }
-
-    /**
-     * Reads a single ACK from the input
-     *
-     * @param  in          The {@link InputStream} to read from
-     * @param  canEof      If {@code true} then OK if EOF is received before full ACK received
-     * @param  log         An optional {@link Logger} to use for issuing log messages - ignored if {@code null}
-     * @param  logHint     An optional hint to be used in the logged messages to identifier the caller's context
-     * @return             The read ACK value
-     * @throws IOException If failed to complete the read
-     */
-    public static int readAck(InputStream in, boolean canEof, Logger log, Object logHint) throws IOException {
-        int c = in.read();
-        boolean debugEnabled = (log != null) && log.isDebugEnabled();
-        switch (c) {
-            case -1:
-                if (debugEnabled) {
-                    log.debug("readAck({})[EOF={}] received EOF", logHint, canEof);
-                }
-                if (!canEof) {
-                    throw new EOFException("readAck - EOF before ACK");
-                }
-                break;
-            case OK:
-                if (debugEnabled) {
-                    log.debug("readAck({})[EOF={}] read OK", logHint, canEof);
-                }
-                break;
-            case WARNING: {
-                if (debugEnabled) {
-                    log.debug("readAck({})[EOF={}] read warning message", logHint, canEof);
-                }
-
-                String line = readLine(in);
-                if (log != null) {
-                    log.warn("readAck({})[EOF={}] - Received warning: {}", logHint, canEof, line);
-                }
-                break;
-            }
-            case ERROR: {
-                if (debugEnabled) {
-                    log.debug("readAck({})[EOF={}] read error message", logHint, canEof);
-                }
-                String line = readLine(in);
-                if (debugEnabled) {
-                    log.debug("readAck({})[EOF={}] received error: {}", logHint, canEof, line);
-                }
-                throw new ScpException("Received nack: " + line, c);
-            }
-            default:
-                break;
-        }
-
-        return c;
+        return sendAcknowledgedCommand(cmd.toHeader(), in, out);
     }
 
-    public static int sendAcknowledgedCommand(
-            String cmd, InputStream in, OutputStream out, Logger log)
-            throws IOException {
+    public static ScpAckInfo sendAcknowledgedCommand(String cmd, InputStream in, OutputStream out) throws IOException {
         writeLine(out, cmd);
-        return readAck(in, false, log, cmd);
-    }
-
-    /**
-     * Sends {@link #OK} ACK code
-     *
-     * @param  out         The target {@link OutputStream}
-     * @throws IOException If failed to send the ACK code
-     */
-    public static void ack(OutputStream out) throws IOException {
-        out.write(OK);
-        out.flush();
-    }
-
-    public static <O extends OutputStream> O sendWarning(O out, String message) throws IOException {
-        return sendResponseMessage(out, WARNING, message);
-    }
-
-    public static <O extends OutputStream> O sendError(O out, String message) throws IOException {
-        return sendResponseMessage(out, ERROR, message);
-    }
-
-    public static <O extends OutputStream> O sendResponseMessage(O out, int level, String message) throws IOException {
-        out.write(level);
-        writeLine(out, message);
-        return out;
-    }
-
-    public static void validateCommandStatusCode(String command, Object location, int statusCode, boolean eofAllowed)
-            throws IOException {
-        switch (statusCode) {
-            case -1:
-                if (!eofAllowed) {
-                    throw new EOFException("Unexpected EOF for command='" + command + "' on " + location);
-                }
-                break;
-            case OK:
-                break;
-            case WARNING:
-                break;
-            default:
-                throw new ScpException(
-                        "Bad reply code (" + statusCode + ") for command='" + command + "' on " + location, statusCode);
-        }
+        return ScpAckInfo.readAck(in, false);
     }
 
     public static String getExitStatusName(Integer exitStatus) {
@@ -219,11 +98,11 @@ public final class ScpIoUtils {
         }
 
         switch (exitStatus) {
-            case OK:
+            case ScpAckInfo.OK:
                 return "OK";
-            case WARNING:
+            case ScpAckInfo.WARNING:
                 return "WARNING";
-            case ERROR:
+            case ScpAckInfo.ERROR:
                 return "ERROR";
             default:
                 return exitStatus.toString();
@@ -335,9 +214,9 @@ public final class ScpIoUtils {
 
         int statusCode = exitStatus;
         switch (statusCode) {
-            case OK: // do nothing
+            case ScpAckInfo.OK: // do nothing
                 break;
-            case WARNING:
+            case ScpAckInfo.WARNING:
                 if (log != null) {
                     log.warn("handleCommandExitStatus({}) cmd='{}' may have terminated with some problems", session, cmd);
                 }
diff --git a/sshd-scp/src/main/java/org/apache/sshd/scp/server/ScpCommand.java b/sshd-scp/src/main/java/org/apache/sshd/scp/server/ScpCommand.java
index 074c8fd..329940c 100644
--- a/sshd-scp/src/main/java/org/apache/sshd/scp/server/ScpCommand.java
+++ b/sshd-scp/src/main/java/org/apache/sshd/scp/server/ScpCommand.java
@@ -29,7 +29,7 @@ import org.apache.sshd.scp.common.ScpFileOpener;
 import org.apache.sshd.scp.common.ScpHelper;
 import org.apache.sshd.scp.common.ScpTransferEventListener;
 import org.apache.sshd.scp.common.helpers.DefaultScpFileOpener;
-import org.apache.sshd.scp.common.helpers.ScpIoUtils;
+import org.apache.sshd.scp.common.helpers.ScpAckInfo;
 import org.apache.sshd.server.Environment;
 import org.apache.sshd.server.ExitCallback;
 import org.apache.sshd.server.channel.ChannelSession;
@@ -157,7 +157,7 @@ public class ScpCommand extends AbstractFileSystemCommand {
 
     @Override
     public void run() {
-        int exitValue = ScpIoUtils.OK;
+        int exitValue = ScpAckInfo.OK;
         String exitMessage = null;
         ServerSession session = getServerSession();
         String command = getCommand();
@@ -178,13 +178,13 @@ public class ScpCommand extends AbstractFileSystemCommand {
                 if (e instanceof ScpException) {
                     statusCode = ((ScpException) e).getExitStatus();
                 }
-                exitValue = (statusCode == null) ? ScpIoUtils.ERROR : statusCode;
+                exitValue = (statusCode == null) ? ScpAckInfo.ERROR : statusCode;
                 // this is an exception so status cannot be OK/WARNING
-                if ((exitValue == ScpIoUtils.OK) || (exitValue == ScpIoUtils.WARNING)) {
+                if ((exitValue == ScpAckInfo.OK) || (exitValue == ScpAckInfo.WARNING)) {
                     if (debugEnabled) {
                         log.debug("run({})[{}] normalize status code={}", session, command, exitValue);
                     }
-                    exitValue = ScpIoUtils.ERROR;
+                    exitValue = ScpAckInfo.ERROR;
                 }
                 exitMessage = GenericUtils.trimToEmpty(e.getMessage());
                 writeCommandResponseMessage(command, exitValue, exitMessage);
@@ -208,7 +208,7 @@ public class ScpCommand extends AbstractFileSystemCommand {
             log.debug("writeCommandResponseMessage({}) command='{}', exit-status={}: {}",
                     getServerSession(), command, exitValue, exitMessage);
         }
-        ScpIoUtils.sendResponseMessage(getOutputStream(), exitValue, exitMessage);
+        ScpAckInfo.sendAck(getOutputStream(), exitValue, exitMessage);
     }
 
     @Override
diff --git a/sshd-scp/src/main/java/org/apache/sshd/scp/server/ScpShell.java b/sshd-scp/src/main/java/org/apache/sshd/scp/server/ScpShell.java
index 3cde5f1..dedde72 100644
--- a/sshd-scp/src/main/java/org/apache/sshd/scp/server/ScpShell.java
+++ b/sshd-scp/src/main/java/org/apache/sshd/scp/server/ScpShell.java
@@ -61,7 +61,7 @@ import org.apache.sshd.scp.common.ScpFileOpener;
 import org.apache.sshd.scp.common.ScpHelper;
 import org.apache.sshd.scp.common.ScpTransferEventListener;
 import org.apache.sshd.scp.common.helpers.DefaultScpFileOpener;
-import org.apache.sshd.scp.common.helpers.ScpIoUtils;
+import org.apache.sshd.scp.common.helpers.ScpAckInfo;
 import org.apache.sshd.server.Environment;
 import org.apache.sshd.server.channel.ChannelSession;
 import org.apache.sshd.server.command.AbstractFileSystemCommand;
@@ -476,13 +476,13 @@ public class ScpShell extends AbstractFileSystemCommand {
             variables.put(STATUS, 0);
         } catch (IOException e) {
             Integer statusCode = e instanceof ScpException ? ((ScpException) e).getExitStatus() : null;
-            int exitValue = (statusCode == null) ? ScpIoUtils.ERROR : statusCode;
+            int exitValue = (statusCode == null) ? ScpAckInfo.ERROR : statusCode;
             // this is an exception so status cannot be OK/WARNING
-            if ((exitValue == ScpIoUtils.OK) || (exitValue == ScpIoUtils.WARNING)) {
-                exitValue = ScpIoUtils.ERROR;
+            if ((exitValue == ScpAckInfo.OK) || (exitValue == ScpAckInfo.WARNING)) {
+                exitValue = ScpAckInfo.ERROR;
             }
             String exitMessage = GenericUtils.trimToEmpty(e.getMessage());
-            ScpIoUtils.sendResponseMessage(getOutputStream(), exitValue, exitMessage);
+            ScpAckInfo.sendAck(getOutputStream(), exitValue, exitMessage);
             variables.put(STATUS, exitValue);
         }
     }
diff --git a/sshd-scp/src/test/java/org/apache/sshd/scp/client/ScpTest.java b/sshd-scp/src/test/java/org/apache/sshd/scp/client/ScpTest.java
index 0d5583b..b7bef34 100644
--- a/sshd-scp/src/test/java/org/apache/sshd/scp/client/ScpTest.java
+++ b/sshd-scp/src/test/java/org/apache/sshd/scp/client/ScpTest.java
@@ -62,6 +62,7 @@ import org.apache.sshd.scp.common.ScpFileOpener;
 import org.apache.sshd.scp.common.ScpHelper;
 import org.apache.sshd.scp.common.ScpTransferEventListener;
 import org.apache.sshd.scp.common.helpers.DefaultScpFileOpener;
+import org.apache.sshd.scp.common.helpers.ScpAckInfo;
 import org.apache.sshd.scp.common.helpers.ScpDirEndCommandDetails;
 import org.apache.sshd.scp.common.helpers.ScpIoUtils;
 import org.apache.sshd.scp.common.helpers.ScpPathCommandDetailsSupport;
@@ -820,7 +821,7 @@ public class ScpTest extends AbstractScpTestSupport {
             @Override
             protected void onExit(int exitValue, String exitMessage) {
                 outputDebugMessage("onExit(%s) status=%d", this, exitValue);
-                super.onExit((exitValue == ScpIoUtils.OK) ? testExitValue : exitValue, exitMessage);
+                super.onExit((exitValue == ScpAckInfo.OK) ? testExitValue : exitValue, exitMessage);
             }
         }
 
@@ -1067,13 +1068,13 @@ public class ScpTest extends AbstractScpTestSupport {
 
         try (OutputStream os = c.getOutputStream();
              InputStream is = c.getInputStream()) {
-            ScpIoUtils.ack(os);
+            ScpAckInfo.sendOk(os);
 
             String header = ScpIoUtils.readLine(is, false);
             String expPrefix = ScpReceiveDirCommandDetails.COMMAND_NAME
                                + ScpReceiveDirCommandDetails.DEFAULT_DIR_OCTAL_PERMISSIONS + " 0 ";
             assertTrue("Bad header prefix for " + path + ": " + header, header.startsWith(expPrefix));
-            ScpIoUtils.ack(os);
+            ScpAckInfo.sendOk(os);
 
             header = ScpIoUtils.readLine(is, false);
             String fileName = Objects.toString(target.getFileName(), null);
@@ -1082,18 +1083,18 @@ public class ScpTest extends AbstractScpTestSupport {
                       + " " + Files.size(target) + " " + fileName;
             assertEquals("Mismatched dir header for " + path, expHeader, header);
             int length = Integer.parseInt(header.substring(6, header.indexOf(' ', 6)));
-            ScpIoUtils.ack(os);
+            ScpAckInfo.sendOk(os);
 
             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);
 
-            ScpIoUtils.ack(os);
+            ScpAckInfo.sendOk(os);
 
             header = ScpIoUtils.readLine(is, false);
             assertEquals("Mismatched end value for " + path, "E", header);
-            ScpIoUtils.ack(os);
+            ScpAckInfo.sendOk(os);
 
             return new String(buffer, StandardCharsets.UTF_8);
         } finally {
@@ -1110,8 +1111,8 @@ public class ScpTest extends AbstractScpTestSupport {
         try (OutputStream os = c.getOutputStream();
              InputStream is = c.getInputStream()) {
 
-            ScpIoUtils.ack(os);
-            assertEquals("Mismatched response for command: " + command, ScpIoUtils.ERROR, is.read());
+            ScpAckInfo.sendOk(os);
+            assertEquals("Mismatched response for command: " + command, ScpAckInfo.ERROR, is.read());
         } finally {
             c.disconnect();
         }
@@ -1139,7 +1140,7 @@ public class ScpTest extends AbstractScpTestSupport {
             os.flush();
             assertAckReceived(is, "Sent data (length=" + data.length() + ") for " + path + "[" + name + "]");
 
-            ScpIoUtils.ack(os);
+            ScpAckInfo.sendOk(os);
 
             Thread.sleep(100);
         } finally {
@@ -1169,7 +1170,7 @@ public class ScpTest extends AbstractScpTestSupport {
 
             command = "C7777 " + data.length() + " " + name;
             ScpIoUtils.writeLine(os, command);
-            assertEquals("Mismatched response for command=" + command, ScpIoUtils.ERROR, is.read());
+            assertEquals("Mismatched response for command=" + command, ScpAckInfo.ERROR, is.read());
         } finally {
             c.disconnect();
         }
@@ -1193,7 +1194,7 @@ public class ScpTest extends AbstractScpTestSupport {
             os.flush();
             assertAckReceived(is, "Send data of " + path);
 
-            ScpIoUtils.ack(os);
+            ScpAckInfo.sendOk(os);
             ScpIoUtils.writeLine(os, ScpDirEndCommandDetails.HEADER);
             assertAckReceived(is, "Signal end of " + path);
         } finally {
diff --git a/sshd-scp/src/test/java/org/apache/sshd/scp/common/ScpLocationParsingTest.java b/sshd-scp/src/test/java/org/apache/sshd/scp/common/ScpLocationParsingTest.java
new file mode 100644
index 0000000..9aa52ca
--- /dev/null
+++ b/sshd-scp/src/test/java/org/apache/sshd/scp/common/ScpLocationParsingTest.java
@@ -0,0 +1,92 @@
+/*
+ * 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.scp.common;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.sshd.common.SshConstants;
+import org.apache.sshd.util.test.JUnit4ClassRunnerWithParametersFactory;
+import org.apache.sshd.util.test.JUnitTestSupport;
+import org.apache.sshd.util.test.NoIoTestCase;
+import org.junit.Assume;
+import org.junit.FixMethodOrder;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+import org.junit.runner.RunWith;
+import org.junit.runners.MethodSorters;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameters;
+import org.junit.runners.Parameterized.UseParametersRunnerFactory;
+
+/**
+ * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
+ */
+@FixMethodOrder(MethodSorters.NAME_ASCENDING)
+@RunWith(Parameterized.class) // see https://github.com/junit-team/junit/wiki/Parameterized-tests
+@UseParametersRunnerFactory(JUnit4ClassRunnerWithParametersFactory.class)
+@Category({ NoIoTestCase.class })
+public class ScpLocationParsingTest extends JUnitTestSupport {
+    private final String value;
+    private final ScpLocation location;
+
+    public ScpLocationParsingTest(String value, ScpLocation location) {
+        this.value = value;
+        this.location = location;
+    }
+
+    @Parameters(name = "value={0}")
+    public static List<Object[]> parameters() {
+        return new ArrayList<Object[]>() {
+            // not serializing it
+            private static final long serialVersionUID = 1L;
+
+            {
+                addTestCase(null, null);
+                addTestCase("", null);
+                addTestCase("/local/path/value", new ScpLocation(null, null, "/local/path/value"));
+                addTestCase("user@host:/remote/path/value", new ScpLocation("user", "host", "/remote/path/value"));
+                addTestCase("scp://user@host/remote/path/value", new ScpLocation("user", "host", "/remote/path/value"));
+                addTestCase("scp://user@host:22/remote/path/value", new ScpLocation("user", "host", "/remote/path/value"));
+                addTestCase("scp://user@host:2222/remote/path/value",
+                        new ScpLocation("user", "host", 2222, "/remote/path/value"));
+            }
+
+            private void addTestCase(String value, ScpLocation expected) {
+                add(new Object[] { value, expected });
+            }
+        };
+    }
+
+    @Test
+    public void testLocationParsing() {
+        ScpLocation actual = ScpLocation.parse(value);
+        assertEquals(location, actual);
+    }
+
+    @Test
+    public void testLocationToString() {
+        Assume.assumeTrue("No expected value to compate", location != null);
+        Assume.assumeTrue("Default port being used",
+                location.isLocal() || (location.resolvePort() != SshConstants.DEFAULT_PORT));
+        String spec = location.toString();
+        assertEquals(value, spec);
+    }
+}


[mina-sshd] 04/05: [SSHD-1056] Add SCP remote-to-remote transfer of directories

Posted by lg...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

lgoldstein pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/mina-sshd.git

commit d1c18fe0f9886441dff32e6c56a48dd176c56d76
Author: Lyor Goldstein <lg...@apache.org>
AuthorDate: Tue Aug 18 15:11:39 2020 +0300

    [SSHD-1056] Add SCP remote-to-remote transfer of directories
---
 .../org/apache/sshd/common/util/SelectorUtils.java |  39 ++-
 .../sshd/common/util/PathsConcatentionTest.java    |  87 ++++++
 .../apache/sshd/common/util/SelectorUtilsTest.java |   8 +
 .../scp/client/ScpRemote2RemoteTransferHelper.java | 304 ++++++++++++++++-----
 .../client/ScpRemote2RemoteTransferListener.java   |  37 +++
 .../java/org/apache/sshd/scp/common/ScpHelper.java |  65 ++++-
 .../common/helpers/ScpDirEndCommandDetails.java    |  14 +
 .../apache/sshd/scp/common/helpers/ScpIoUtils.java |  71 -----
 .../common/helpers/ScpTimestampCommandDetails.java |   2 +-
 .../client/ScpRemote2RemoteTransferHelperTest.java | 221 ++++++++++++++-
 10 files changed, 692 insertions(+), 156 deletions(-)

diff --git a/sshd-common/src/main/java/org/apache/sshd/common/util/SelectorUtils.java b/sshd-common/src/main/java/org/apache/sshd/common/util/SelectorUtils.java
index 78e3235..9cb0a71 100644
--- a/sshd-common/src/main/java/org/apache/sshd/common/util/SelectorUtils.java
+++ b/sshd-common/src/main/java/org/apache/sshd/common/util/SelectorUtils.java
@@ -477,7 +477,7 @@ public final class SelectorUtils {
 
     /**
      * Tests whether two characters are equal.
-     * 
+     *
      * @param  c1              1st character
      * @param  c2              2nd character
      * @param  isCaseSensitive Whether to compare case sensitive
@@ -520,7 +520,7 @@ public final class SelectorUtils {
      * /** Converts a path to one matching the target file system by applying the &quot;slashification&quot; rules,
      * converting it to a local path and then translating its separator to the target file system one (if different than
      * local one)
-     * 
+     *
      * @param  path          The input path
      * @param  pathSeparator The separator used to build the input path
      * @param  fs            The target {@link FileSystem} - may not be {@code null}
@@ -536,7 +536,7 @@ public final class SelectorUtils {
      * Converts a path to one matching the target file system by applying the &quot;slashification&quot; rules,
      * converting it to a local path and then translating its separator to the target file system one (if different than
      * local one)
-     * 
+     *
      * @param  path          The input path
      * @param  pathSeparator The separator used to build the input path
      * @param  fsSeparator   The target file system separator
@@ -559,7 +559,7 @@ public final class SelectorUtils {
      * Specification version 3, section 3.266</A> and
      * <A HREF="http://pubs.opengroup.org/onlinepubs/009695399/basedefs/xbd_chap04.html#tag_04_11">section 4.11 -
      * Pathname resolution</A>
-     * 
+     *
      * @param  path    The original path - ignored if {@code null}/empty or does not contain any slashes
      * @param  sepChar The &quot;slash&quot; character
      * @return         The effective path - may be same as input if no changes required
@@ -693,7 +693,7 @@ public final class SelectorUtils {
 
     /**
      * Converts a path containing a specific separator to one using the specified file-system one
-     * 
+     *
      * @param  path          The input path - ignored if {@code null}/empty
      * @param  pathSeparator The separator used to build the input path - may not be {@code null}/empty
      * @param  fs            The target {@link FileSystem} - may not be {@code null}
@@ -709,7 +709,7 @@ public final class SelectorUtils {
 
     /**
      * Converts a path containing a specific separator to one using the specified file-system one
-     * 
+     *
      * @param  path                     The input path - ignored if {@code null}/empty
      * @param  pathSeparator            The separator used to build the input path - may not be {@code null}/empty
      * @param  fsSeparator              The target file system separator - may not be {@code null}/empty
@@ -742,6 +742,33 @@ public final class SelectorUtils {
     }
 
     /**
+     * Creates a single path by concatenating 2 parts and taking care not to create FS separator duplication in the
+     * process
+     *
+     * @param  p1          prefix part - ignored if {@code null}/empty
+     * @param  p2          suffix part - ignored if {@code null}/empty
+     * @param  fsSeparator The expected file-system separator
+     * @return             Concatenation result
+     */
+    public static String concatPaths(String p1, String p2, char fsSeparator) {
+        if (GenericUtils.isEmpty(p1)) {
+            return p2;
+        } else if (GenericUtils.isEmpty(p2)) {
+            return p1;
+        } else if (p1.charAt(p1.length() - 1) == fsSeparator) {
+            if (p2.charAt(0) == fsSeparator) {
+                return (p2.length() == 1) ? p1 : p1 + p2.substring(1); // a/b/c/  + /d/e/f
+            } else {
+                return p1 + p2;     // a/b/c/ + d/e/f
+            }
+        } else if (p2.charAt(0) == fsSeparator) {
+            return (p2.length() == 1) ? p1 : p1 + p2; // /a/b/c + /d/e/f
+        } else {
+            return p1 + Character.toString(fsSeparator) + p2;    // /a/b/c + d/e/f
+        }
+    }
+
+    /**
      * "Flattens" a string by removing all whitespace (space, tab, line-feed, carriage return, and form-feed). This uses
      * StringTokenizer and the default set of tokens as documented in the single argument constructor.
      *
diff --git a/sshd-common/src/test/java/org/apache/sshd/common/util/PathsConcatentionTest.java b/sshd-common/src/test/java/org/apache/sshd/common/util/PathsConcatentionTest.java
new file mode 100644
index 0000000..bf8fd58
--- /dev/null
+++ b/sshd-common/src/test/java/org/apache/sshd/common/util/PathsConcatentionTest.java
@@ -0,0 +1,87 @@
+/*
+ * 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.common.util;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.sshd.util.test.JUnit4ClassRunnerWithParametersFactory;
+import org.apache.sshd.util.test.JUnitTestSupport;
+import org.apache.sshd.util.test.NoIoTestCase;
+import org.junit.FixMethodOrder;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+import org.junit.runner.RunWith;
+import org.junit.runners.MethodSorters;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameters;
+import org.junit.runners.Parameterized.UseParametersRunnerFactory;
+
+/**
+ * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
+ */
+@FixMethodOrder(MethodSorters.NAME_ASCENDING)
+@RunWith(Parameterized.class) // see https://github.com/junit-team/junit/wiki/Parameterized-tests
+@UseParametersRunnerFactory(JUnit4ClassRunnerWithParametersFactory.class)
+@Category({ NoIoTestCase.class })
+public class PathsConcatentionTest extends JUnitTestSupport {
+    private final String p1;
+    private final String p2;
+    private final String expected;
+
+    public PathsConcatentionTest(String p1, String p2, String expected) {
+        this.p1 = p1;
+        this.p2 = p2;
+        this.expected = expected;
+    }
+
+    @Parameters(name = "p1={0}, p2={1}, expected={2}")
+    public static List<Object[]> parameters() {
+        return new ArrayList<Object[]>() {
+            // not serializing it
+            private static final long serialVersionUID = 1L;
+
+            {
+                addTestCase("/a/b/c", "d/e/f", "/a/b/c/d/e/f");
+                addTestCase("/a/b/c", "/d/e/f", "/a/b/c/d/e/f");
+                addTestCase("/a/b/c/", "d/e/f", "/a/b/c/d/e/f");
+                addTestCase("/a/b/c/", "/d/e/f", "/a/b/c/d/e/f");
+
+                addTestCase("/", "/d", "/d");
+                addTestCase("/a", "/", "/a");
+                addTestCase("/", "/", "/");
+
+                addTestCase(null, null, null);
+                addTestCase(null, "", "");
+                addTestCase("", null, null);
+                addTestCase("", "", "");
+            }
+
+            private void addTestCase(String p1, String p2, String expected) {
+                add(new Object[] { p1, p2, expected });
+            }
+        };
+    }
+
+    @Test
+    public void testConcatPaths() {
+        assertEquals(expected, SelectorUtils.concatPaths(p1, p2, '/'));
+    }
+}
diff --git a/sshd-common/src/test/java/org/apache/sshd/common/util/SelectorUtilsTest.java b/sshd-common/src/test/java/org/apache/sshd/common/util/SelectorUtilsTest.java
index 430873e..7fb0575 100644
--- a/sshd-common/src/test/java/org/apache/sshd/common/util/SelectorUtilsTest.java
+++ b/sshd-common/src/test/java/org/apache/sshd/common/util/SelectorUtilsTest.java
@@ -144,4 +144,12 @@ public class SelectorUtilsTest extends JUnitTestSupport {
         }
     }
 
+    @Test
+    public void testConcatPathsOneEmptyOrNull() {
+        String path = getCurrentTestName();
+        assertSame("Null 1st", path, SelectorUtils.concatPaths(null, path, File.separatorChar));
+        assertSame("Empty 1st", path, SelectorUtils.concatPaths("", path, File.separatorChar));
+        assertSame("Null 2nd", path, SelectorUtils.concatPaths(path, null, File.separatorChar));
+        assertSame("Empty 2nd", path, SelectorUtils.concatPaths(path, "", File.separatorChar));
+    }
 }
diff --git a/sshd-scp/src/main/java/org/apache/sshd/scp/client/ScpRemote2RemoteTransferHelper.java b/sshd-scp/src/main/java/org/apache/sshd/scp/client/ScpRemote2RemoteTransferHelper.java
index 2f88fc3..07a8ae2 100644
--- a/sshd-scp/src/main/java/org/apache/sshd/scp/client/ScpRemote2RemoteTransferHelper.java
+++ b/sshd-scp/src/main/java/org/apache/sshd/scp/client/ScpRemote2RemoteTransferHelper.java
@@ -28,15 +28,20 @@ import java.util.Collection;
 import java.util.Collections;
 import java.util.EnumSet;
 import java.util.Objects;
+import java.util.Set;
 
 import org.apache.sshd.client.channel.ChannelExec;
 import org.apache.sshd.client.session.ClientSession;
+import org.apache.sshd.common.util.SelectorUtils;
 import org.apache.sshd.common.util.io.IoUtils;
 import org.apache.sshd.common.util.io.LimitInputStream;
 import org.apache.sshd.common.util.logging.AbstractLoggingBean;
 import org.apache.sshd.scp.client.ScpClient.Option;
 import org.apache.sshd.scp.common.helpers.AbstractScpCommandDetails;
+import org.apache.sshd.scp.common.helpers.ScpDirEndCommandDetails;
 import org.apache.sshd.scp.common.helpers.ScpIoUtils;
+import org.apache.sshd.scp.common.helpers.ScpPathCommandDetailsSupport;
+import org.apache.sshd.scp.common.helpers.ScpReceiveDirCommandDetails;
 import org.apache.sshd.scp.common.helpers.ScpReceiveFileCommandDetails;
 import org.apache.sshd.scp.common.helpers.ScpTimestampCommandDetails;
 
@@ -88,8 +93,27 @@ public class ScpRemote2RemoteTransferHelper extends AbstractLoggingBean {
     public void transferFile(String source, String destination, boolean preserveAttributes) throws IOException {
         Collection<Option> options = preserveAttributes
                 ? Collections.unmodifiableSet(EnumSet.of(Option.PreserveAttributes))
-                : Collections.emptySet()
-                ;
+                : Collections.emptySet();
+        executeTransfer(source, options, destination, options);
+    }
+
+    /**
+     * Transfers a directory
+     *
+     * @param  source             Source path in the source session
+     * @param  destination        Destination path in the destination session
+     * @param  preserveAttributes Whether to preserve the attributes of the transferred file (e.g., permissions, file
+     *                            associated timestamps, etc.)
+     * @throws IOException        If failed to transfer
+     */
+    public void transferDirectory(String source, String destination, boolean preserveAttributes)
+            throws IOException {
+        Set<Option> options = EnumSet.of(Option.TargetIsDirectory, Option.Recursive);
+        if (preserveAttributes) {
+            options.add(Option.PreserveAttributes);
+        }
+
+        options = Collections.unmodifiableSet(options);
         executeTransfer(source, options, destination, options);
     }
 
@@ -100,6 +124,7 @@ public class ScpRemote2RemoteTransferHelper extends AbstractLoggingBean {
         String srcCmd = ScpClient.createReceiveCommand(source, srcOptions);
         ClientSession srcSession = getSourceSession();
         ClientSession dstSession = getDestinationSession();
+
         boolean debugEnabled = log.isDebugEnabled();
         if (debugEnabled) {
             log.debug("executeTransfer({})[srcCmd='{}']) {} => {}",
@@ -120,77 +145,254 @@ public class ScpRemote2RemoteTransferHelper extends AbstractLoggingBean {
                  OutputStream dstOut = dstChannel.getInvertedIn()) {
                 int statusCode = transferStatusCode("XFER-CMD", dstIn, srcOut);
                 ScpIoUtils.validateCommandStatusCode("XFER-CMD", "executeTransfer", statusCode, false);
-                redirectReceivedFile(source, srcIn, srcOut, destination, dstIn, dstOut);
+
+                if (srcOptions.contains(Option.TargetIsDirectory) || dstOptions.contains(Option.TargetIsDirectory)) {
+                    redirectDirectoryTransfer(source, srcIn, srcOut, destination, dstIn, dstOut, 0);
+                } else {
+                    redirectFileTransfer(source, srcIn, srcOut, destination, dstIn, dstOut);
+                }
             } finally {
                 dstChannel.close(false);
             }
         } finally {
             srcChannel.close(false);
         }
-
     }
 
-    protected long redirectReceivedFile(
+    protected long redirectFileTransfer(
             String source, InputStream srcIn, OutputStream srcOut,
             String destination, InputStream dstIn, OutputStream dstOut)
             throws IOException {
         boolean debugEnabled = log.isDebugEnabled();
         String header = ScpIoUtils.readLine(srcIn, false);
         if (debugEnabled) {
-            log.debug("redirectReceivedFile({}) header={}", this, header);
+            log.debug("redirectFileTransfer({}) {} => {}: header={}", this, source, destination, header);
         }
 
-        char cmdName = header.charAt(0);
         ScpTimestampCommandDetails time = null;
-        if (cmdName == ScpTimestampCommandDetails.COMMAND_NAME) {
+        if (header.charAt(0) == ScpTimestampCommandDetails.COMMAND_NAME) {
             // Pass along the "T<mtime> 0 <atime> 0" and wait for response
-            time = ScpTimestampCommandDetails.parseTime(header);
-            // Read the next command - which must be a 'C' command
-            header = transferTimestampCommand(source, srcIn, srcOut, destination, dstIn, dstOut, time);
-            cmdName = header.charAt(0);
+            time = new ScpTimestampCommandDetails(header);
+            signalReceivedCommand(time);
+
+            header = transferTimestampCommand(source, srcIn, srcOut, destination, dstIn, dstOut, header);
+            if (debugEnabled) {
+                log.debug("redirectFileTransfer({}) {} => {}: header={}", this, source, destination, header);
+            }
         }
 
-        if (cmdName != ScpReceiveFileCommandDetails.COMMAND_NAME) {
-            throw new StreamCorruptedException("Unexpected file command: " + header);
+        return handleFileTransferRequest(source, srcIn, srcOut, destination, dstIn, dstOut, time, header);
+    }
+
+    protected long handleFileTransferRequest(
+            String source, InputStream srcIn, OutputStream srcOut,
+            String destination, InputStream dstIn, OutputStream dstOut,
+            ScpTimestampCommandDetails fileTime, String header)
+            throws IOException {
+        if (header.charAt(0) != ScpReceiveFileCommandDetails.COMMAND_NAME) {
+            throw new IllegalArgumentException("Invalid file transfer request: " + header);
         }
 
-        ScpReceiveFileCommandDetails details = new ScpReceiveFileCommandDetails(header);
-        signalReceivedCommand(details);
+        ScpIoUtils.writeLine(dstOut, header);
+        int statusCode = transferStatusCode(header, dstIn, srcOut);
+        ScpIoUtils.validateCommandStatusCode("[DST] " + header, "handleFileTransferRequest", statusCode, false);
+
+        ScpReceiveFileCommandDetails fileDetails = new ScpReceiveFileCommandDetails(header);
+        signalReceivedCommand(fileDetails);
+
+        ClientSession srcSession = getSourceSession();
+        ClientSession dstSession = getDestinationSession();
+        if (listener != null) {
+            listener.startDirectFileTransfer(srcSession, source, dstSession, destination, fileTime, fileDetails);
+        }
+
+        long xferCount;
+        try {
+            xferCount = transferSimpleFile(source, srcIn, srcOut, destination, dstIn, dstOut, header, fileDetails.getLength());
+        } catch (IOException | RuntimeException | Error e) {
+            if (listener != null) {
+                listener.endDirectFileTransfer(srcSession, source, dstSession, destination, fileTime, fileDetails, 0L, e);
+            }
+            throw e;
+        }
+
+        if (listener != null) {
+            listener.endDirectFileTransfer(srcSession, source, dstSession, destination, fileTime, fileDetails, xferCount, null);
+        }
+
+        return xferCount;
+    }
+
+    protected void redirectDirectoryTransfer(
+            String source, InputStream srcIn, OutputStream srcOut,
+            String destination, InputStream dstIn, OutputStream dstOut,
+            int depth)
+            throws IOException {
+        boolean debugEnabled = log.isDebugEnabled();
+        String header = ScpIoUtils.readLine(srcIn, false);
+        if (debugEnabled) {
+            log.debug("redirectDirectoryTransfer({})[depth={}] {} => {}: header={}",
+                    this, depth, source, destination, header);
+        }
+
+        ScpTimestampCommandDetails time = null;
+        if (header.charAt(0) == ScpTimestampCommandDetails.COMMAND_NAME) {
+            // Pass along the "T<mtime> 0 <atime> 0" and wait for response
+            time = new ScpTimestampCommandDetails(header);
+            signalReceivedCommand(time);
+
+            header = transferTimestampCommand(source, srcIn, srcOut, destination, dstIn, dstOut, header);
+            if (debugEnabled) {
+                log.debug("redirectDirectoryTransfer({})[depth={}] {} => {}: header={}",
+                        this, depth, source, destination, header);
+            }
+        }
+
+        handleDirectoryTransferRequest(source, srcIn, srcOut, destination, dstIn, dstOut, depth, time, header);
+    }
+
+    @SuppressWarnings("checkstyle:ParameterNumber")
+    protected void handleDirectoryTransferRequest(
+            String srcPath, InputStream srcIn, OutputStream srcOut,
+            String dstPath, InputStream dstIn, OutputStream dstOut,
+            int depth, ScpTimestampCommandDetails dirTime, String header)
+            throws IOException {
+        if (header.charAt(0) != ScpReceiveDirCommandDetails.COMMAND_NAME) {
+            throw new IllegalArgumentException("Invalid file transfer request: " + header);
+        }
 
-        // Pass along the "Cmmmm <length> <filename" command and wait for ACK
         ScpIoUtils.writeLine(dstOut, header);
         int statusCode = transferStatusCode(header, dstIn, srcOut);
-        ScpIoUtils.validateCommandStatusCode("[DST] " + header, "redirectReceivedFile", statusCode, false);
-        // Wait with ACK ready for transfer until ready to transfer data
-        long xferCount = transferFileData(source, srcIn, srcOut, destination, dstIn, dstOut, time, details);
+        ScpIoUtils.validateCommandStatusCode("[DST@" + depth + "] " + header, "handleDirectoryTransferRequest", statusCode,
+                false);
+
+        ScpReceiveDirCommandDetails dirDetails = new ScpReceiveDirCommandDetails(header);
+        signalReceivedCommand(dirDetails);
+
+        String dirName = dirDetails.getName();
+        // 1st command refers to the first path component of the original source/destination
+        String source = (depth == 0) ? srcPath : SelectorUtils.concatPaths(srcPath, dirName, '/');
+        String destination = (depth == 0) ? dstPath : SelectorUtils.concatPaths(dstPath, dirName, '/');
+
+        ClientSession srcSession = getSourceSession();
+        ClientSession dstSession = getDestinationSession();
+        if (listener != null) {
+            listener.startDirectDirectoryTransfer(srcSession, source, dstSession, destination, dirTime, dirDetails);
+        }
+
+        try {
+            for (boolean debugEnabled = log.isDebugEnabled(), dirEndSignal = false;
+                 !dirEndSignal;
+                 debugEnabled = log.isDebugEnabled()) {
+                header = ScpIoUtils.readLine(srcIn, false);
+                if (debugEnabled) {
+                    log.debug("handleDirectoryTransferRequest({})[depth={}] {} => {}: header={}",
+                            this, depth, source, destination, header);
+                }
+
+                ScpTimestampCommandDetails time = null;
+                char cmdName = header.charAt(0);
+                if (cmdName == ScpTimestampCommandDetails.COMMAND_NAME) {
+                    // Pass along the "T<mtime> 0 <atime> 0" and wait for response
+                    time = new ScpTimestampCommandDetails(header);
+                    signalReceivedCommand(time);
+
+                    header = transferTimestampCommand(source, srcIn, srcOut, destination, dstIn, dstOut, header);
+                    if (debugEnabled) {
+                        log.debug("handleDirectoryTransferRequest({})[depth={}] {} => {}: header={}",
+                                this, depth, source, destination, header);
+                    }
+                    cmdName = header.charAt(0);
+                }
+
+                switch (cmdName) {
+                    case ScpReceiveFileCommandDetails.COMMAND_NAME:
+                    case ScpReceiveDirCommandDetails.COMMAND_NAME: {
+                        ScpPathCommandDetailsSupport subPathDetails = (cmdName == ScpReceiveFileCommandDetails.COMMAND_NAME)
+                                ? new ScpReceiveFileCommandDetails(header)
+                                : new ScpReceiveDirCommandDetails(header);
+                        String name = subPathDetails.getName();
+                        String srcSubPath = SelectorUtils.concatPaths(source, name, '/');
+                        String dstSubPath = SelectorUtils.concatPaths(destination, name, '/');
+                        if (cmdName == ScpReceiveFileCommandDetails.COMMAND_NAME) {
+                            handleFileTransferRequest(srcSubPath, srcIn, srcOut, dstSubPath, dstIn, dstOut, time, header);
+                        } else {
+                            handleDirectoryTransferRequest(srcSubPath, srcIn, srcOut, dstSubPath, dstIn, dstOut, depth + 1,
+                                    time, header);
+                        }
+                        break;
+                    }
+
+                    case ScpDirEndCommandDetails.COMMAND_NAME: {
+                        ScpIoUtils.writeLine(dstOut, header);
+                        statusCode = transferStatusCode(header, dstIn, srcOut);
+                        ScpIoUtils.validateCommandStatusCode("[DST@" + depth + "] " + header, "handleDirectoryTransferRequest",
+                                statusCode, false);
+
+                        ScpDirEndCommandDetails details = ScpDirEndCommandDetails.parse(header);
+                        signalReceivedCommand(details);
+                        dirEndSignal = true;
+                        break;
+                    }
+
+                    default:
+                        throw new StreamCorruptedException("Unexpected file command: " + header);
+                }
+            }
+        } catch (IOException | RuntimeException | Error e) {
+            if (listener != null) {
+                listener.endDirectDirectoryTransfer(srcSession, source, dstSession, destination, dirTime, dirDetails, e);
+            }
+            throw e;
+        }
+
+        if (listener != null) {
+            listener.endDirectDirectoryTransfer(srcSession, source, dstSession, destination, dirTime, dirDetails, null);
+        }
+    }
+
+    protected long transferSimpleFile(
+            String source, InputStream srcIn, OutputStream srcOut,
+            String destination, InputStream dstIn, OutputStream dstOut,
+            String header, long length)
+            throws IOException {
+        if (length < 0L) { // TODO consider throwing an exception...
+            log.warn("transferSimpleFile({})[{} => {}] bad length in header: {}",
+                    this, source, destination, header);
+        }
+
+        long xferCount;
+        try (InputStream inputStream = new LimitInputStream(srcIn, length)) {
+            ScpIoUtils.ack(srcOut); // ready to receive the data from source
+            xferCount = IoUtils.copy(inputStream, dstOut);
+            dstOut.flush(); // make sure all data sent to destination
+        }
+
+        if (log.isDebugEnabled()) {
+            log.debug("transferSimpleFile({})[{} => {}] xfer {}/{}",
+                    this, source, destination, xferCount, length);
+        }
 
         // wait for source to signal data finished and pass it along
-        statusCode = transferStatusCode("SRC-EOF", srcIn, dstOut);
-        ScpIoUtils.validateCommandStatusCode("[SRC-EOF] " + header, "redirectReceivedFile", statusCode, false);
+        int statusCode = transferStatusCode("SRC-EOF", srcIn, dstOut);
+        ScpIoUtils.validateCommandStatusCode("[SRC-EOF] " + header, "transferSimpleFile", statusCode, false);
 
         // wait for destination to signal data received
         statusCode = ScpIoUtils.readAck(dstIn, false, log, "DST-EOF");
-        ScpIoUtils.validateCommandStatusCode("[DST-EOF] " + header, "redirectReceivedFile", statusCode, false);
+        ScpIoUtils.validateCommandStatusCode("[DST-EOF] " + header, "transferSimpleFile", statusCode, false);
         return xferCount;
     }
 
     protected String transferTimestampCommand(
             String source, InputStream srcIn, OutputStream srcOut,
             String destination, InputStream dstIn, OutputStream dstOut,
-            ScpTimestampCommandDetails time)
+            String header)
             throws IOException {
-        signalReceivedCommand(time);
-
-        String header = time.toHeader();
         ScpIoUtils.writeLine(dstOut, header);
         int statusCode = transferStatusCode(header, dstIn, srcOut);
         ScpIoUtils.validateCommandStatusCode("[DST] " + header, "transferTimestampCommand", statusCode, false);
 
         header = ScpIoUtils.readLine(srcIn, false);
-        if (log.isDebugEnabled()) {
-            log.debug("transferTimestampCommand({}) header={}", this, header);
-        }
-
         return header;
     }
 
@@ -218,46 +420,6 @@ public class ScpRemote2RemoteTransferHelper extends AbstractLoggingBean {
         return statusCode;
     }
 
-    protected long transferFileData(
-            String source, InputStream srcIn, OutputStream srcOut,
-            String destination, InputStream dstIn, OutputStream dstOut,
-            ScpTimestampCommandDetails time, ScpReceiveFileCommandDetails details)
-            throws IOException {
-        long length = details.getLength();
-        if (length < 0L) { // TODO consider throwing an exception...
-            log.warn("transferFileData({})[{} => {}] bad length in header: {}",
-                    this, source, destination, details.toHeader());
-        }
-
-        ClientSession srcSession = getSourceSession();
-        ClientSession dstSession = getDestinationSession();
-        if (listener != null) {
-            listener.startDirectFileTransfer(srcSession, source, dstSession, destination, time, details);
-        }
-
-        long xferCount;
-        try (InputStream inputStream = new LimitInputStream(srcIn, length)) {
-            ScpIoUtils.ack(srcOut); // ready to receive the data from source
-            xferCount = IoUtils.copy(inputStream, dstOut);
-            dstOut.flush(); // make sure all data sent to destination
-        } catch (IOException | RuntimeException | Error e) {
-            if (listener != null) {
-                listener.endDirectFileTransfer(srcSession, source, dstSession, destination, time, details, 0L, e);
-            }
-            throw e;
-        }
-
-        if (log.isDebugEnabled()) {
-            log.debug("transferFileData({})[{} => {}] xfer {}/{} for {}",
-                    this, source, destination, xferCount, length, details.getName());
-        }
-        if (listener != null) {
-            listener.endDirectFileTransfer(srcSession, source, dstSession, destination, time, details, xferCount, null);
-        }
-
-        return xferCount;
-    }
-
     // Useful "hook" for implementors
     protected void signalReceivedCommand(AbstractScpCommandDetails details) throws IOException {
         if (log.isDebugEnabled()) {
diff --git a/sshd-scp/src/main/java/org/apache/sshd/scp/client/ScpRemote2RemoteTransferListener.java b/sshd-scp/src/main/java/org/apache/sshd/scp/client/ScpRemote2RemoteTransferListener.java
index 8ddad59..5aea4a4 100644
--- a/sshd-scp/src/main/java/org/apache/sshd/scp/client/ScpRemote2RemoteTransferListener.java
+++ b/sshd-scp/src/main/java/org/apache/sshd/scp/client/ScpRemote2RemoteTransferListener.java
@@ -22,6 +22,7 @@ package org.apache.sshd.scp.client;
 import java.io.IOException;
 
 import org.apache.sshd.client.session.ClientSession;
+import org.apache.sshd.scp.common.helpers.ScpReceiveDirCommandDetails;
 import org.apache.sshd.scp.common.helpers.ScpReceiveFileCommandDetails;
 import org.apache.sshd.scp.common.helpers.ScpTimestampCommandDetails;
 
@@ -65,4 +66,40 @@ public interface ScpRemote2RemoteTransferListener {
             ScpTimestampCommandDetails timestamp, ScpReceiveFileCommandDetails details,
             long xferSize, Throwable thrown)
             throws IOException;
+
+    /**
+     * Indicates start of direct directory transfer
+     *
+     * @param  srcSession  The source {@link ClientSession}
+     * @param  source      The source path
+     * @param  dstSession  The destination {@link ClientSession}
+     * @param  destination The destination path
+     * @param  timestamp   The {@link ScpTimestampCommandDetails timestamp} of the directory - may be {@code null}
+     * @param  details     The {@link ScpReceiveDirCommandDetails details} of the attempted directory transfer
+     * @throws IOException If failed to handle the callback
+     */
+    void startDirectDirectoryTransfer(
+            ClientSession srcSession, String source,
+            ClientSession dstSession, String destination,
+            ScpTimestampCommandDetails timestamp, ScpReceiveDirCommandDetails details)
+            throws IOException;
+
+    /**
+     * Indicates end of direct file transfer
+     *
+     * @param  srcSession  The source {@link ClientSession}
+     * @param  source      The source path
+     * @param  dstSession  The destination {@link ClientSession}
+     * @param  destination The destination path
+     * @param  timestamp   The {@link ScpTimestampCommandDetails timestamp} of the directory - may be {@code null}
+     * @param  details     The {@link ScpReceiveDirCommandDetails details} of the attempted directory transfer
+     * @param  thrown      Error thrown during transfer attempt - {@code null} if successful
+     * @throws IOException If failed to handle the callback
+     */
+    void endDirectDirectoryTransfer(
+            ClientSession srcSession, String source,
+            ClientSession dstSession, String destination,
+            ScpTimestampCommandDetails timestamp, ScpReceiveDirCommandDetails details,
+            Throwable thrown)
+            throws IOException;
 }
diff --git a/sshd-scp/src/main/java/org/apache/sshd/scp/common/ScpHelper.java b/sshd-scp/src/main/java/org/apache/sshd/scp/common/ScpHelper.java
index 4a6b111..1a68d86 100644
--- a/sshd-scp/src/main/java/org/apache/sshd/scp/common/ScpHelper.java
+++ b/sshd-scp/src/main/java/org/apache/sshd/scp/common/ScpHelper.java
@@ -160,8 +160,67 @@ public class ScpHelper extends AbstractLoggingBean implements SessionHolder<Sess
         });
     }
 
+    /**
+     * Reads command line(s) and invokes the handler until EOF or and &quot;E&quot; command is received
+     *
+     * @param  handler     The {@link ScpReceiveLineHandler} to invoke when a command has been read
+     * @throws IOException If failed to read/write
+     */
     protected void receive(ScpReceiveLineHandler handler) throws IOException {
-        ScpIoUtils.receive(getSession(), in, out, log, this, handler);
+        ack();
+
+        boolean debugEnabled = log.isDebugEnabled();
+        Session session = getSession();
+        for (ScpTimestampCommandDetails time = null;; debugEnabled = log.isDebugEnabled()) {
+            String line;
+            boolean isDir = false;
+            int c = readAck(true);
+            switch (c) {
+                case -1:
+                    return;
+                case ScpReceiveDirCommandDetails.COMMAND_NAME:
+                    line = ScpIoUtils.readLine(in);
+                    line = Character.toString((char) c) + line;
+                    isDir = true;
+                    if (debugEnabled) {
+                        log.debug("receive({}) - Received 'D' header: {}", this, line);
+                    }
+                    break;
+                case ScpReceiveFileCommandDetails.COMMAND_NAME:
+                    line = ScpIoUtils.readLine(in);
+                    line = Character.toString((char) c) + line;
+                    if (debugEnabled) {
+                        log.debug("receive({}) - Received 'C' header: {}", this, line);
+                    }
+                    break;
+                case ScpTimestampCommandDetails.COMMAND_NAME:
+                    line = ScpIoUtils.readLine(in);
+                    line = Character.toString((char) c) + line;
+                    if (debugEnabled) {
+                        log.debug("receive({}) - Received 'T' header: {}", this, line);
+                    }
+                    time = ScpTimestampCommandDetails.parse(line);
+                    ack();
+                    continue;
+                case ScpDirEndCommandDetails.COMMAND_NAME:
+                    line = ScpIoUtils.readLine(in);
+                    line = Character.toString((char) c) + line;
+                    if (debugEnabled) {
+                        log.debug("receive({}) - Received 'E' header: {}", this, line);
+                    }
+                    ack();
+                    return;
+                default:
+                    // a real ack that has been acted upon already
+                    continue;
+            }
+
+            try {
+                handler.process(session, line, isDir, time);
+            } finally {
+                time = null;
+            }
+        }
     }
 
     public void receiveDir(String header, Path local, ScpTimestampCommandDetails time, boolean preserve, int bufferSize)
@@ -176,7 +235,7 @@ public class ScpHelper extends AbstractLoggingBean implements SessionHolder<Sess
         ScpReceiveDirCommandDetails details = new ScpReceiveDirCommandDetails(header);
         String name = details.getName();
         long length = details.getLength();
-        if (length != 0) {
+        if (length != 0L) {
             throw new IOException("Expected 0 length for directory=" + name + " but got " + length);
         }
 
@@ -207,7 +266,7 @@ public class ScpHelper extends AbstractLoggingBean implements SessionHolder<Sess
                     ack();
                     break;
                 } else if (cmdChar == ScpTimestampCommandDetails.COMMAND_NAME) {
-                    time = ScpTimestampCommandDetails.parseTime(header);
+                    time = ScpTimestampCommandDetails.parse(header);
                     ack();
                 } else {
                     throw new IOException("Unexpected message: '" + header + "'");
diff --git a/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/ScpDirEndCommandDetails.java b/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/ScpDirEndCommandDetails.java
index 3075384..6fd02e5 100644
--- a/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/ScpDirEndCommandDetails.java
+++ b/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/ScpDirEndCommandDetails.java
@@ -19,6 +19,8 @@
 
 package org.apache.sshd.scp.common.helpers;
 
+import org.apache.sshd.common.util.GenericUtils;
+
 /**
  * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
  */
@@ -64,4 +66,16 @@ public class ScpDirEndCommandDetails extends AbstractScpCommandDetails {
         // All ScpDirEndCommandDetails are equal to each other
         return true;
     }
+
+    public static ScpDirEndCommandDetails parse(String header) {
+        if (GenericUtils.isEmpty(header)) {
+            return null;
+        }
+
+        if (HEADER.equals(header)) {
+            return INSTANCE;
+        }
+
+        throw new IllegalArgumentException("Invalid header: " + header);
+    }
 }
diff --git a/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/ScpIoUtils.java b/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/ScpIoUtils.java
index 0f95834..775a502 100644
--- a/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/ScpIoUtils.java
+++ b/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/ScpIoUtils.java
@@ -35,12 +35,10 @@ import org.apache.sshd.client.channel.ChannelExec;
 import org.apache.sshd.client.channel.ClientChannel;
 import org.apache.sshd.client.channel.ClientChannelEvent;
 import org.apache.sshd.client.session.ClientSession;
-import org.apache.sshd.common.session.Session;
 import org.apache.sshd.common.util.GenericUtils;
 import org.apache.sshd.core.CoreModuleProperties;
 import org.apache.sshd.scp.ScpModuleProperties;
 import org.apache.sshd.scp.common.ScpException;
-import org.apache.sshd.scp.common.ScpReceiveLineHandler;
 import org.slf4j.Logger;
 
 /**
@@ -183,75 +181,6 @@ public final class ScpIoUtils {
         out.flush();
     }
 
-    /**
-     * Reads command line(s) and invokes the handler until EOF or and &quot;E&quot; command is received
-     *
-     * @param  session     The associated {@link Session}
-     * @param  in          The {@link InputStream} to read from
-     * @param  out         The {@link OutputStream} to write ACKs to
-     * @param  log         An optional {@link Logger} to use for issuing log messages - ignored if {@code null}
-     * @param  logHint     An optional hint to be used in the logged messages to identifier the caller's context
-     * @param  handler     The {@link ScpReceiveLineHandler} to invoke when a command has been read
-     * @throws IOException If failed to read/write
-     */
-    public static void receive(
-            Session session, InputStream in, OutputStream out, Logger log, Object logHint, ScpReceiveLineHandler handler)
-            throws IOException {
-        ack(out);
-
-        boolean debugEnabled = (log != null) && log.isDebugEnabled();
-        for (ScpTimestampCommandDetails time = null;;) {
-            String line;
-            boolean isDir = false;
-            int c = readAck(in, true, log, logHint);
-            switch (c) {
-                case -1:
-                    return;
-                case ScpReceiveDirCommandDetails.COMMAND_NAME:
-                    line = readLine(in);
-                    line = Character.toString((char) c) + line;
-                    isDir = true;
-                    if (debugEnabled) {
-                        log.debug("receive({}) - Received 'D' header: {}", logHint, line);
-                    }
-                    break;
-                case ScpReceiveFileCommandDetails.COMMAND_NAME:
-                    line = readLine(in);
-                    line = Character.toString((char) c) + line;
-                    if (debugEnabled) {
-                        log.debug("receive({}) - Received 'C' header: {}", logHint, line);
-                    }
-                    break;
-                case ScpTimestampCommandDetails.COMMAND_NAME:
-                    line = readLine(in);
-                    line = Character.toString((char) c) + line;
-                    if (debugEnabled) {
-                        log.debug("receive({}) - Received 'T' header: {}", logHint, line);
-                    }
-                    time = ScpTimestampCommandDetails.parseTime(line);
-                    ack(out);
-                    continue;
-                case ScpDirEndCommandDetails.COMMAND_NAME:
-                    line = readLine(in);
-                    line = Character.toString((char) c) + line;
-                    if (debugEnabled) {
-                        log.debug("receive({}) - Received 'E' header: {}", logHint, line);
-                    }
-                    ack(out);
-                    return;
-                default:
-                    // a real ack that has been acted upon already
-                    continue;
-            }
-
-            try {
-                handler.process(session, line, isDir, time);
-            } finally {
-                time = null;
-            }
-        }
-    }
-
     public static <O extends OutputStream> O sendWarning(O out, String message) throws IOException {
         return sendResponseMessage(out, WARNING, message);
     }
diff --git a/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/ScpTimestampCommandDetails.java b/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/ScpTimestampCommandDetails.java
index e1a085d..a8fa773 100644
--- a/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/ScpTimestampCommandDetails.java
+++ b/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/ScpTimestampCommandDetails.java
@@ -111,7 +111,7 @@ public class ScpTimestampCommandDetails extends AbstractScpCommandDetails {
      * @see                          <A HREF="https://blogs.oracle.com/janp/entry/how_the_scp_protocol_works">How the
      *                               SCP protocol works</A>
      */
-    public static ScpTimestampCommandDetails parseTime(String line) throws NumberFormatException {
+    public static ScpTimestampCommandDetails parse(String line) throws NumberFormatException {
         return GenericUtils.isEmpty(line) ? null : new ScpTimestampCommandDetails(line);
     }
 }
diff --git a/sshd-scp/src/test/java/org/apache/sshd/scp/client/ScpRemote2RemoteTransferHelperTest.java b/sshd-scp/src/test/java/org/apache/sshd/scp/client/ScpRemote2RemoteTransferHelperTest.java
index f28739c..043a37d 100644
--- a/sshd-scp/src/test/java/org/apache/sshd/scp/client/ScpRemote2RemoteTransferHelperTest.java
+++ b/sshd-scp/src/test/java/org/apache/sshd/scp/client/ScpRemote2RemoteTransferHelperTest.java
@@ -20,33 +20,107 @@
 package org.apache.sshd.scp.client;
 
 import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.DirectoryStream;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.nio.file.attribute.PosixFilePermission;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Set;
 import java.util.concurrent.atomic.AtomicLong;
 
 import org.apache.sshd.client.session.ClientSession;
+import org.apache.sshd.common.session.Session;
 import org.apache.sshd.common.util.io.IoUtils;
 import org.apache.sshd.scp.common.ScpHelper;
+import org.apache.sshd.scp.common.ScpTransferEventListener;
+import org.apache.sshd.scp.common.helpers.ScpReceiveDirCommandDetails;
 import org.apache.sshd.scp.common.helpers.ScpReceiveFileCommandDetails;
 import org.apache.sshd.scp.common.helpers.ScpTimestampCommandDetails;
+import org.apache.sshd.scp.server.ScpCommandFactory;
 import org.apache.sshd.util.test.CommonTestSupportUtils;
+import org.apache.sshd.util.test.JUnit4ClassRunnerWithParametersFactory;
 import org.junit.BeforeClass;
 import org.junit.FixMethodOrder;
 import org.junit.Test;
+import org.junit.runner.RunWith;
 import org.junit.runners.MethodSorters;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameters;
+import org.junit.runners.Parameterized.UseParametersRunnerFactory;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /**
  * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
  */
 @FixMethodOrder(MethodSorters.NAME_ASCENDING)
+@RunWith(Parameterized.class) // see https://github.com/junit-team/junit/wiki/Parameterized-tests
+@UseParametersRunnerFactory(JUnit4ClassRunnerWithParametersFactory.class)
 public class ScpRemote2RemoteTransferHelperTest extends AbstractScpTestSupport {
-    public ScpRemote2RemoteTransferHelperTest() {
-        super();
+    protected final Logger log;
+    private final boolean preserveAttributes;
+
+    public ScpRemote2RemoteTransferHelperTest(boolean preserveAttributes) {
+        this.preserveAttributes = preserveAttributes;
+        this.log = LoggerFactory.getLogger(getClass());
     }
 
     @BeforeClass
     public static void setupClientAndServer() throws Exception {
         setupClientAndServer(ScpRemote2RemoteTransferHelperTest.class);
+
+        ScpCommandFactory factory = (ScpCommandFactory) sshd.getCommandFactory();
+        factory.addEventListener(new ScpTransferEventListener() {
+            private final Logger log = LoggerFactory.getLogger(ScpRemote2RemoteTransferHelperTest.class);
+
+            @Override
+            public void startFileEvent(
+                    Session session, FileOperation op, Path file,
+                    long length, Set<PosixFilePermission> perms)
+                    throws IOException {
+                log.info("startFileEvent({})[{}] {}", session, op, file);
+            }
+
+            @Override
+            public void endFileEvent(
+                    Session session, FileOperation op, Path file,
+                    long length, Set<PosixFilePermission> perms, Throwable thrown)
+                    throws IOException {
+                if (thrown == null) {
+                    log.info("endFileEvent({})[{}] {}", session, op, file);
+                } else {
+                    log.error("endFileEvent({})[{}] {}: {}", session, op, file, thrown);
+                }
+            }
+
+            @Override
+            public void startFolderEvent(
+                    Session session, FileOperation op, Path file,
+                    Set<PosixFilePermission> perms)
+                    throws IOException {
+                log.info("startFolderEvent({})[{}] {}", session, op, file);
+            }
+
+            @Override
+            public void endFolderEvent(
+                    Session session, FileOperation op, Path file,
+                    Set<PosixFilePermission> perms,
+                    Throwable thrown)
+                    throws IOException {
+                if (thrown == null) {
+                    log.info("endFolderEvent({})[{}] {}", session, op, file);
+                } else {
+                    log.error("endFolderEvent({})[{}] {}: {}", session, op, file, thrown);
+                }
+            }
+        });
+    }
+
+    @Parameters(name = "preserveAttributes={0}")
+    public static List<Object[]> parameters() {
+        return parameterize(Arrays.asList(Boolean.TRUE, Boolean.FALSE));
     }
 
     @Test
@@ -54,7 +128,7 @@ public class ScpRemote2RemoteTransferHelperTest extends AbstractScpTestSupport {
         Path targetPath = detectTargetFolder();
         Path parentPath = targetPath.getParent();
         Path scpRoot = CommonTestSupportUtils.resolve(targetPath,
-                ScpHelper.SCP_COMMAND_PREFIX, getClass().getSimpleName(), getCurrentTestName());
+                ScpHelper.SCP_COMMAND_PREFIX, getClass().getSimpleName(), "testTransferFiles-" + preserveAttributes);
         CommonTestSupportUtils.deleteRecursive(scpRoot);    // start clean
 
         Path srcDir = assertHierarchyTargetFolderExists(scpRoot.resolve("srcdir"));
@@ -95,8 +169,29 @@ public class ScpRemote2RemoteTransferHelperTest extends AbstractScpTestSupport {
                             long prev = xferCount.getAndSet(xferSize);
                             assertEquals("Mismatched 1st end file xfer size", 0L, prev);
                         }
+
+                        @Override
+                        public void startDirectDirectoryTransfer(
+                                ClientSession srcSession, String source,
+                                ClientSession dstSession, String destination,
+                                ScpTimestampCommandDetails timestamp,
+                                ScpReceiveDirCommandDetails details)
+                                throws IOException {
+                            fail("Unexpected start directory transfer: " + source + " => " + destination);
+                        }
+
+                        @Override
+                        public void endDirectDirectoryTransfer(
+                                ClientSession srcSession, String source,
+                                ClientSession dstSession, String destination,
+                                ScpTimestampCommandDetails timestamp,
+                                ScpReceiveDirCommandDetails details,
+                                Throwable thrown)
+                                throws IOException {
+                            fail("Unexpected end directory transfer: " + source + " => " + destination);
+                        }
                     });
-            helper.transferFile(srcPath, dstPath, true);
+            helper.transferFile(srcPath, dstPath, preserveAttributes);
         }
         assertEquals("Mismatched transfer size", expectedData.length, xferCount.getAndSet(0L));
 
@@ -104,6 +199,119 @@ public class ScpRemote2RemoteTransferHelperTest extends AbstractScpTestSupport {
         assertArrayEquals("Mismatched transfer contents", expectedData, actualData);
     }
 
+    @Test
+    public void testTransferDirectoriesRecursively() throws Exception {
+        Path targetPath = detectTargetFolder();
+        Path parentPath = targetPath.getParent();
+        Path scpRoot = CommonTestSupportUtils.resolve(targetPath,
+                ScpHelper.SCP_COMMAND_PREFIX, getClass().getSimpleName(),
+                "testTransferDirectories-" + preserveAttributes);
+        CommonTestSupportUtils.deleteRecursive(scpRoot);    // start clean
+
+        Path srcDir = assertHierarchyTargetFolderExists(scpRoot.resolve("srcdir"));
+        Path curDir = assertHierarchyTargetFolderExists(srcDir.resolve("root"));
+        String srcPath = CommonTestSupportUtils.resolveRelativeRemotePath(parentPath, curDir);
+        for (int depth = 0; depth <= 3; depth++) {
+            curDir = assertHierarchyTargetFolderExists(curDir);
+
+            Path curFile = curDir.resolve(depth + ".txt");
+            CommonTestSupportUtils.writeFile(
+                    curFile, getClass().getName() + "#" + getCurrentTestName() + "@" + depth + IoUtils.EOL);
+            curDir = curDir.resolve("0" + Integer.toHexString(depth));
+        }
+
+        Path dstDir = assertHierarchyTargetFolderExists(scpRoot.resolve("dstdir"));
+        String dstPath = CommonTestSupportUtils.resolveRelativeRemotePath(parentPath, dstDir);
+        try (ClientSession srcSession = createClientSession(getCurrentTestName() + "-src");
+             ClientSession dstSession = createClientSession(getCurrentTestName() + "-dst")) {
+            ScpRemote2RemoteTransferHelper helper = new ScpRemote2RemoteTransferHelper(
+                    srcSession, dstSession,
+                    new ScpRemote2RemoteTransferListener() {
+                        private final String logHint = getCurrentTestName();
+
+                        @Override
+                        public void startDirectFileTransfer(
+                                ClientSession srcSession, String source,
+                                ClientSession dstSession, String destination,
+                                ScpTimestampCommandDetails timestamp,
+                                ScpReceiveFileCommandDetails details)
+                                throws IOException {
+                            log.info("{}: startDirectFileTransfer - {} => {}",
+                                    logHint, source, destination);
+                        }
+
+                        @Override
+                        public void startDirectDirectoryTransfer(
+                                ClientSession srcSession, String source,
+                                ClientSession dstSession, String destination,
+                                ScpTimestampCommandDetails timestamp,
+                                ScpReceiveDirCommandDetails details)
+                                throws IOException {
+                            log.info("{}: startDirectDirectoryTransfer -  {} => {}",
+                                    logHint, source, destination);
+                        }
+
+                        @Override
+                        public void endDirectFileTransfer(
+                                ClientSession srcSession, String source,
+                                ClientSession dstSession, String destination,
+                                ScpTimestampCommandDetails timestamp,
+                                ScpReceiveFileCommandDetails details,
+                                long xferSize, Throwable thrown)
+                                throws IOException {
+                            log.info("{}: endDirectFileTransfer - {} => {}: size={}, thrown={}",
+                                    logHint, source, destination, xferSize,
+                                    (thrown == null) ? null : thrown.getClass().getSimpleName());
+                        }
+
+                        @Override
+                        public void endDirectDirectoryTransfer(
+                                ClientSession srcSession, String source,
+                                ClientSession dstSession, String destination,
+                                ScpTimestampCommandDetails timestamp,
+                                ScpReceiveDirCommandDetails details,
+                                Throwable thrown)
+                                throws IOException {
+                            log.info("{}: endDirectDirectoryTransfer {} => {}: thrown={}",
+                                    logHint, source, destination, (thrown == null) ? null : thrown.getClass().getSimpleName());
+                        }
+                    });
+            helper.transferDirectory(srcPath, dstPath, preserveAttributes);
+        }
+
+        validateXferDirContents(srcDir, dstDir);
+    }
+
+    private static void validateXferDirContents(Path srcPath, Path dstPath) throws Exception {
+        try (DirectoryStream<Path> srcDir = Files.newDirectoryStream(srcPath)) {
+            for (Path srcFile : srcDir) {
+                String name = srcFile.getFileName().toString();
+                Path dstFile = dstPath.resolve(name);
+                if (Files.isDirectory(srcFile)) {
+                    validateXferDirContents(srcFile, dstFile);
+                } else {
+                    byte[] srcData = Files.readAllBytes(srcFile);
+                    byte[] dstData = Files.readAllBytes(dstFile);
+                    assertEquals(name + "[DATA]",
+                            new String(srcData, StandardCharsets.UTF_8),
+                            new String(dstData, StandardCharsets.UTF_8));
+                }
+            }
+        }
+
+        try (DirectoryStream<Path> dstDir = Files.newDirectoryStream(dstPath)) {
+            for (Path dstFile : dstDir) {
+                String name = dstFile.getFileName().toString();
+                Path srcFile = srcPath.resolve(name);
+                if (Files.isDirectory(dstFile)) {
+                    assertTrue(name + ": unmatched destination folder", Files.isDirectory(srcFile));
+                } else {
+                    assertTrue(name + ": unmatched destination file", Files.exists(srcFile));
+                }
+            }
+        }
+    }
+
     private ClientSession createClientSession(String username) throws IOException {
         ClientSession session = client.connect(username, TEST_LOCALHOST, port)
                 .verify(CONNECT_TIMEOUT)
@@ -121,4 +329,9 @@ public class ScpRemote2RemoteTransferHelperTest extends AbstractScpTestSupport {
             }
         }
     }
+
+    @Override
+    public String toString() {
+        return getClass().getSimpleName() + "[preserveAttributes=" + preserveAttributes + "]";
+    }
 }