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 <lgoldst...@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:d...@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:d...@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:d...@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:d...@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:d...@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);
+    }
+}

Reply via email to