You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@mina.apache.org by lg...@apache.org on 2020/08/18 05:49:03 UTC

[mina-sshd] branch master updated (37ffa46 -> 39a0841)

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

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


    from 37ffa46  [SSHD-1004] Using a more constant time MAC validation to minimize timing side channel information leak
     new faa087e  Added support for 'keeaplive@*' global request pattern
     new 123140f2 [SSHD-1020] Added a default NIO2-READ-TIMEOUT value for tests
     new c52e1c4  [SSHD-1005] Added ScpTransferEventListener if verbose level in SshServerCliSupport setup
     new a2afd6b  [SSHD-1005] Restructured some SCP code to enable easier re-use
     new 2be1660  [SSHD-1005] Added ScpTransferHelper support
     new 39a0841  [SSHD-1005] Create consistent SCP command details hierarchy

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


Summary of changes:
 CHANGES.md                                         |   1 +
 docs/scp.md                                        |  32 +-
 docs/sftp.md                                       |   4 +
 .../sshd/cli/server/SshServerCliSupport.java       |  14 +-
 .../org/apache/sshd/cli/server/SshServerMain.java  |  17 +-
 .../helper/ScpCommandTransferEventListener.java    |  45 +-
 .../org/apache/sshd/cli/server/SshFsMounter.java   |   7 +-
 .../org/apache/sshd/common/util/GenericUtils.java  |  28 +-
 .../sshd/server/global/KeepAliveHandler.java       |   3 +-
 .../java/org/apache/sshd/KeyReExchangeTest.java    |   3 +-
 .../org/apache/sshd/util/test/BaseTestSupport.java |  33 +-
 .../sshd/util/test/CoreTestSupportUtils.java       |  30 ++
 .../apache/sshd/scp/client/AbstractScpClient.java  |  85 +---
 .../apache/sshd/scp/client/DefaultScpClient.java   |   9 +-
 .../sshd/scp/client/DefaultScpStreamResolver.java  |   8 +-
 .../java/org/apache/sshd/scp/client/ScpClient.java |  10 +-
 .../scp/client/ScpRemote2RemoteTransferHelper.java | 254 +++++++++++
 .../client/ScpRemote2RemoteTransferListener.java   |  68 +++
 .../org/apache/sshd/scp/common/ScpException.java   |   4 +-
 .../org/apache/sshd/scp/common/ScpFileOpener.java  |  10 +-
 .../java/org/apache/sshd/scp/common/ScpHelper.java | 466 +++++----------------
 .../sshd/scp/common/ScpReceiveLineHandler.java     |   5 +-
 .../sshd/scp/common/ScpSourceStreamResolver.java   |   7 +-
 .../sshd/scp/common/ScpTargetStreamResolver.java   |   3 +-
 .../org/apache/sshd/scp/common/ScpTimestamp.java   |  70 ----
 .../common/helpers/AbstractScpCommandDetails.java  |  27 +-
 .../scp/common/helpers/CommandStatusHandler.java   |  28 +-
 .../helpers/LocalFileScpSourceStreamResolver.java  |   7 +-
 .../helpers/LocalFileScpTargetStreamResolver.java  |   5 +-
 .../common/helpers/ScpDirEndCommandDetails.java    |  54 +--
 .../apache/sshd/scp/common/helpers/ScpIoUtils.java | 422 +++++++++++++++++++
 .../helpers/ScpPathCommandDetailsSupport.java      | 211 ++++++++++
 .../helpers/ScpReceiveDirCommandDetails.java       |  33 +-
 .../helpers/ScpReceiveFileCommandDetails.java      |  25 +-
 .../common/helpers/ScpTimestampCommandDetails.java | 117 ++++++
 .../org/apache/sshd/scp/server/ScpCommand.java     |  13 +-
 .../java/org/apache/sshd/scp/server/ScpShell.java  |   9 +-
 .../sshd/scp/client/AbstractScpTestSupport.java    | 158 +++++++
 .../client/ScpRemote2RemoteTransferHelperTest.java | 124 ++++++
 .../java/org/apache/sshd/scp/client/ScpTest.java   | 207 ++-------
 .../helpers/AbstractScpCommandDetailsTest.java     |  69 ++-
 .../server/ScpReceiveDirCommandDetailsTest.java    |  25 +-
 42 files changed, 1827 insertions(+), 923 deletions(-)
 copy sshd-scp/src/main/java/org/apache/sshd/scp/common/AbstractScpTransferEventListenerAdapter.java => sshd-cli/src/main/java/org/apache/sshd/cli/server/helper/ScpCommandTransferEventListener.java (54%)
 create mode 100644 sshd-scp/src/main/java/org/apache/sshd/scp/client/ScpRemote2RemoteTransferHelper.java
 create mode 100644 sshd-scp/src/main/java/org/apache/sshd/scp/client/ScpRemote2RemoteTransferListener.java
 delete mode 100644 sshd-scp/src/main/java/org/apache/sshd/scp/common/ScpTimestamp.java
 copy sshd-common/src/main/java/org/apache/sshd/common/compression/CompressionDelayedZlib.java => sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/AbstractScpCommandDetails.java (69%)
 copy sshd-core/src/main/java/org/apache/sshd/client/auth/password/UserAuthPasswordFactory.java => sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/CommandStatusHandler.java (58%)
 copy sshd-sftp/src/main/java/org/apache/sshd/sftp/server/PrincipalBase.java => sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/ScpDirEndCommandDetails.java (53%)
 create mode 100644 sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/ScpIoUtils.java
 create mode 100644 sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/ScpPathCommandDetailsSupport.java
 copy sshd-core/src/main/java/org/apache/sshd/server/session/ServerSessionImpl.java => sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/ScpReceiveDirCommandDetails.java (53%)
 copy sshd-core/src/main/java/org/apache/sshd/common/channel/ChannelRequestHandler.java => sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/ScpReceiveFileCommandDetails.java (57%)
 create mode 100644 sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/ScpTimestampCommandDetails.java
 create mode 100644 sshd-scp/src/test/java/org/apache/sshd/scp/client/AbstractScpTestSupport.java
 create mode 100644 sshd-scp/src/test/java/org/apache/sshd/scp/client/ScpRemote2RemoteTransferHelperTest.java
 copy sshd-common/src/test/java/org/apache/sshd/common/PropertyResolverParseBooleanTest.java => sshd-scp/src/test/java/org/apache/sshd/scp/common/helpers/AbstractScpCommandDetailsTest.java (52%)
 copy sshd-common/src/test/java/org/apache/sshd/common/util/VersionInfoTest.java => sshd-scp/src/test/java/org/apache/sshd/scp/server/ScpReceiveDirCommandDetailsTest.java (64%)


[mina-sshd] 02/06: [SSHD-1020] Added a default NIO2-READ-TIMEOUT value for tests

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

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

commit 123140f2038bc8d00061126c6bce967e89bc210a
Author: Lyor Goldstein <lg...@apache.org>
AuthorDate: Fri Aug 14 19:27:40 2020 +0300

    [SSHD-1020] Added a default NIO2-READ-TIMEOUT value for tests
---
 .../java/org/apache/sshd/KeyReExchangeTest.java    |  3 +-
 .../org/apache/sshd/util/test/BaseTestSupport.java | 33 ++++------------------
 .../sshd/util/test/CoreTestSupportUtils.java       | 30 ++++++++++++++++++++
 3 files changed, 37 insertions(+), 29 deletions(-)

diff --git a/sshd-core/src/test/java/org/apache/sshd/KeyReExchangeTest.java b/sshd-core/src/test/java/org/apache/sshd/KeyReExchangeTest.java
index 18bef92..2724a8e 100644
--- a/sshd-core/src/test/java/org/apache/sshd/KeyReExchangeTest.java
+++ b/sshd-core/src/test/java/org/apache/sshd/KeyReExchangeTest.java
@@ -62,6 +62,7 @@ import org.apache.sshd.server.channel.ChannelSession;
 import org.apache.sshd.server.command.Command;
 import org.apache.sshd.server.subsystem.SubsystemFactory;
 import org.apache.sshd.util.test.BaseTestSupport;
+import org.apache.sshd.util.test.CoreTestSupportUtils;
 import org.apache.sshd.util.test.JSchLogger;
 import org.apache.sshd.util.test.OutputCountTrackingOutputStream;
 import org.apache.sshd.util.test.SimpleUserInfo;
@@ -654,7 +655,7 @@ public class KeyReExchangeTest extends BaseTestSupport {
                     teeOut.write("exit\n".getBytes(StandardCharsets.UTF_8));
                     teeOut.flush();
 
-                    Duration timeout = getTimeout("KeyReExchangeTest", Duration.ofSeconds(15));
+                    Duration timeout = CoreTestSupportUtils.getTimeout("KeyReExchangeTest", Duration.ofSeconds(15));
 
                     Collection<ClientChannelEvent> result = channel.waitFor(EnumSet.of(ClientChannelEvent.CLOSED), timeout);
                     assertFalse("Timeout while waiting for channel closure", result.contains(ClientChannelEvent.TIMEOUT));
diff --git a/sshd-core/src/test/java/org/apache/sshd/util/test/BaseTestSupport.java b/sshd-core/src/test/java/org/apache/sshd/util/test/BaseTestSupport.java
index 69b2614..94765ef 100644
--- a/sshd-core/src/test/java/org/apache/sshd/util/test/BaseTestSupport.java
+++ b/sshd-core/src/test/java/org/apache/sshd/util/test/BaseTestSupport.java
@@ -45,11 +45,11 @@ public abstract class BaseTestSupport extends JUnitTestSupport {
     public static final String TEST_LOCALHOST
             = System.getProperty("org.apache.sshd.test.localhost", SshdSocketAddress.LOCALHOST_IPV4);
 
-    public static final Duration CONNECT_TIMEOUT = getTimeout("connect", Duration.ofSeconds(7));
-    public static final Duration AUTH_TIMEOUT = getTimeout("auth", Duration.ofSeconds(5));
-    public static final Duration OPEN_TIMEOUT = getTimeout("open", Duration.ofSeconds(9));
-    public static final Duration DEFAULT_TIMEOUT = getTimeout("default", Duration.ofSeconds(5));
-    public static final Duration CLOSE_TIMEOUT = getTimeout("close", Duration.ofSeconds(15));
+    public static final Duration CONNECT_TIMEOUT = CoreTestSupportUtils.getTimeout("connect", Duration.ofSeconds(7));
+    public static final Duration AUTH_TIMEOUT = CoreTestSupportUtils.getTimeout("auth", Duration.ofSeconds(5));
+    public static final Duration OPEN_TIMEOUT = CoreTestSupportUtils.getTimeout("open", Duration.ofSeconds(9));
+    public static final Duration DEFAULT_TIMEOUT = CoreTestSupportUtils.getTimeout("default", Duration.ofSeconds(5));
+    public static final Duration CLOSE_TIMEOUT = CoreTestSupportUtils.getTimeout("close", Duration.ofSeconds(15));
 
     @Rule
     public final TestWatcher rule = new TestWatcher() {
@@ -98,29 +98,6 @@ public abstract class BaseTestSupport extends JUnitTestSupport {
         logger.setLevel(level);
     }
 
-    public static Duration getTimeout(String property, Duration defaultValue) {
-        // Do we have a specific timeout value ?
-        String str = System.getProperty("org.apache.sshd.test.timeout." + property);
-        if (GenericUtils.isNotEmpty(str)) {
-            return Duration.ofMillis(Long.parseLong(str));
-        }
-
-        // Do we have a specific factor ?
-        str = System.getProperty("org.apache.sshd.test.timeout.factor." + property);
-        if (GenericUtils.isEmpty(str)) {
-            // Do we have a global factor ?
-            str = System.getProperty("org.apache.sshd.test.timeout.factor");
-        }
-
-        if (GenericUtils.isNotEmpty(str)) {
-            double factor = Double.parseDouble(str);
-            long dur = Math.round(defaultValue.toMillis() * factor);
-            return Duration.ofMillis(dur);
-        }
-
-        return defaultValue;
-    }
-
     protected SshServer setupTestServer() {
         return CoreTestSupportUtils.setupTestServer(getClass());
     }
diff --git a/sshd-core/src/test/java/org/apache/sshd/util/test/CoreTestSupportUtils.java b/sshd-core/src/test/java/org/apache/sshd/util/test/CoreTestSupportUtils.java
index 4ad8967..6e52784 100644
--- a/sshd-core/src/test/java/org/apache/sshd/util/test/CoreTestSupportUtils.java
+++ b/sshd-core/src/test/java/org/apache/sshd/util/test/CoreTestSupportUtils.java
@@ -21,6 +21,7 @@ package org.apache.sshd.util.test;
 import java.net.InetAddress;
 import java.net.InetSocketAddress;
 import java.net.ServerSocket;
+import java.time.Duration;
 import java.util.ArrayList;
 
 import org.apache.sshd.client.ClientBuilder;
@@ -32,12 +33,16 @@ import org.apache.sshd.common.helpers.AbstractFactoryManager;
 import org.apache.sshd.common.kex.BuiltinDHFactories;
 import org.apache.sshd.common.keyprovider.KeyIdentityProvider;
 import org.apache.sshd.common.signature.BuiltinSignatures;
+import org.apache.sshd.common.util.GenericUtils;
+import org.apache.sshd.core.CoreModuleProperties;
 import org.apache.sshd.server.ServerBuilder;
 import org.apache.sshd.server.SshServer;
 import org.apache.sshd.server.auth.pubkey.AcceptAllPublickeyAuthenticator;
 import org.apache.sshd.server.shell.UnknownCommandFactory;
 
 public final class CoreTestSupportUtils {
+    public static final Duration READ_TIMEOUT = getTimeout("read.nio2", Duration.ofSeconds(60));
+
     private CoreTestSupportUtils() {
         throw new UnsupportedOperationException("No instance");
     }
@@ -55,6 +60,7 @@ public final class CoreTestSupportUtils {
         client.setServerKeyVerifier(AcceptAllServerKeyVerifier.INSTANCE);
         client.setHostConfigEntryResolver(HostConfigEntryResolver.EMPTY);
         client.setKeyIdentityProvider(KeyIdentityProvider.EMPTY_KEYS_PROVIDER);
+        CoreModuleProperties.NIO2_READ_TIMEOUT.set(client, READ_TIMEOUT);
         return client;
     }
 
@@ -77,6 +83,7 @@ public final class CoreTestSupportUtils {
         sshd.setPublickeyAuthenticator(AcceptAllPublickeyAuthenticator.INSTANCE);
         sshd.setShellFactory(EchoShellFactory.INSTANCE);
         sshd.setCommandFactory(UnknownCommandFactory.INSTANCE);
+        CoreModuleProperties.NIO2_READ_TIMEOUT.set(sshd, READ_TIMEOUT);
         return sshd;
     }
 
@@ -97,4 +104,27 @@ public final class CoreTestSupportUtils {
         manager.setSignatureFactories(new ArrayList<>(BuiltinSignatures.VALUES));
         return manager;
     }
+
+    public static Duration getTimeout(String property, Duration defaultValue) {
+        // Do we have a specific timeout value ?
+        String str = System.getProperty("org.apache.sshd.test.timeout." + property);
+        if (GenericUtils.isNotEmpty(str)) {
+            return Duration.ofMillis(Long.parseLong(str));
+        }
+
+        // Do we have a specific factor ?
+        str = System.getProperty("org.apache.sshd.test.timeout.factor." + property);
+        if (GenericUtils.isEmpty(str)) {
+            // Do we have a global factor ?
+            str = System.getProperty("org.apache.sshd.test.timeout.factor");
+        }
+
+        if (GenericUtils.isNotEmpty(str)) {
+            double factor = Double.parseDouble(str);
+            long dur = Math.round(defaultValue.toMillis() * factor);
+            return Duration.ofMillis(dur);
+        }
+
+        return defaultValue;
+    }
 }


[mina-sshd] 03/06: [SSHD-1005] Added ScpTransferEventListener if verbose level in SshServerCliSupport setup

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

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

commit c52e1c4d980e8ee98452945641a9bb2bc70a51cd
Author: Lyor Goldstein <lg...@apache.org>
AuthorDate: Mon Aug 17 11:20:57 2020 +0300

    [SSHD-1005] Added ScpTransferEventListener if verbose level in SshServerCliSupport setup
---
 .../sshd/cli/server/SshServerCliSupport.java       | 14 +++-
 .../org/apache/sshd/cli/server/SshServerMain.java  | 17 +++--
 .../helper/ScpCommandTransferEventListener.java    | 77 ++++++++++++++++++++++
 .../org/apache/sshd/cli/server/SshFsMounter.java   |  7 +-
 4 files changed, 104 insertions(+), 11 deletions(-)

diff --git a/sshd-cli/src/main/java/org/apache/sshd/cli/server/SshServerCliSupport.java b/sshd-cli/src/main/java/org/apache/sshd/cli/server/SshServerCliSupport.java
index c173cb7..d731231 100644
--- a/sshd-cli/src/main/java/org/apache/sshd/cli/server/SshServerCliSupport.java
+++ b/sshd-cli/src/main/java/org/apache/sshd/cli/server/SshServerCliSupport.java
@@ -38,6 +38,7 @@ import java.util.stream.Collectors;
 import java.util.stream.Stream;
 
 import org.apache.sshd.cli.CliSupport;
+import org.apache.sshd.cli.server.helper.ScpCommandTransferEventListener;
 import org.apache.sshd.cli.server.helper.ServerPortForwardingEventListener;
 import org.apache.sshd.cli.server.helper.SftpServerSubSystemEventListener;
 import org.apache.sshd.common.PropertyResolver;
@@ -254,12 +255,16 @@ public abstract class SshServerCliSupport extends CliSupport {
      * to load and instantiate it using a public no-args constructor</LI>
      * </UL>
      *
+     * @param  level     The verbosity {@link Level}
+     * @param  stdout    The STDOUT stream for logging
      * @param  stderr    The STDERR stream for errors
      * @param  options   The available options - assuming defaults if {@code null}
      * @return           The resolved {@link ShellFactory}
      * @throws Exception If failed to resolve
      */
-    public static ShellFactory resolveShellFactory(PrintStream stderr, PropertyResolver options) throws Exception {
+    public static ShellFactory resolveShellFactory(
+            Level level, PrintStream stdout, PrintStream stderr, PropertyResolver options)
+            throws Exception {
         String factory = (options == null) ? null : options.getString(SHELL_FACTORY_OPTION);
         if (GenericUtils.isEmpty(factory)) {
             return DEFAULT_SHELL_FACTORY;
@@ -270,7 +275,12 @@ public abstract class SshServerCliSupport extends CliSupport {
         }
 
         if (ScpCommandFactory.SCP_FACTORY_NAME.equalsIgnoreCase(factory)) {
-            return new ScpCommandFactory();
+            ScpCommandFactory shell = new ScpCommandFactory();
+            if (isEnabledVerbosityLogging(level)) {
+                shell.addEventListener(new ScpCommandTransferEventListener(stdout, stderr));
+            }
+
+            return shell;
         }
 
         ClassLoader cl = ThreadUtils.resolveDefaultClassLoader(ShellFactory.class);
diff --git a/sshd-cli/src/main/java/org/apache/sshd/cli/server/SshServerMain.java b/sshd-cli/src/main/java/org/apache/sshd/cli/server/SshServerMain.java
index 1646e08..0e437b5 100644
--- a/sshd-cli/src/main/java/org/apache/sshd/cli/server/SshServerMain.java
+++ b/sshd-cli/src/main/java/org/apache/sshd/cli/server/SshServerMain.java
@@ -29,6 +29,7 @@ import java.util.TreeMap;
 import java.util.logging.Level;
 import java.util.stream.Collectors;
 
+import org.apache.sshd.cli.server.helper.ScpCommandTransferEventListener;
 import org.apache.sshd.common.NamedResource;
 import org.apache.sshd.common.PropertyResolver;
 import org.apache.sshd.common.PropertyResolverUtils;
@@ -191,7 +192,7 @@ public class SshServerMain extends SshServerCliSupport {
         setupServerBanner(sshd, resolver);
         sshd.setPort(port);
 
-        ShellFactory shellFactory = resolveShellFactory(System.err, resolver);
+        ShellFactory shellFactory = resolveShellFactory(level, System.out, System.err, resolver);
         if (shellFactory != null) {
             System.out.append("Using shell=").println(shellFactory.getClass().getName());
             sshd.setShellFactory(shellFactory);
@@ -201,7 +202,7 @@ public class SshServerMain extends SshServerCliSupport {
         sshd.setPublickeyAuthenticator(AcceptAllPublickeyAuthenticator.INSTANCE);
         setupUserAuthFactories(sshd, resolver);
         setupServerForwarding(sshd, level, System.out, System.err, resolver);
-        setupCommandFactory(sshd, shellFactory);
+        setupCommandFactory(sshd, level, System.out, System.err, shellFactory);
 
         List<SubsystemFactory> subsystems = resolveServerSubsystems(sshd, level, System.out, System.err, resolver);
         if (GenericUtils.isNotEmpty(subsystems)) {
@@ -215,14 +216,18 @@ public class SshServerMain extends SshServerCliSupport {
         System.err.println("Exiting after a very (very very) long time");
     }
 
-    private static CommandFactory setupCommandFactory(SshServer sshd, ShellFactory shellFactory) {
+    private static CommandFactory setupCommandFactory(
+            SshServer sshd, Level level, Appendable stdout, Appendable stderr, ShellFactory shellFactory) {
         ScpCommandFactory scpFactory;
         if (shellFactory instanceof ScpCommandFactory) {
             scpFactory = (ScpCommandFactory) shellFactory;
         } else {
-            scpFactory = new ScpCommandFactory.Builder()
-                    .withDelegate(ProcessShellCommandFactory.INSTANCE)
-                    .build();
+            ScpCommandFactory.Builder builder = new ScpCommandFactory.Builder()
+                    .withDelegate(ProcessShellCommandFactory.INSTANCE);
+            if (isEnabledVerbosityLogging(level)) {
+                builder = builder.addEventListener(new ScpCommandTransferEventListener(stdout, stderr));
+            }
+            scpFactory = builder.build();
         }
         sshd.setCommandFactory(scpFactory);
         return scpFactory;
diff --git a/sshd-cli/src/main/java/org/apache/sshd/cli/server/helper/ScpCommandTransferEventListener.java b/sshd-cli/src/main/java/org/apache/sshd/cli/server/helper/ScpCommandTransferEventListener.java
new file mode 100644
index 0000000..85b9261
--- /dev/null
+++ b/sshd-cli/src/main/java/org/apache/sshd/cli/server/helper/ScpCommandTransferEventListener.java
@@ -0,0 +1,77 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.sshd.cli.server.helper;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.nio.file.attribute.PosixFilePermission;
+import java.util.Set;
+
+import org.apache.sshd.common.session.Session;
+import org.apache.sshd.scp.common.ScpTransferEventListener;
+import org.apache.sshd.scp.server.ScpCommandFactory;
+
+/**
+ * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
+ */
+public class ScpCommandTransferEventListener
+        extends ServerEventListenerHelper
+        implements ScpTransferEventListener {
+    public ScpCommandTransferEventListener(Appendable stdout, Appendable stderr) {
+        super(ScpCommandFactory.SCP_FACTORY_NAME, stdout, stderr);
+    }
+
+    @Override
+    public void startFileEvent(
+            Session session, FileOperation op, Path file, long length, Set<PosixFilePermission> perms)
+            throws IOException {
+        outputDebugMessage("startFileEvent(%s)[%s] len=%d, perms=%s: %s", session, op, length, perms, file);
+    }
+
+    @Override
+    public void endFileEvent(
+            Session session, FileOperation op, Path file, long length, Set<PosixFilePermission> perms, Throwable thrown)
+            throws IOException {
+        if (thrown != null) {
+            outputErrorMessage("endFileEvent(%s)[%s] failed (%s) len=%d, perms=%s [%s]: %s",
+                    session, op, thrown.getClass().getSimpleName(), length, perms, file, thrown.getMessage());
+        } else {
+            outputDebugMessage("endFileEvent(%s)[%s] len=%d, perms=%s: %s", session, op, length, perms, file);
+        }
+    }
+
+    @Override
+    public void startFolderEvent(Session session, FileOperation op, Path file, Set<PosixFilePermission> perms)
+            throws IOException {
+        outputDebugMessage("startFolderEvent(%s)[%s] perms=%s: %s", session, op, perms, file);
+    }
+
+    @Override
+    public void endFolderEvent(
+            Session session, FileOperation op, Path file, Set<PosixFilePermission> perms, Throwable thrown)
+            throws IOException {
+        if (thrown != null) {
+            outputErrorMessage("endFolderEvent(%s)[%s] failed (%s) perms=%s [%s]: %s",
+                    session, op, thrown.getClass().getSimpleName(), perms, file, thrown.getMessage());
+        } else {
+            outputDebugMessage("endFolderEvent(%s)[%s] lperms=%s: %s", session, op, perms, file);
+        }
+    }
+}
diff --git a/sshd-cli/src/test/java/org/apache/sshd/cli/server/SshFsMounter.java b/sshd-cli/src/test/java/org/apache/sshd/cli/server/SshFsMounter.java
index 4683d33..74626a2 100644
--- a/sshd-cli/src/test/java/org/apache/sshd/cli/server/SshFsMounter.java
+++ b/sshd-cli/src/test/java/org/apache/sshd/cli/server/SshFsMounter.java
@@ -32,6 +32,7 @@ import java.util.Objects;
 import java.util.TreeMap;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Future;
+import java.util.logging.Level;
 
 import org.apache.sshd.cli.CliSupport;
 import org.apache.sshd.common.PropertyResolver;
@@ -299,11 +300,11 @@ public final class SshFsMounter extends SshServerCliSupport {
         }
 
         PropertyResolver resolver = PropertyResolverUtils.toPropertyResolver(options);
+        Level level = resolveLoggingVerbosity(resolver, args);
         SshServer sshd = error
                 ? null : setupIoServiceFactory(
                         CoreTestSupportUtils.setupTestServer(SshFsMounter.class), resolver,
-                        resolveLoggingVerbosity(resolver, args),
-                        System.out, System.err, args);
+                        level, System.out, System.err, args);
         if (sshd == null) {
             error = true;
         }
@@ -325,7 +326,7 @@ public final class SshFsMounter extends SshServerCliSupport {
         // Should come AFTER key pair provider setup so auto-welcome can be generated if needed
         setupServerBanner(sshd, resolver);
 
-        ShellFactory shellFactory = resolveShellFactory(System.err, resolver);
+        ShellFactory shellFactory = resolveShellFactory(level, System.out, System.err, resolver);
         if (shellFactory != null) {
             System.out.append("Using shell=").println(shellFactory.getClass().getName());
             sshd.setShellFactory(shellFactory);


[mina-sshd] 04/06: [SSHD-1005] Restructured some SCP code to enable easier re-use

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

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

commit a2afd6b6cb8e3a79a9a5a67ce16b569c4f132bb1
Author: Lyor Goldstein <lg...@apache.org>
AuthorDate: Fri Aug 14 12:08:36 2020 +0300

    [SSHD-1005] Restructured some SCP code to enable easier re-use
---
 docs/scp.md                                        |  17 +-
 docs/sftp.md                                       |   4 +
 .../apache/sshd/scp/client/AbstractScpClient.java  |  85 +---
 .../apache/sshd/scp/client/DefaultScpClient.java   |   4 +
 .../org/apache/sshd/scp/common/ScpException.java   |   4 +-
 .../java/org/apache/sshd/scp/common/ScpHelper.java | 461 +++++----------------
 .../org/apache/sshd/scp/common/ScpTimestamp.java   |  41 +-
 .../common/helpers/AbstractScpCommandDetails.java  |  40 ++
 .../scp/common/helpers/CommandStatusHandler.java   |  41 ++
 .../common/helpers/ScpDirEndCommandDetails.java    |  39 ++
 .../apache/sshd/scp/common/helpers/ScpIoUtils.java | 423 +++++++++++++++++++
 .../helpers/ScpPathCommandDetailsSupport.java      | 178 ++++++++
 .../helpers/ScpReceiveDirCommandDetails.java       |  44 ++
 .../helpers/ScpReceiveFileCommandDetails.java      |  45 ++
 .../org/apache/sshd/scp/server/ScpCommand.java     |  11 +-
 .../java/org/apache/sshd/scp/server/ScpShell.java  |   9 +-
 .../java/org/apache/sshd/scp/client/ScpTest.java   |  45 +-
 17 files changed, 1009 insertions(+), 482 deletions(-)

diff --git a/docs/scp.md b/docs/scp.md
index c9bff2a..a91a09d 100644
--- a/docs/scp.md
+++ b/docs/scp.md
@@ -1,4 +1,4 @@
-## SCP
+# SCP
 
 Both client-side and server-side SCP are supported. Starting from version 2.0, the SCP related code is located in the `sshd-scp` module, so you need
 to add this additional dependency to your maven project:
@@ -13,7 +13,7 @@ to add this additional dependency to your maven project:
 
 ```
 
-### `ScpTransferEventListener`
+## `ScpTransferEventListener`
 
 Callback to inform about SCP related events. `ScpTransferEventListener`(s) can be registered on *both* client and server side:
 
@@ -38,7 +38,7 @@ Callback to inform about SCP related events. `ScpTransferEventListener`(s) can b
     }
 ```
 
-### Client-side SCP
+## Client-side SCP
 
 In order to obtain an `ScpClient` one needs to use an `ScpClientCreator`:
 
@@ -66,7 +66,7 @@ ScpClient client2 = creator.createScpClient(session, new SomeOtherListener());
 
 ```
 
-#### ScpFileOpener(s)
+## ScpFileOpener(s)
 
 As part of the `ScpClientCreator`, the SCP module also uses a `ScpFileOpener` instance in order to access
 the local files. The default implementation simply opens an [InputStream](https://docs.oracle.com/javase/8/docs/api/java/io/InputStream.html)
@@ -126,7 +126,7 @@ different sensitivity via `DirectoryScanner#setCaseSensitive` call (or executes
     * `Windows` - case insensitive
     * `Unix` - case sensitive
 
-### Server-side SCP
+## Server-side SCP
 
 Setting up SCP support on the server side is straightforward - simply initialize a `ScpCommandFactory` and
 set it as the **primary** command factory. If support for commands other than SCP is also required then provide
@@ -147,7 +147,7 @@ The `ScpCommandFactory` allows users to attach an `ScpFileOpener` and/or `ScpTra
 monitoring and intervention on the accessed local files. Furthermore, the factory can also be configured with a custom executor service for
 executing the requested copy commands as well as controlling the internal buffer sizes used to copy files.
 
-### The SCP "shell"
+## The SCP "shell"
 
 Some SCP clients (e.g. [WinSCP](https://winscp.net/)) open a shell connection even if configured to use pure SCP in order to retrieve information
 about the remote server's files and potentially navigate through them. In other words, SCP is only used as the **transfer** protocol, but
@@ -171,3 +171,8 @@ is likely to require. For this purpose, the `ScpCommandFactory` also implements
 ```
 
 **Note:** a similar result can be achieved if activating SSHD from the command line by specifying `-o ShellFactory=scp`
+
+## References
+
+* [How the SCP protocol works](https://chuacw.ath.cx/development/b/chuacw/archive/2019/02/04/how-the-scp-protocol-works.aspx)
+* [scp.c](https://github.com/cloudsigma/illumos-omnios/blob/master/usr/src/cmd/ssh/scp/scp.c)
diff --git a/docs/sftp.md b/docs/sftp.md
index bba6d01..7d1cf72 100644
--- a/docs/sftp.md
+++ b/docs/sftp.md
@@ -514,3 +514,7 @@ for sending and receiving the newly added extension.
 
 See how other extensions are implemented and follow their example
 
+## References
+
+* [SFTP drafts for the various versions](https://tools.ietf.org/wg/secsh/draft-ietf-secsh-filexfer/)
+
diff --git a/sshd-scp/src/main/java/org/apache/sshd/scp/client/AbstractScpClient.java b/sshd-scp/src/main/java/org/apache/sshd/scp/client/AbstractScpClient.java
index 54418cf..1930ef8 100644
--- a/sshd-scp/src/main/java/org/apache/sshd/scp/client/AbstractScpClient.java
+++ b/sshd-scp/src/main/java/org/apache/sshd/scp/client/AbstractScpClient.java
@@ -24,16 +24,12 @@ import java.nio.file.FileSystem;
 import java.nio.file.Files;
 import java.nio.file.LinkOption;
 import java.nio.file.Path;
-import java.time.Duration;
 import java.util.Arrays;
 import java.util.Collection;
-import java.util.Collections;
 import java.util.EnumSet;
-import java.util.Set;
 
 import org.apache.sshd.client.channel.ChannelExec;
 import org.apache.sshd.client.channel.ClientChannel;
-import org.apache.sshd.client.channel.ClientChannelEvent;
 import org.apache.sshd.client.session.ClientSession;
 import org.apache.sshd.common.FactoryManager;
 import org.apache.sshd.common.SshException;
@@ -44,17 +40,14 @@ import org.apache.sshd.common.util.ValidateUtils;
 import org.apache.sshd.common.util.io.IoUtils;
 import org.apache.sshd.common.util.logging.AbstractLoggingBean;
 import org.apache.sshd.core.CoreModuleProperties;
-import org.apache.sshd.scp.ScpModuleProperties;
 import org.apache.sshd.scp.common.ScpException;
 import org.apache.sshd.scp.common.ScpHelper;
+import org.apache.sshd.scp.common.helpers.ScpIoUtils;
 
 /**
  * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
  */
 public abstract class AbstractScpClient extends AbstractLoggingBean implements ScpClient {
-    public static final Set<ClientChannelEvent> COMMAND_WAIT_EVENTS
-            = Collections.unmodifiableSet(EnumSet.of(ClientChannelEvent.EXIT_STATUS, ClientChannelEvent.CLOSED));
-
     protected AbstractScpClient() {
         super();
     }
@@ -180,26 +173,8 @@ public abstract class AbstractScpClient extends AbstractLoggingBean implements S
      * @see                #handleCommandExitStatus(String, Integer)
      */
     protected void handleCommandExitStatus(String cmd, ClientChannel channel) throws IOException {
-        // give a chance for the exit status to be received
-        Duration timeout = ScpModuleProperties.SCP_EXEC_CHANNEL_EXIT_STATUS_TIMEOUT.getRequired(channel);
-        if (GenericUtils.isNegativeOrNull(timeout)) {
-            handleCommandExitStatus(cmd, (Integer) null);
-            return;
-        }
-
-        long waitStart = System.nanoTime();
-        Collection<ClientChannelEvent> events = channel.waitFor(COMMAND_WAIT_EVENTS, timeout);
-        long waitEnd = System.nanoTime();
-        if (log.isDebugEnabled()) {
-            log.debug("handleCommandExitStatus({}) cmd='{}', waited={} nanos, events={}",
-                    getClientSession(), cmd, waitEnd - waitStart, events);
-        }
-
-        /*
-         * There are sometimes race conditions in the order in which channels are closed and exit-status sent by the
-         * remote peer (if at all), thus there is no guarantee that we will have an exit status here
-         */
-        handleCommandExitStatus(cmd, channel.getExitStatus());
+        ScpIoUtils.handleCommandExitStatus(
+                getClientSession(), cmd, channel, (session, command, status) -> handleCommandExitStatus(command, status), log);
     }
 
     /**
@@ -208,30 +183,10 @@ public abstract class AbstractScpClient extends AbstractLoggingBean implements S
      *
      * @param  cmd         The attempted remote copy command
      * @param  exitStatus  The exit status - if {@code null} then no status was reported
-     * @throws IOException If failed the command
+     * @throws IOException If received non-OK exit status
      */
     protected void handleCommandExitStatus(String cmd, Integer exitStatus) throws IOException {
-        if (log.isDebugEnabled()) {
-            log.debug("handleCommandExitStatus({}) cmd='{}', exit-status={}",
-                    getClientSession(), cmd, ScpHelper.getExitStatusName(exitStatus));
-        }
-
-        if (exitStatus == null) {
-            return;
-        }
-
-        int statusCode = exitStatus;
-        switch (statusCode) {
-            case ScpHelper.OK: // do nothing
-                break;
-            case ScpHelper.WARNING:
-                log.warn("handleCommandExitStatus({}) cmd='{}' may have terminated with some problems", getClientSession(),
-                        cmd);
-                break;
-            default:
-                throw new ScpException(
-                        "Failed to run command='" + cmd + "': " + ScpHelper.getExitStatusName(exitStatus), exitStatus);
-        }
+        ScpIoUtils.handleCommandExitStatus(getClientSession(), cmd, exitStatus, log);
     }
 
     protected Collection<Option> addTargetIsDirectory(Collection<Option> options) {
@@ -245,35 +200,7 @@ public abstract class AbstractScpClient extends AbstractLoggingBean implements S
     }
 
     protected ChannelExec openCommandChannel(ClientSession session, String cmd) throws IOException {
-        Duration waitTimeout = ScpModuleProperties.SCP_EXEC_CHANNEL_OPEN_TIMEOUT.getRequired(session);
-        ChannelExec channel = session.createExecChannel(cmd);
-
-        long startTime = System.nanoTime();
-        try {
-            channel.open().verify(waitTimeout);
-            long endTime = System.nanoTime();
-            long nanosWait = endTime - startTime;
-            if (log.isTraceEnabled()) {
-                log.trace("openCommandChannel(" + session + ")[" + cmd + "]"
-                          + " completed after " + nanosWait
-                          + " nanos out of " + waitTimeout.toNanos());
-            }
-
-            return channel;
-        } catch (IOException | RuntimeException e) {
-            long endTime = System.nanoTime();
-            long nanosWait = endTime - startTime;
-            if (log.isTraceEnabled()) {
-                log.trace("openCommandChannel(" + session + ")[" + cmd + "]"
-                          + " failed (" + e.getClass().getSimpleName() + ")"
-                          + " to complete after " + nanosWait
-                          + " nanos out of " + waitTimeout.toNanos()
-                          + ": " + e.getMessage());
-            }
-
-            channel.close(false);
-            throw e;
-        }
+        return ScpIoUtils.openCommandChannel(session, cmd, log);
     }
 
     @FunctionalInterface
diff --git a/sshd-scp/src/main/java/org/apache/sshd/scp/client/DefaultScpClient.java b/sshd-scp/src/main/java/org/apache/sshd/scp/client/DefaultScpClient.java
index 4ebf567..471ef2d 100644
--- a/sshd-scp/src/main/java/org/apache/sshd/scp/client/DefaultScpClient.java
+++ b/sshd-scp/src/main/java/org/apache/sshd/scp/client/DefaultScpClient.java
@@ -50,6 +50,10 @@ public class DefaultScpClient extends AbstractScpClient {
     protected final ScpTransferEventListener listener;
     private final ClientSession clientSession;
 
+    public DefaultScpClient(ClientSession clientSession) {
+        this(clientSession, DefaultScpFileOpener.INSTANCE, ScpTransferEventListener.EMPTY);
+    }
+
     public DefaultScpClient(
                             ClientSession clientSession, ScpFileOpener fileOpener, ScpTransferEventListener eventListener) {
         this.clientSession = Objects.requireNonNull(clientSession, "No client session");
diff --git a/sshd-scp/src/main/java/org/apache/sshd/scp/common/ScpException.java b/sshd-scp/src/main/java/org/apache/sshd/scp/common/ScpException.java
index fb4f91c..ab1fada 100644
--- a/sshd-scp/src/main/java/org/apache/sshd/scp/common/ScpException.java
+++ b/sshd-scp/src/main/java/org/apache/sshd/scp/common/ScpException.java
@@ -22,6 +22,8 @@ package org.apache.sshd.scp.common;
 import java.io.IOException;
 import java.util.Objects;
 
+import org.apache.sshd.scp.common.helpers.ScpIoUtils;
+
 /**
  * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
  */
@@ -34,7 +36,7 @@ public class ScpException extends IOException {
     }
 
     public ScpException(Integer exitStatus) {
-        this("Exit status=" + ScpHelper.getExitStatusName(Objects.requireNonNull(exitStatus, "No exit status")), exitStatus);
+        this("Exit status=" + ScpIoUtils.getExitStatusName(Objects.requireNonNull(exitStatus, "No exit status")), exitStatus);
     }
 
     public ScpException(String message, Integer exitStatus) {
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 6ef5dbf..53c3f94 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,14 +18,11 @@
  */
 package org.apache.sshd.scp.common;
 
-import java.io.ByteArrayOutputStream;
-import java.io.EOFException;
 import java.io.File;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
 import java.io.StreamCorruptedException;
-import java.nio.charset.StandardCharsets;
 import java.nio.file.DirectoryStream;
 import java.nio.file.FileSystem;
 import java.nio.file.InvalidPathException;
@@ -39,7 +36,6 @@ import java.util.Collection;
 import java.util.EnumSet;
 import java.util.Objects;
 import java.util.Set;
-import java.util.concurrent.TimeUnit;
 
 import org.apache.sshd.common.file.util.MockPath;
 import org.apache.sshd.common.session.Session;
@@ -50,21 +46,21 @@ 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.ScpDirEndCommandDetails;
+import org.apache.sshd.scp.common.helpers.ScpIoUtils;
+import org.apache.sshd.scp.common.helpers.ScpPathCommandDetailsSupport;
+import org.apache.sshd.scp.common.helpers.ScpReceiveDirCommandDetails;
+import org.apache.sshd.scp.common.helpers.ScpReceiveFileCommandDetails;
 
 /**
  * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
  */
-@SuppressWarnings("PMD.AvoidUsingOctalValues")
 public class ScpHelper extends AbstractLoggingBean implements SessionHolder<Session> {
     /**
      * Command prefix used to identify SCP commands
      */
     public static final String SCP_COMMAND_PREFIX = "scp";
 
-    public static final int OK = 0;
-    public static final int WARNING = 1;
-    public static final int ERROR = 2;
-
     /**
      * Default size (in bytes) of send / receive buffer size
      */
@@ -79,19 +75,6 @@ public class ScpHelper extends AbstractLoggingBean implements SessionHolder<Sess
     public static final int MIN_RECEIVE_BUFFER_SIZE = MIN_COPY_BUFFER_SIZE;
     public static final int MIN_SEND_BUFFER_SIZE = MIN_COPY_BUFFER_SIZE;
 
-    public static final int S_IRUSR = 0000400;
-    public static final int S_IWUSR = 0000200;
-    public static final int S_IXUSR = 0000100;
-    public static final int S_IRGRP = 0000040;
-    public static final int S_IWGRP = 0000020;
-    public static final int S_IXGRP = 0000010;
-    public static final int S_IROTH = 0000004;
-    public static final int S_IWOTH = 0000002;
-    public static final int S_IXOTH = 0000001;
-
-    public static final String DEFAULT_DIR_OCTAL_PERMISSIONS = "0755";
-    public static final String DEFAULT_FILE_OCTAL_PERMISSIONS = "0644";
-
     protected final InputStream in;
     protected final OutputStream out;
     protected final FileSystem fileSystem;
@@ -100,8 +83,8 @@ public class ScpHelper extends AbstractLoggingBean implements SessionHolder<Sess
 
     private final Session sessionInstance;
 
-    public ScpHelper(Session session, InputStream in, OutputStream out,
-                     FileSystem fileSystem, ScpFileOpener opener, ScpTransferEventListener eventListener) {
+    public ScpHelper(Session session, InputStream in, OutputStream out, FileSystem fileSystem, ScpFileOpener opener,
+                     ScpTransferEventListener eventListener) {
         this.sessionInstance = Objects.requireNonNull(session, "No session");
         this.in = Objects.requireNonNull(in, "No input stream");
         this.out = Objects.requireNonNull(out, "No output stream");
@@ -124,9 +107,11 @@ public class ScpHelper extends AbstractLoggingBean implements SessionHolder<Sess
             Path path = new MockPath(line);
             receiveStream(line, new ScpTargetStreamResolver() {
                 @Override
-                @SuppressWarnings("synthetic-access") // see https://bugs.eclipse.org/bugs/show_bug.cgi?id=537593
+                @SuppressWarnings("synthetic-access") // see
+                                                     // https://bugs.eclipse.org/bugs/show_bug.cgi?id=537593
                 public OutputStream resolveTargetStream(
-                        Session session, String name, long length, Set<PosixFilePermission> perms, OpenOption... options)
+                        Session session, String name, long length,
+                        Set<PosixFilePermission> perms, OpenOption... options)
                         throws IOException {
                     if (log.isDebugEnabled()) {
                         log.debug("resolveTargetStream({}) name={}, perms={}, len={} - started local stream download",
@@ -141,13 +126,15 @@ public class ScpHelper extends AbstractLoggingBean implements SessionHolder<Sess
                 }
 
                 @Override
-                @SuppressWarnings("synthetic-access") // see https://bugs.eclipse.org/bugs/show_bug.cgi?id=537593
+                @SuppressWarnings("synthetic-access") // see
+                                                     // https://bugs.eclipse.org/bugs/show_bug.cgi?id=537593
                 public void postProcessReceivedData(
-                        String name, boolean preserve, Set<PosixFilePermission> perms, ScpTimestamp time)
+                        String name, boolean preserve, Set<PosixFilePermission> perms,
+                        ScpTimestamp time)
                         throws IOException {
                     if (log.isDebugEnabled()) {
-                        log.debug("postProcessReceivedData({}) name={}, perms={}, preserve={} time={}",
-                                ScpHelper.this, name, perms, preserve, time);
+                        log.debug("postProcessReceivedData({}) name={}, perms={}, preserve={} time={}", ScpHelper.this,
+                                name, perms, preserve, time);
                     }
                 }
 
@@ -159,14 +146,10 @@ public class ScpHelper extends AbstractLoggingBean implements SessionHolder<Sess
         });
     }
 
-    public void receive(
-            Path local, boolean recursive, boolean shouldBeDir, boolean preserve, int bufferSize)
+    public void receive(Path local, boolean recursive, boolean shouldBeDir, boolean preserve, int bufferSize)
             throws IOException {
-        Path localPath = Objects.requireNonNull(local, "No local path")
-                .normalize()
-                .toAbsolutePath();
-        Path path = opener.resolveIncomingReceiveLocation(
-                getSession(), localPath, recursive, shouldBeDir, preserve);
+        Path localPath = Objects.requireNonNull(local, "No local path").normalize().toAbsolutePath();
+        Path path = opener.resolveIncomingReceiveLocation(getSession(), localPath, recursive, shouldBeDir, preserve);
         receive((session, line, isDir, time) -> {
             if (recursive && isDir) {
                 receiveDir(line, path, time, preserve, bufferSize);
@@ -177,85 +160,28 @@ public class ScpHelper extends AbstractLoggingBean implements SessionHolder<Sess
     }
 
     protected void receive(ScpReceiveLineHandler handler) throws IOException {
-        ack();
-        ScpTimestamp time = null;
-        for (Session session = getSession();;) {
-            String line;
-            boolean isDir = false;
-            int c = readAck(true);
-            switch (c) {
-                case -1:
-                    return;
-                case 'D':
-                    line = readLine();
-                    line = Character.toString((char) c) + line;
-                    isDir = true;
-                    if (log.isDebugEnabled()) {
-                        log.debug("receive({}) - Received 'D' header: {}", this, line);
-                    }
-                    break;
-                case 'C':
-                    line = readLine();
-                    line = Character.toString((char) c) + line;
-                    if (log.isDebugEnabled()) {
-                        log.debug("receive({}) - Received 'C' header: {}", this, line);
-                    }
-                    break;
-                case 'T':
-                    line = readLine();
-                    line = Character.toString((char) c) + line;
-                    if (log.isDebugEnabled()) {
-                        log.debug("receive({}) - Received 'T' header: {}", this, line);
-                    }
-                    time = ScpTimestamp.parseTime(line);
-                    ack();
-                    continue;
-                case 'E':
-                    line = readLine();
-                    line = Character.toString((char) c) + line;
-                    if (log.isDebugEnabled()) {
-                        log.debug("receive({}) - Received 'E' header: {}", this, line);
-                    }
-                    ack();
-                    return;
-                default:
-                    // a real ack that has been acted upon already
-                    continue;
-            }
-
-            try {
-                handler.process(session, line, isDir, time);
-            } finally {
-                time = null;
-            }
-        }
+        ScpIoUtils.receive(getSession(), in, out, log, this, handler);
     }
 
-    public void receiveDir(
-            String header, Path local, ScpTimestamp time, boolean preserve, int bufferSize)
+    public void receiveDir(String header, Path local, ScpTimestamp time, boolean preserve, int bufferSize)
             throws IOException {
-        Path path = Objects.requireNonNull(local, "No local path")
-                .normalize()
-                .toAbsolutePath();
+        Path path = Objects.requireNonNull(local, "No local path").normalize().toAbsolutePath();
         boolean debugEnabled = log.isDebugEnabled();
         if (debugEnabled) {
-            log.debug("receiveDir({})[{}] Receiving directory {} - preserve={}, time={}, buffer-size={}",
-                    this, header, path, preserve, time, bufferSize);
-        }
-        if (!header.startsWith("D")) {
-            throw new IOException("Expected a 'D; message but got '" + header + "'");
+            log.debug("receiveDir({})[{}] Receiving directory {} - preserve={}, time={}, buffer-size={}", this, header,
+                    path, preserve, time, bufferSize);
         }
 
-        Set<PosixFilePermission> perms = parseOctalPermissions(header.substring(1, 5));
-        int length = Integer.parseInt(header.substring(6, header.indexOf(' ', 6)));
-        String name = header.substring(header.indexOf(' ', 6) + 1);
+        ScpReceiveDirCommandDetails details = new ScpReceiveDirCommandDetails(header);
+        String name = details.getName();
+        long length = details.getLength();
         if (length != 0) {
             throw new IOException("Expected 0 length for directory=" + name + " but got " + length);
         }
 
         Session session = getSession();
-        Path file = opener.resolveIncomingFilePath(
-                session, path, name, preserve, perms, time);
+        Set<PosixFilePermission> perms = details.getPermissions();
+        Path file = opener.resolveIncomingFilePath(session, path, name, preserve, perms, time);
 
         ack();
 
@@ -269,16 +195,17 @@ public class ScpHelper extends AbstractLoggingBean implements SessionHolder<Sess
                     log.debug("receiveDir({})[{}] Received header: {}", this, file, header);
                 }
 
-                if (header.startsWith("C")) {
+                char cmdChar = header.charAt(0);
+                if (cmdChar == ScpReceiveFileCommandDetails.COMMAND_NAME) {
                     receiveFile(header, file, time, preserve, bufferSize);
                     time = null;
-                } else if (header.startsWith("D")) {
+                } else if (cmdChar == ScpReceiveDirCommandDetails.COMMAND_NAME) {
                     receiveDir(header, file, time, preserve, bufferSize);
                     time = null;
-                } else if (header.equals("E")) {
+                } else if (cmdChar == ScpDirEndCommandDetails.COMMAND_NAME) {
                     ack();
                     break;
-                } else if (header.startsWith("T")) {
+                } else if (cmdChar == ScpTimestamp.COMMAND_NAME) {
                     time = ScpTimestamp.parseTime(header);
                     ack();
                 } else {
@@ -292,15 +219,12 @@ public class ScpHelper extends AbstractLoggingBean implements SessionHolder<Sess
         listener.endFolderEvent(session, FileOperation.RECEIVE, path, perms, null);
     }
 
-    public void receiveFile(
-            String header, Path local, ScpTimestamp time, boolean preserve, int bufferSize)
+    public void receiveFile(String header, Path local, ScpTimestamp time, boolean preserve, int bufferSize)
             throws IOException {
-        Path path = Objects.requireNonNull(local, "No local path")
-                .normalize()
-                .toAbsolutePath();
+        Path path = Objects.requireNonNull(local, "No local path").normalize().toAbsolutePath();
         if (log.isDebugEnabled()) {
-            log.debug("receiveFile({})[{}] Receiving file {} - preserve={}, time={}, buffer-size={}",
-                    this, header, path, preserve, time, bufferSize);
+            log.debug("receiveFile({})[{}] Receiving file {} - preserve={}, time={}, buffer-size={}", this, header,
+                    path, preserve, time, bufferSize);
         }
 
         ScpTargetStreamResolver targetStreamResolver = opener.createScpTargetStreamResolver(getSession(), path);
@@ -308,32 +232,29 @@ public class ScpHelper extends AbstractLoggingBean implements SessionHolder<Sess
     }
 
     public void receiveStream(
-            String header, ScpTargetStreamResolver resolver, ScpTimestamp time, boolean preserve, int bufferSize)
+            String header, ScpTargetStreamResolver resolver, ScpTimestamp time, boolean preserve,
+            int bufferSize)
             throws IOException {
-        if (!header.startsWith("C")) {
-            throw new IOException("receiveStream(" + resolver + ") Expected a C message but got '" + header + "'");
-        }
-
         if (bufferSize < MIN_RECEIVE_BUFFER_SIZE) {
             throw new IOException(
-                    "receiveStream(" + resolver + ") buffer size (" + bufferSize + ") below minimum (" + MIN_RECEIVE_BUFFER_SIZE
-                                  + ")");
+                    "receiveStream(" + resolver + ") buffer size (" + bufferSize + ") below minimum ("
+                                  + MIN_RECEIVE_BUFFER_SIZE + ")");
         }
 
-        Set<PosixFilePermission> perms = parseOctalPermissions(header.substring(1, 5));
-        long length = Long.parseLong(header.substring(6, header.indexOf(' ', 6)));
-        String name = header.substring(header.indexOf(' ', 6) + 1);
+        ScpReceiveFileCommandDetails details = new ScpReceiveFileCommandDetails(header);
+        long length = details.getLength();
         if (length < 0L) { // TODO consider throwing an exception...
             log.warn("receiveStream({})[{}] bad length in header: {}", this, resolver, header);
         }
 
-        // if file size is less than buffer size allocate only expected file size
+        // if file size is less than buffer size allocate only expected file
+        // size
         int bufSize;
         boolean debugEnabled = log.isDebugEnabled();
         if (length == 0L) {
             if (debugEnabled) {
-                log.debug("receiveStream({})[{}] zero file size (perhaps special file) using copy buffer size={}",
-                        this, resolver, MIN_RECEIVE_BUFFER_SIZE);
+                log.debug("receiveStream({})[{}] zero file size (perhaps special file) using copy buffer size={}", this,
+                        resolver, MIN_RECEIVE_BUFFER_SIZE);
             }
             bufSize = MIN_RECEIVE_BUFFER_SIZE;
         } else {
@@ -341,15 +262,17 @@ public class ScpHelper extends AbstractLoggingBean implements SessionHolder<Sess
         }
 
         if (bufSize < 0) { // TODO consider throwing an exception
-            log.warn("receiveStream({})[{}] bad buffer size ({}) using default ({})",
-                    this, resolver, bufSize, MIN_RECEIVE_BUFFER_SIZE);
+            log.warn("receiveStream({})[{}] bad buffer size ({}) using default ({})", this, resolver, bufSize,
+                    MIN_RECEIVE_BUFFER_SIZE);
             bufSize = MIN_RECEIVE_BUFFER_SIZE;
         }
 
         Session session = getSession();
+        String name = details.getName();
+        Set<PosixFilePermission> perms = details.getPermissions();
         try (InputStream is = new LimitInputStream(this.in, length);
-             OutputStream os = resolver.resolveTargetStream(
-                     session, name, length, perms, IoUtils.EMPTY_OPEN_OPTIONS)) {
+             OutputStream os = resolver.resolveTargetStream(session, name, length, perms,
+                     IoUtils.EMPTY_OPEN_OPTIONS)) {
             ack();
 
             Path file = resolver.getEventListenerFilePath();
@@ -380,26 +303,10 @@ public class ScpHelper extends AbstractLoggingBean implements SessionHolder<Sess
     }
 
     public String readLine(boolean canEof) throws IOException {
-        try (ByteArrayOutputStream baos = new ByteArrayOutputStream(Byte.MAX_VALUE)) {
-            for (;;) {
-                int c = in.read();
-                if (c == '\n') {
-                    return baos.toString(StandardCharsets.UTF_8.name());
-                } else if (c == -1) {
-                    if (!canEof) {
-                        throw new EOFException("EOF while await end of line");
-                    }
-                    return null;
-                } else {
-                    baos.write(c);
-                }
-            }
-        }
+        return ScpIoUtils.readLine(in, canEof);
     }
 
-    public void send(
-            Collection<String> paths, boolean recursive, boolean preserve, int bufferSize)
-            throws IOException {
+    public void send(Collection<String> paths, boolean recursive, boolean preserve, int bufferSize) throws IOException {
         int readyCode = readAck(false);
         boolean debugEnabled = log.isDebugEnabled();
         if (debugEnabled) {
@@ -519,12 +426,13 @@ public class ScpHelper extends AbstractLoggingBean implements SessionHolder<Sess
     public void sendStream(ScpSourceStreamResolver resolver, boolean preserve, int bufferSize) throws IOException {
         if (bufferSize < MIN_SEND_BUFFER_SIZE) {
             throw new IOException(
-                    "sendStream(" + resolver + ") buffer size (" + bufferSize + ") below minimum (" + MIN_SEND_BUFFER_SIZE
-                                  + ")");
+                    "sendStream(" + resolver + ") buffer size (" + bufferSize + ") below minimum ("
+                                  + MIN_SEND_BUFFER_SIZE + ")");
         }
 
         long fileSize = resolver.getSize();
-        // if file size is less than buffer size allocate only expected file size
+        // if file size is less than buffer size allocate only expected file
+        // size
         int bufSize;
         boolean debugEnabled = log.isDebugEnabled();
         if (fileSize <= 0L) {
@@ -538,46 +446,36 @@ public class ScpHelper extends AbstractLoggingBean implements SessionHolder<Sess
         }
 
         if (bufSize < 0) { // TODO consider throwing an exception
-            log.warn("sendStream({})[{}] bad buffer size ({}) using default ({})",
-                    this, resolver, bufSize, MIN_SEND_BUFFER_SIZE);
+            log.warn("sendStream({})[{}] bad buffer size ({}) using default ({})", this, resolver, bufSize,
+                    MIN_SEND_BUFFER_SIZE);
             bufSize = MIN_SEND_BUFFER_SIZE;
         }
 
         ScpTimestamp time = resolver.getTimestamp();
         if (preserve && (time != null)) {
-            String cmd = "T" + TimeUnit.MILLISECONDS.toSeconds(time.getLastModifiedTime())
-                         + " " + "0" + " " + TimeUnit.MILLISECONDS.toSeconds(time.getLastAccessTime())
-                         + " " + "0";
-            if (debugEnabled) {
-                log.debug("sendStream({})[{}] send timestamp={} command: {}", this, resolver, time, cmd);
-            }
-            out.write(cmd.getBytes(StandardCharsets.UTF_8));
-            out.write('\n');
-            out.flush();
-
-            int readyCode = readAck(false);
+            int readyCode = ScpIoUtils.sendTimeCommand(in, out, time, log, this);
+            String cmd = time.toHeader();
             if (debugEnabled) {
                 log.debug("sendStream({})[{}] command='{}' ready code={}", this, resolver, cmd, readyCode);
             }
+
             validateAckReplyCode(cmd, resolver, readyCode, false);
         }
 
         Set<PosixFilePermission> perms = EnumSet.copyOf(resolver.getPermissions());
-        String octalPerms
-                = ((!preserve) || GenericUtils.isEmpty(perms)) ? DEFAULT_FILE_OCTAL_PERMISSIONS : getOctalPermissions(perms);
+        String octalPerms = ((!preserve) || GenericUtils.isEmpty(perms))
+                ? ScpReceiveFileCommandDetails.DEFAULT_FILE_OCTAL_PERMISSIONS
+                : ScpPathCommandDetailsSupport.getOctalPermissions(perms);
         String fileName = resolver.getFileName();
-        String cmd = "C" + octalPerms + " " + fileSize + " " + fileName;
+        String cmd = ScpReceiveFileCommandDetails.COMMAND_NAME + octalPerms + " " + fileSize + " " + fileName;
         if (debugEnabled) {
             log.debug("sendStream({})[{}] send 'C' command: {}", this, resolver, cmd);
         }
-        out.write(cmd.getBytes(StandardCharsets.UTF_8));
-        out.write('\n');
-        out.flush();
 
-        int readyCode = readAck(false);
+        int readyCode = sendAcknowledgedCommand(cmd);
         if (debugEnabled) {
-            log.debug("sendStream({})[{}] command='{}' ready code={}",
-                    this, resolver, cmd.substring(0, cmd.length() - 1), readyCode);
+            log.debug("sendStream({})[{}] command='{}' ready code={}", this, resolver,
+                    cmd.substring(0, cmd.length() - 1), readyCode);
         }
         validateAckReplyCode(cmd, resolver, readyCode, false);
 
@@ -608,34 +506,22 @@ public class ScpHelper extends AbstractLoggingBean implements SessionHolder<Sess
         validateCommandStatusCode(command, location, readyCode, eofAllowed);
     }
 
-    protected void validateAckReplyCode(String command, Object location, int replyCode, boolean eofAllowed) throws IOException {
+    protected void validateAckReplyCode(String command, Object location, int replyCode, boolean eofAllowed)
+            throws IOException {
         validateCommandStatusCode(command, location, replyCode, eofAllowed);
     }
 
     protected 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);
-        }
+        ScpIoUtils.validateCommandStatusCode(command, location, statusCode, eofAllowed);
     }
 
     public void sendDir(Path local, boolean preserve, int bufferSize) throws IOException {
         Path path = Objects.requireNonNull(local, "No local path").normalize().toAbsolutePath();
         boolean debugEnabled = log.isDebugEnabled();
         if (debugEnabled) {
-            log.debug("sendDir({}) Sending directory {} - preserve={}, buffer-size={}",
-                    this, path, preserve, bufferSize);
+            log.debug("sendDir({}) Sending directory {} - preserve={}, buffer-size={}", this, path, preserve,
+                    bufferSize);
         }
 
         LinkOption[] options = IoUtils.getLinkOptions(true);
@@ -644,19 +530,14 @@ public class ScpHelper extends AbstractLoggingBean implements SessionHolder<Sess
             BasicFileAttributes basic = opener.getLocalBasicFileAttributes(session, path, options);
             FileTime lastModified = basic.lastModifiedTime();
             FileTime lastAccess = basic.lastAccessTime();
-            String cmd = "T" + lastModified.to(TimeUnit.SECONDS) + " "
-                         + "0" + " " + lastAccess.to(TimeUnit.SECONDS) + " "
-                         + "0";
+            ScpTimestamp time = new ScpTimestamp(lastModified, lastAccess);
+            String cmd = time.toHeader();
             if (debugEnabled) {
-                log.debug("sendDir({})[{}] send last-modified={}, last-access={} command: {}",
-                        this, path, lastModified, lastAccess, cmd);
+                log.debug("sendDir({})[{}] send last-modified={}, last-access={} command: {}", this, path, lastModified,
+                        lastAccess, cmd);
             }
 
-            out.write(cmd.getBytes(StandardCharsets.UTF_8));
-            out.write('\n');
-            out.flush();
-
-            int readyCode = readAck(false);
+            int readyCode = sendAcknowledgedCommand(cmd);
             if (debugEnabled) {
                 if (debugEnabled) {
                     log.debug("sendDir({})[{}] command='{}' ready code={}", this, path, cmd, readyCode);
@@ -666,20 +547,18 @@ public class ScpHelper extends AbstractLoggingBean implements SessionHolder<Sess
         }
 
         Set<PosixFilePermission> perms = opener.getLocalFilePermissions(session, path, options);
-        String octalPerms
-                = ((!preserve) || GenericUtils.isEmpty(perms)) ? DEFAULT_DIR_OCTAL_PERMISSIONS : getOctalPermissions(perms);
-        String cmd = "D" + octalPerms + " " + "0" + " " + Objects.toString(path.getFileName(), null);
+        String octalPerms = ((!preserve) || GenericUtils.isEmpty(perms))
+                ? ScpReceiveDirCommandDetails.DEFAULT_DIR_OCTAL_PERMISSIONS
+                : ScpPathCommandDetailsSupport.getOctalPermissions(perms);
+        String cmd = ScpReceiveDirCommandDetails.COMMAND_NAME + octalPerms + " " + "0" + " "
+                     + Objects.toString(path.getFileName(), null);
         if (debugEnabled) {
             log.debug("sendDir({})[{}] send 'D' command: {}", this, path, cmd);
         }
-        out.write(cmd.getBytes(StandardCharsets.UTF_8));
-        out.write('\n');
-        out.flush();
 
-        int readyCode = readAck(false);
+        int readyCode = sendAcknowledgedCommand(cmd);
         if (debugEnabled) {
-            log.debug("sendDir({})[{}] command='{}' ready code={}",
-                    this, path, cmd.substring(0, cmd.length() - 1), readyCode);
+            log.debug("sendDir({})[{}] command='{}' ready code={}", this, path, cmd, readyCode);
         }
         validateAckReplyCode(cmd, path, readyCode, false);
 
@@ -705,178 +584,36 @@ public class ScpHelper extends AbstractLoggingBean implements SessionHolder<Sess
         if (debugEnabled) {
             log.debug("sendDir({})[{}] send 'E' command", this, path);
         }
-        out.write("E\n".getBytes(StandardCharsets.UTF_8));
-        out.flush();
 
-        readyCode = readAck(false);
+        readyCode = sendAcknowledgedCommand(ScpDirEndCommandDetails.HEADER);
         if (debugEnabled) {
             log.debug("sendDir({})[{}] 'E' command reply code=", this, path, readyCode);
         }
-        validateAckReplyCode("E", path, readyCode, false);
-    }
-
-    public static String getOctalPermissions(Collection<PosixFilePermission> perms) {
-        int pf = 0;
-
-        for (PosixFilePermission p : perms) {
-            switch (p) {
-                case OWNER_READ:
-                    pf |= S_IRUSR;
-                    break;
-                case OWNER_WRITE:
-                    pf |= S_IWUSR;
-                    break;
-                case OWNER_EXECUTE:
-                    pf |= S_IXUSR;
-                    break;
-                case GROUP_READ:
-                    pf |= S_IRGRP;
-                    break;
-                case GROUP_WRITE:
-                    pf |= S_IWGRP;
-                    break;
-                case GROUP_EXECUTE:
-                    pf |= S_IXGRP;
-                    break;
-                case OTHERS_READ:
-                    pf |= S_IROTH;
-                    break;
-                case OTHERS_WRITE:
-                    pf |= S_IWOTH;
-                    break;
-                case OTHERS_EXECUTE:
-                    pf |= S_IXOTH;
-                    break;
-                default: // ignored
-            }
-        }
-
-        return String.format("%04o", pf);
+        validateAckReplyCode(ScpDirEndCommandDetails.HEADER, path, readyCode, false);
     }
 
-    public static Set<PosixFilePermission> parseOctalPermissions(String str) {
-        int perms = Integer.parseInt(str, 8);
-        Set<PosixFilePermission> p = EnumSet.noneOf(PosixFilePermission.class);
-        if ((perms & S_IRUSR) != 0) {
-            p.add(PosixFilePermission.OWNER_READ);
-        }
-        if ((perms & S_IWUSR) != 0) {
-            p.add(PosixFilePermission.OWNER_WRITE);
-        }
-        if ((perms & S_IXUSR) != 0) {
-            p.add(PosixFilePermission.OWNER_EXECUTE);
-        }
-        if ((perms & S_IRGRP) != 0) {
-            p.add(PosixFilePermission.GROUP_READ);
-        }
-        if ((perms & S_IWGRP) != 0) {
-            p.add(PosixFilePermission.GROUP_WRITE);
-        }
-        if ((perms & S_IXGRP) != 0) {
-            p.add(PosixFilePermission.GROUP_EXECUTE);
-        }
-        if ((perms & S_IROTH) != 0) {
-            p.add(PosixFilePermission.OTHERS_READ);
-        }
-        if ((perms & S_IWOTH) != 0) {
-            p.add(PosixFilePermission.OTHERS_WRITE);
-        }
-        if ((perms & S_IXOTH) != 0) {
-            p.add(PosixFilePermission.OTHERS_EXECUTE);
-        }
-
-        return p;
+    protected int sendAcknowledgedCommand(String cmd) throws IOException {
+        return ScpIoUtils.sendAcknowledgedCommand(cmd, in, out, log);
     }
 
     protected void sendWarning(String message) throws IOException {
-        sendResponseMessage(WARNING, message);
+        sendResponseMessage(ScpIoUtils.WARNING, message);
     }
 
     protected void sendError(String message) throws IOException {
-        sendResponseMessage(ERROR, message);
+        sendResponseMessage(ScpIoUtils.ERROR, message);
     }
 
     protected void sendResponseMessage(int level, String message) throws IOException {
-        sendResponseMessage(out, level, message);
-    }
-
-    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);
-        out.write(message.getBytes(StandardCharsets.UTF_8));
-        out.write('\n');
-        out.flush();
-        return out;
-    }
-
-    public static String getExitStatusName(Integer exitStatus) {
-        if (exitStatus == null) {
-            return "null";
-        }
-
-        switch (exitStatus) {
-            case OK:
-                return "OK";
-            case WARNING:
-                return "WARNING";
-            case ERROR:
-                return "ERROR";
-            default:
-                return exitStatus.toString();
-        }
+        ScpIoUtils.sendResponseMessage(out, level, message);
     }
 
     public void ack() throws IOException {
-        out.write(0);
-        out.flush();
+        ScpIoUtils.ack(out);
     }
 
     public int readAck(boolean canEof) throws IOException {
-        int c = in.read();
-        switch (c) {
-            case -1:
-                if (log.isDebugEnabled()) {
-                    log.debug("readAck({})[EOF={}] received EOF", this, canEof);
-                }
-                if (!canEof) {
-                    throw new EOFException("readAck - EOF before ACK");
-                }
-                break;
-            case OK:
-                if (log.isDebugEnabled()) {
-                    log.debug("readAck({})[EOF={}] read OK", this, canEof);
-                }
-                break;
-            case WARNING: {
-                if (log.isDebugEnabled()) {
-                    log.debug("readAck({})[EOF={}] read warning message", this, canEof);
-                }
-
-                String line = readLine();
-                log.warn("readAck({})[EOF={}] - Received warning: {}", this, canEof, line);
-                break;
-            }
-            case ERROR: {
-                if (log.isDebugEnabled()) {
-                    log.debug("readAck({})[EOF={}] read error message", this, canEof);
-                }
-                String line = readLine();
-                if (log.isDebugEnabled()) {
-                    log.debug("readAck({})[EOF={}] received error: {}", this, canEof, line);
-                }
-                throw new ScpException("Received nack: " + line, c);
-            }
-            default:
-                break;
-        }
-        return c;
+        return ScpIoUtils.readAck(in, canEof, log, this);
     }
 
     @Override
diff --git a/sshd-scp/src/main/java/org/apache/sshd/scp/common/ScpTimestamp.java b/sshd-scp/src/main/java/org/apache/sshd/scp/common/ScpTimestamp.java
index 9c4372c..23fcb5d 100644
--- a/sshd-scp/src/main/java/org/apache/sshd/scp/common/ScpTimestamp.java
+++ b/sshd-scp/src/main/java/org/apache/sshd/scp/common/ScpTimestamp.java
@@ -19,21 +19,43 @@
 
 package org.apache.sshd.scp.common;
 
+import java.nio.file.attribute.FileTime;
 import java.util.Date;
 import java.util.concurrent.TimeUnit;
 
 import org.apache.sshd.common.util.GenericUtils;
+import org.apache.sshd.scp.common.helpers.AbstractScpCommandDetails;
 
 /**
  * Represents an SCP timestamp definition
- * 
+ *
  * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
  */
-public class ScpTimestamp {
+public class ScpTimestamp extends AbstractScpCommandDetails {
+    public static final char COMMAND_NAME = 'T';
+
     private final long lastModifiedTime;
     private final long lastAccessTime;
 
+    public ScpTimestamp(String header) {
+        super(COMMAND_NAME);
+
+        if (header.charAt(0) != COMMAND_NAME) {
+            throw new IllegalArgumentException("Expected a '" + COMMAND_NAME + "' but got '" + header + "'");
+        }
+
+        String[] numbers = GenericUtils.split(header.substring(1), ' ');
+        lastModifiedTime = TimeUnit.SECONDS.toMillis(Long.parseLong(numbers[0]));
+        lastAccessTime = TimeUnit.SECONDS.toMillis(Long.parseLong(numbers[2]));
+    }
+
+    public ScpTimestamp(FileTime modTime, FileTime accTime) {
+        this(modTime.to(TimeUnit.MILLISECONDS), accTime.to(TimeUnit.MILLISECONDS));
+    }
+
     public ScpTimestamp(long modTime, long accTime) {
+        super(COMMAND_NAME);
+
         lastModifiedTime = modTime;
         lastAccessTime = accTime;
     }
@@ -47,6 +69,12 @@ public class ScpTimestamp {
     }
 
     @Override
+    public String toHeader() {
+        return Character.toString(getCommand()) + TimeUnit.MILLISECONDS.toSeconds(getLastModifiedTime())
+               + " 0 " + TimeUnit.MILLISECONDS.toSeconds(getLastAccessTime()) + "0";
+    }
+
+    @Override
     public String toString() {
         return "modified=" + new Date(lastModifiedTime)
                + ";accessed=" + new Date(lastAccessTime);
@@ -55,16 +83,13 @@ public class ScpTimestamp {
     /**
      * @param  line                  The time specification - format:
      *                               {@code T<mtime-sec> <mtime-micros> <atime-sec> <atime-micros>} where specified
-     *                               times are in seconds since UTC
+     *                               times are in seconds since UTC - ignored if {@code null}
      * @return                       The {@link ScpTimestamp} value with the timestamps converted to <U>milliseconds</U>
-     * @throws NumberFormatException if bad numerical values - <B>Note:</B> does not check if 1st character is 'T'.
+     * @throws NumberFormatException if bad numerical values - <B>Note:</B> validates that 1st character is 'T'.
      * @see                          <A HREF="https://blogs.oracle.com/janp/entry/how_the_scp_protocol_works">How the
      *                               SCP protocol works</A>
      */
     public static ScpTimestamp parseTime(String line) throws NumberFormatException {
-        String[] numbers = GenericUtils.split(line.substring(1), ' ');
-        return new ScpTimestamp(
-                TimeUnit.SECONDS.toMillis(Long.parseLong(numbers[0])),
-                TimeUnit.SECONDS.toMillis(Long.parseLong(numbers[2])));
+        return GenericUtils.isEmpty(line) ? null : new ScpTimestamp(line);
     }
 }
diff --git a/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/AbstractScpCommandDetails.java b/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/AbstractScpCommandDetails.java
new file mode 100644
index 0000000..b484ebe
--- /dev/null
+++ b/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/AbstractScpCommandDetails.java
@@ -0,0 +1,40 @@
+/*
+ * 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;
+
+/**
+ * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
+ */
+public abstract class AbstractScpCommandDetails {
+    protected final char command;
+
+    protected AbstractScpCommandDetails(char command) {
+        this.command = command;
+    }
+
+    public char getCommand() {
+        return command;
+    }
+
+    /**
+     * @return The equivalent SCP command header represented by these details
+     */
+    public abstract String toHeader();
+}
diff --git a/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/CommandStatusHandler.java b/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/CommandStatusHandler.java
new file mode 100644
index 0000000..a7a88e8
--- /dev/null
+++ b/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/CommandStatusHandler.java
@@ -0,0 +1,41 @@
+/*
+ * 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.IOException;
+
+import org.apache.sshd.client.session.ClientSession;
+
+/**
+ * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
+ */
+@FunctionalInterface
+public interface CommandStatusHandler {
+    /**
+     * Invoked by the various <code>upload/download</code> methods after having successfully completed the remote copy
+     * command and (optionally) having received an exit status from the remote server
+     *
+     * @param  session     The associated {@link ClientSession}
+     * @param  cmd         The attempted remote copy command
+     * @param  exitStatus  The exit status - if {@code null} then no status was reported
+     * @throws IOException If failed the command
+     */
+    void handleCommandExitStatus(ClientSession session, String cmd, Integer exitStatus) throws IOException;
+}
diff --git a/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/ScpDirEndCommandDetails.java b/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/ScpDirEndCommandDetails.java
new file mode 100644
index 0000000..b1a638f
--- /dev/null
+++ b/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/ScpDirEndCommandDetails.java
@@ -0,0 +1,39 @@
+/*
+ * 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;
+
+/**
+ * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
+ */
+public class ScpDirEndCommandDetails extends AbstractScpCommandDetails {
+    public static final char COMMAND_NAME = 'E';
+    public static final String HEADER = "E";
+
+    public static final ScpDirEndCommandDetails INSTANCE = new ScpDirEndCommandDetails();
+
+    public ScpDirEndCommandDetails() {
+        super(COMMAND_NAME);
+    }
+
+    @Override
+    public String toHeader() {
+        return HEADER;
+    }
+}
diff --git a/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/ScpIoUtils.java b/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/ScpIoUtils.java
new file mode 100644
index 0000000..b8e271c
--- /dev/null
+++ b/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/ScpIoUtils.java
@@ -0,0 +1,423 @@
+/*
+ * 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.ByteArrayOutputStream;
+import java.io.EOFException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.Set;
+
+import org.apache.sshd.client.channel.ChannelExec;
+import org.apache.sshd.client.channel.ClientChannel;
+import org.apache.sshd.client.channel.ClientChannelEvent;
+import org.apache.sshd.client.session.ClientSession;
+import org.apache.sshd.common.session.Session;
+import org.apache.sshd.common.util.GenericUtils;
+import org.apache.sshd.core.CoreModuleProperties;
+import org.apache.sshd.scp.ScpModuleProperties;
+import org.apache.sshd.scp.common.ScpException;
+import org.apache.sshd.scp.common.ScpReceiveLineHandler;
+import org.apache.sshd.scp.common.ScpTimestamp;
+import org.slf4j.Logger;
+
+/**
+ * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
+ */
+public final class ScpIoUtils {
+    // ACK status codes
+    public static final int OK = 0;
+    public static final int WARNING = 1;
+    public static final int ERROR = 2;
+
+    public static final Set<ClientChannelEvent> COMMAND_WAIT_EVENTS
+            = Collections.unmodifiableSet(EnumSet.of(ClientChannelEvent.EXIT_STATUS, ClientChannelEvent.CLOSED));
+
+    private ScpIoUtils() {
+        throw new UnsupportedOperationException("No instance");
+    }
+
+    public static String readLine(InputStream in) throws IOException {
+        return readLine(in, false);
+    }
+
+    public static String readLine(InputStream in, boolean canEof) throws IOException {
+        try (ByteArrayOutputStream baos = new ByteArrayOutputStream(Byte.MAX_VALUE)) {
+            for (;;) {
+                int c = in.read();
+                if (c == '\n') {
+                    return baos.toString(StandardCharsets.UTF_8.name());
+                } else if (c == -1) {
+                    if (!canEof) {
+                        throw new EOFException("EOF while await end of line");
+                    }
+                    return null;
+                } else {
+                    baos.write(c);
+                }
+            }
+        }
+    }
+
+    public static void writeLine(OutputStream out, String cmd) throws IOException {
+        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 ScpTimestamp} 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, ScpTimestamp time, Logger log, Object logHint)
+            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;
+    }
+
+    public static int sendAcknowledgedCommand(
+            String cmd, InputStream in, OutputStream out, Logger log)
+            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();
+    }
+
+    /**
+     * Reads command line(s) and invokes the handler until EOF or and &quot;E&quot; command is received
+     *
+     * @param  session     The associated {@link Session}
+     * @param  in          The {@link InputStream} to read from
+     * @param  out         The {@link OutputStream} to write ACKs to
+     * @param  log         An optional {@link Logger} to use for issuing log messages - ignored if {@code null}
+     * @param  logHint     An optional hint to be used in the logged messages to identifier the caller's context
+     * @param  handler     The {@link ScpReceiveLineHandler} to invoke when a command has been read
+     * @throws IOException If failed to read/write
+     */
+    public static void receive(
+            Session session, InputStream in, OutputStream out, Logger log, Object logHint, ScpReceiveLineHandler handler)
+            throws IOException {
+        ack(out);
+
+        boolean debugEnabled = (log != null) && log.isDebugEnabled();
+        for (ScpTimestamp time = null;;) {
+            String line;
+            boolean isDir = false;
+            int c = readAck(in, true, log, logHint);
+            switch (c) {
+                case -1:
+                    return;
+                case ScpReceiveDirCommandDetails.COMMAND_NAME:
+                    line = readLine(in);
+                    line = Character.toString((char) c) + line;
+                    isDir = true;
+                    if (debugEnabled) {
+                        log.debug("receive({}) - Received 'D' header: {}", logHint, line);
+                    }
+                    break;
+                case ScpReceiveFileCommandDetails.COMMAND_NAME:
+                    line = readLine(in);
+                    line = Character.toString((char) c) + line;
+                    if (debugEnabled) {
+                        log.debug("receive({}) - Received 'C' header: {}", logHint, line);
+                    }
+                    break;
+                case ScpTimestamp.COMMAND_NAME:
+                    line = readLine(in);
+                    line = Character.toString((char) c) + line;
+                    if (debugEnabled) {
+                        log.debug("receive({}) - Received 'T' header: {}", logHint, line);
+                    }
+                    time = ScpTimestamp.parseTime(line);
+                    ack(out);
+                    continue;
+                case ScpDirEndCommandDetails.COMMAND_NAME:
+                    line = readLine(in);
+                    line = Character.toString((char) c) + line;
+                    if (debugEnabled) {
+                        log.debug("receive({}) - Received 'E' header: {}", logHint, line);
+                    }
+                    ack(out);
+                    return;
+                default:
+                    // a real ack that has been acted upon already
+                    continue;
+            }
+
+            try {
+                handler.process(session, line, isDir, time);
+            } finally {
+                time = null;
+            }
+        }
+    }
+
+    public static <O extends OutputStream> O sendWarning(O out, String message) throws IOException {
+        return sendResponseMessage(out, WARNING, message);
+    }
+
+    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);
+        }
+    }
+
+    public static String getExitStatusName(Integer exitStatus) {
+        if (exitStatus == null) {
+            return "null";
+        }
+
+        switch (exitStatus) {
+            case OK:
+                return "OK";
+            case WARNING:
+                return "WARNING";
+            case ERROR:
+                return "ERROR";
+            default:
+                return exitStatus.toString();
+        }
+    }
+
+    public static ChannelExec openCommandChannel(ClientSession session, String cmd, Logger log) throws IOException {
+        Duration waitTimeout = ScpModuleProperties.SCP_EXEC_CHANNEL_OPEN_TIMEOUT.getRequired(session);
+        ChannelExec channel = session.createExecChannel(cmd);
+
+        long startTime = System.nanoTime();
+        try {
+            channel.open().verify(waitTimeout);
+            long endTime = System.nanoTime();
+            long nanosWait = endTime - startTime;
+            if ((log != null) && log.isTraceEnabled()) {
+                log.trace("openCommandChannel(" + session + ")[" + cmd + "]"
+                          + " completed after " + nanosWait
+                          + " nanos out of " + waitTimeout.toNanos());
+            }
+
+            return channel;
+        } catch (IOException | RuntimeException e) {
+            long endTime = System.nanoTime();
+            long nanosWait = endTime - startTime;
+            if ((log != null) && log.isTraceEnabled()) {
+                log.trace("openCommandChannel(" + session + ")[" + cmd + "]"
+                          + " failed (" + e.getClass().getSimpleName() + ")"
+                          + " to complete after " + nanosWait
+                          + " nanos out of " + waitTimeout.toNanos()
+                          + ": " + e.getMessage());
+            }
+
+            channel.close(false);
+            throw e;
+        }
+    }
+
+    /**
+     * Invoked by the various <code>upload/download</code> methods after having successfully completed the remote copy
+     * command and (optionally) having received an exit status from the remote server. If no exit status received within
+     * {@link CoreModuleProperties#CHANNEL_CLOSE_TIMEOUT} the no further action is taken. Otherwise, the exit status is
+     * examined to ensure it is either OK or WARNING - if not, an {@link ScpException} is thrown
+     *
+     * @param  session     The associated {@link ClientSession}
+     * @param  cmd         The attempted remote copy command
+     * @param  channel     The {@link ClientChannel} through which the command was sent - <B>Note:</B> then channel may
+     *                     be in the process of being closed
+     * @param  handler     The {@link CommandStatusHandler} to invoke once the exit status is received. if {@code null}
+     *                     then {@link #handleCommandExitStatus(ClientSession, String, Integer, Logger)} is called
+     * @param  log         An optional {@link Logger} to use for issuing log messages - ignored if {@code null}
+     * @throws IOException If failed the command
+     */
+    public static void handleCommandExitStatus(
+            ClientSession session, String cmd, ClientChannel channel, CommandStatusHandler handler, Logger log)
+            throws IOException {
+        // give a chance for the exit status to be received
+        Duration timeout = ScpModuleProperties.SCP_EXEC_CHANNEL_EXIT_STATUS_TIMEOUT.getRequired(channel);
+        if (GenericUtils.isNegativeOrNull(timeout)) {
+            if (handler == null) {
+                handleCommandExitStatus(session, cmd, null, log);
+            } else {
+                handler.handleCommandExitStatus(session, cmd, (Integer) null);
+            }
+            return;
+        }
+
+        long waitStart = System.nanoTime();
+        Collection<ClientChannelEvent> events = channel.waitFor(COMMAND_WAIT_EVENTS, timeout);
+        long waitEnd = System.nanoTime();
+        if ((log != null) && log.isDebugEnabled()) {
+            log.debug("handleCommandExitStatus({}) cmd='{}', waited={} nanos, events={}",
+                    session, cmd, waitEnd - waitStart, events);
+        }
+
+        /*
+         * There are sometimes race conditions in the order in which channels are closed and exit-status sent by the
+         * remote peer (if at all), thus there is no guarantee that we will have an exit status here
+         */
+        Integer exitStatus = channel.getExitStatus();
+        if (handler == null) {
+            handleCommandExitStatus(session, cmd, exitStatus, log);
+        } else {
+            handler.handleCommandExitStatus(session, cmd, exitStatus);
+        }
+    }
+
+    /**
+     * Invoked by the various <code>upload/download</code> methods after having successfully completed the remote copy
+     * command and (optionally) having received an exit status from the remote server
+     *
+     * @param  session     The associated {@link ClientSession}
+     * @param  cmd         The attempted remote copy command
+     * @param  exitStatus  The exit status - if {@code null} then no status was reported
+     * @param  log         An optional {@link Logger} to use for issuing log messages - ignored if {@code null}
+     * @throws IOException If got a an error exit status
+     */
+    public static void handleCommandExitStatus(
+            ClientSession session, String cmd, Integer exitStatus, Logger log)
+            throws IOException {
+        if ((log != null) && log.isDebugEnabled()) {
+            log.debug("handleCommandExitStatus({}) cmd='{}', exit-status={}",
+                    session, cmd, ScpIoUtils.getExitStatusName(exitStatus));
+        }
+
+        if (exitStatus == null) {
+            return;
+        }
+
+        int statusCode = exitStatus;
+        switch (statusCode) {
+            case OK: // do nothing
+                break;
+            case WARNING:
+                if (log != null) {
+                    log.warn("handleCommandExitStatus({}) cmd='{}' may have terminated with some problems", session, cmd);
+                }
+                break;
+            default:
+                throw new ScpException(
+                        "Failed to run command='" + cmd + "': " + ScpIoUtils.getExitStatusName(exitStatus), exitStatus);
+        }
+    }
+
+}
diff --git a/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/ScpPathCommandDetailsSupport.java b/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/ScpPathCommandDetailsSupport.java
new file mode 100644
index 0000000..5197656
--- /dev/null
+++ b/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/ScpPathCommandDetailsSupport.java
@@ -0,0 +1,178 @@
+/*
+ * 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.nio.file.attribute.PosixFilePermission;
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.Set;
+
+import org.apache.sshd.common.NamedResource;
+import org.apache.sshd.common.util.ValidateUtils;
+
+/**
+ * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
+ */
+@SuppressWarnings("PMD.AvoidUsingOctalValues")
+public abstract class ScpPathCommandDetailsSupport extends AbstractScpCommandDetails implements NamedResource {
+    // File permissions masks
+    public static final int S_IRUSR = 0000400;
+    public static final int S_IWUSR = 0000200;
+    public static final int S_IXUSR = 0000100;
+    public static final int S_IRGRP = 0000040;
+    public static final int S_IWGRP = 0000020;
+    public static final int S_IXGRP = 0000010;
+    public static final int S_IROTH = 0000004;
+    public static final int S_IWOTH = 0000002;
+    public static final int S_IXOTH = 0000001;
+
+    private Set<PosixFilePermission> permissions;
+    private long length;
+    private String name;
+
+    protected ScpPathCommandDetailsSupport(char command) {
+        super(command);
+    }
+
+    protected ScpPathCommandDetailsSupport(char command, String header) {
+        super(command);
+
+        ValidateUtils.checkNotNullAndNotEmpty(header, "No header provided");
+        if (header.charAt(0) != command) {
+            throw new IllegalArgumentException("Expected a '" + command + "' message but got '" + header + "'");
+        }
+
+        permissions = parseOctalPermissions(header.substring(1, 5));
+        length = Long.parseLong(header.substring(6, header.indexOf(' ', 6)));
+        name = header.substring(header.indexOf(' ', 6) + 1);
+    }
+
+    public Set<PosixFilePermission> getPermissions() {
+        return permissions;
+    }
+
+    public void setPermissions(Set<PosixFilePermission> permissions) {
+        this.permissions = permissions;
+    }
+
+    public long getLength() {
+        return length;
+    }
+
+    public void setLength(long length) {
+        this.length = length;
+    }
+
+    @Override
+    public String getName() {
+        return name;
+    }
+
+    public void setName(String name) {
+        this.name = name;
+    }
+
+    @Override
+    public String toHeader() {
+        return getCommand() + getOctalPermissions(getPermissions()) + " " + getLength() + " " + getName();
+    }
+
+    @Override
+    public String toString() {
+        return getClass().getSimpleName()
+               + "[name=" + getName()
+               + ", len=" + getLength()
+               + ", perms=" + getPermissions()
+               + "]";
+    }
+
+    public static String getOctalPermissions(Collection<PosixFilePermission> perms) {
+        int pf = 0;
+
+        for (PosixFilePermission p : perms) {
+            switch (p) {
+                case OWNER_READ:
+                    pf |= S_IRUSR;
+                    break;
+                case OWNER_WRITE:
+                    pf |= S_IWUSR;
+                    break;
+                case OWNER_EXECUTE:
+                    pf |= S_IXUSR;
+                    break;
+                case GROUP_READ:
+                    pf |= S_IRGRP;
+                    break;
+                case GROUP_WRITE:
+                    pf |= S_IWGRP;
+                    break;
+                case GROUP_EXECUTE:
+                    pf |= S_IXGRP;
+                    break;
+                case OTHERS_READ:
+                    pf |= S_IROTH;
+                    break;
+                case OTHERS_WRITE:
+                    pf |= S_IWOTH;
+                    break;
+                case OTHERS_EXECUTE:
+                    pf |= S_IXOTH;
+                    break;
+                default: // ignored
+            }
+        }
+
+        return String.format("%04o", pf);
+    }
+
+    public static Set<PosixFilePermission> parseOctalPermissions(String str) {
+        int perms = Integer.parseInt(str, 8);
+        Set<PosixFilePermission> p = EnumSet.noneOf(PosixFilePermission.class);
+        if ((perms & S_IRUSR) != 0) {
+            p.add(PosixFilePermission.OWNER_READ);
+        }
+        if ((perms & S_IWUSR) != 0) {
+            p.add(PosixFilePermission.OWNER_WRITE);
+        }
+        if ((perms & S_IXUSR) != 0) {
+            p.add(PosixFilePermission.OWNER_EXECUTE);
+        }
+        if ((perms & S_IRGRP) != 0) {
+            p.add(PosixFilePermission.GROUP_READ);
+        }
+        if ((perms & S_IWGRP) != 0) {
+            p.add(PosixFilePermission.GROUP_WRITE);
+        }
+        if ((perms & S_IXGRP) != 0) {
+            p.add(PosixFilePermission.GROUP_EXECUTE);
+        }
+        if ((perms & S_IROTH) != 0) {
+            p.add(PosixFilePermission.OTHERS_READ);
+        }
+        if ((perms & S_IWOTH) != 0) {
+            p.add(PosixFilePermission.OTHERS_WRITE);
+        }
+        if ((perms & S_IXOTH) != 0) {
+            p.add(PosixFilePermission.OTHERS_EXECUTE);
+        }
+
+        return p;
+    }
+}
diff --git a/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/ScpReceiveDirCommandDetails.java b/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/ScpReceiveDirCommandDetails.java
new file mode 100644
index 0000000..f9f67ee
--- /dev/null
+++ b/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/ScpReceiveDirCommandDetails.java
@@ -0,0 +1,44 @@
+/*
+ * 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 org.apache.sshd.common.util.GenericUtils;
+
+/**
+ * Holds the details of a &quot;Dmmmm <length> <directory>&quot; command - e.g., &quot;D0755 0 dirname&quot;
+ * 
+ * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
+ */
+public class ScpReceiveDirCommandDetails extends ScpPathCommandDetailsSupport {
+    public static final String DEFAULT_DIR_OCTAL_PERMISSIONS = "0755";
+    public static final char COMMAND_NAME = 'D';
+
+    public ScpReceiveDirCommandDetails() {
+        super(COMMAND_NAME);
+    }
+
+    public ScpReceiveDirCommandDetails(String header) {
+        super(COMMAND_NAME, header);
+    }
+
+    public static ScpReceiveDirCommandDetails parse(String header) {
+        return GenericUtils.isEmpty(header) ? null : new ScpReceiveDirCommandDetails(header);
+    }
+}
diff --git a/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/ScpReceiveFileCommandDetails.java b/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/ScpReceiveFileCommandDetails.java
new file mode 100644
index 0000000..7524707
--- /dev/null
+++ b/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/ScpReceiveFileCommandDetails.java
@@ -0,0 +1,45 @@
+/*
+ * 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 org.apache.sshd.common.util.GenericUtils;
+
+/**
+ * Holds the details of a &quot;Cmmmm <length> <filename>&quot; command - e.g., &quot;C0644 299 file1.txt&quot;
+ *
+ * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
+ */
+public class ScpReceiveFileCommandDetails extends ScpPathCommandDetailsSupport {
+    public static final String DEFAULT_FILE_OCTAL_PERMISSIONS = "0644";
+
+    public static final char COMMAND_NAME = 'C';
+
+    public ScpReceiveFileCommandDetails() {
+        super(COMMAND_NAME);
+    }
+
+    public ScpReceiveFileCommandDetails(String header) {
+        super(COMMAND_NAME, header);
+    }
+
+    public static ScpReceiveFileCommandDetails parse(String header) {
+        return GenericUtils.isEmpty(header) ? null : new ScpReceiveFileCommandDetails(header);
+    }
+}
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 030b62a..fcaf577 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,6 +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.server.Environment;
 import org.apache.sshd.server.ExitCallback;
 import org.apache.sshd.server.channel.ChannelSession;
@@ -156,7 +157,7 @@ public class ScpCommand extends AbstractFileSystemCommand {
 
     @Override
     public void run() {
-        int exitValue = ScpHelper.OK;
+        int exitValue = ScpIoUtils.OK;
         String exitMessage = null;
         ServerSession session = getServerSession();
         String command = getCommand();
@@ -177,13 +178,13 @@ public class ScpCommand extends AbstractFileSystemCommand {
                 if (e instanceof ScpException) {
                     statusCode = ((ScpException) e).getExitStatus();
                 }
-                exitValue = (statusCode == null) ? ScpHelper.ERROR : statusCode;
+                exitValue = (statusCode == null) ? ScpIoUtils.ERROR : statusCode;
                 // this is an exception so status cannot be OK/WARNING
-                if ((exitValue == ScpHelper.OK) || (exitValue == ScpHelper.WARNING)) {
+                if ((exitValue == ScpIoUtils.OK) || (exitValue == ScpIoUtils.WARNING)) {
                     if (debugEnabled) {
                         log.debug("run({})[{}] normalize status code={}", session, command, exitValue);
                     }
-                    exitValue = ScpHelper.ERROR;
+                    exitValue = ScpIoUtils.ERROR;
                 }
                 exitMessage = GenericUtils.trimToEmpty(e.getMessage());
                 writeCommandResponseMessage(command, exitValue, exitMessage);
@@ -213,7 +214,7 @@ public class ScpCommand extends AbstractFileSystemCommand {
             log.debug("writeCommandResponseMessage({}) command='{}', exit-status={}: {}",
                     getServerSession(), command, exitValue, exitMessage);
         }
-        ScpHelper.sendResponseMessage(getOutputStream(), exitValue, exitMessage);
+        ScpIoUtils.sendResponseMessage(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 faa9339..3cde5f1 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,6 +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.server.Environment;
 import org.apache.sshd.server.channel.ChannelSession;
 import org.apache.sshd.server.command.AbstractFileSystemCommand;
@@ -475,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) ? ScpHelper.ERROR : statusCode;
+            int exitValue = (statusCode == null) ? ScpIoUtils.ERROR : statusCode;
             // this is an exception so status cannot be OK/WARNING
-            if ((exitValue == ScpHelper.OK) || (exitValue == ScpHelper.WARNING)) {
-                exitValue = ScpHelper.ERROR;
+            if ((exitValue == ScpIoUtils.OK) || (exitValue == ScpIoUtils.WARNING)) {
+                exitValue = ScpIoUtils.ERROR;
             }
             String exitMessage = GenericUtils.trimToEmpty(e.getMessage());
-            ScpHelper.sendResponseMessage(getOutputStream(), exitValue, exitMessage);
+            ScpIoUtils.sendResponseMessage(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 5e0ec88..788c0fa 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
@@ -40,12 +40,6 @@ import java.util.Set;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
 
-import ch.ethz.ssh2.Connection;
-import ch.ethz.ssh2.ConnectionInfo;
-import ch.ethz.ssh2.SCPClient;
-import com.jcraft.jsch.ChannelExec;
-import com.jcraft.jsch.JSch;
-import com.jcraft.jsch.JSchException;
 import org.apache.sshd.client.SshClient;
 import org.apache.sshd.client.session.ClientSession;
 import org.apache.sshd.common.Factory;
@@ -66,6 +60,11 @@ 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.ScpDirEndCommandDetails;
+import org.apache.sshd.scp.common.helpers.ScpIoUtils;
+import org.apache.sshd.scp.common.helpers.ScpPathCommandDetailsSupport;
+import org.apache.sshd.scp.common.helpers.ScpReceiveDirCommandDetails;
+import org.apache.sshd.scp.common.helpers.ScpReceiveFileCommandDetails;
 import org.apache.sshd.scp.server.ScpCommand;
 import org.apache.sshd.scp.server.ScpCommandFactory;
 import org.apache.sshd.server.SshServer;
@@ -84,6 +83,14 @@ import org.junit.FixMethodOrder;
 import org.junit.Test;
 import org.junit.runners.MethodSorters;
 
+import com.jcraft.jsch.ChannelExec;
+import com.jcraft.jsch.JSch;
+import com.jcraft.jsch.JSchException;
+
+import ch.ethz.ssh2.Connection;
+import ch.ethz.ssh2.ConnectionInfo;
+import ch.ethz.ssh2.SCPClient;
+
 /**
  * Test for SCP support.
  *
@@ -922,7 +929,7 @@ public class ScpTest extends BaseTestSupport {
             @Override
             protected void onExit(int exitValue, String exitMessage) {
                 outputDebugMessage("onExit(%s) status=%d", this, exitValue);
-                super.onExit((exitValue == ScpHelper.OK) ? testExitValue : exitValue, exitMessage);
+                super.onExit((exitValue == ScpIoUtils.OK) ? testExitValue : exitValue, exitMessage);
             }
         }
 
@@ -1086,7 +1093,7 @@ public class ScpTest extends BaseTestSupport {
         String remotePath = CommonTestSupportUtils.resolveRelativeRemotePath(parentPath, remoteDir);
         String fileName = "file.txt";
         Path remoteFile = remoteDir.resolve(fileName);
-        String mode = ScpHelper.getOctalPermissions(EnumSet.of(
+        String mode = ScpPathCommandDetailsSupport.getOctalPermissions(EnumSet.of(
                 PosixFilePermission.OTHERS_READ, PosixFilePermission.OTHERS_WRITE,
                 PosixFilePermission.GROUP_READ, PosixFilePermission.GROUP_WRITE,
                 PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE));
@@ -1138,7 +1145,9 @@ public class ScpTest extends BaseTestSupport {
             os.flush();
 
             String header = readLine(is);
-            String expHeader = "C" + ScpHelper.DEFAULT_FILE_OCTAL_PERMISSIONS + " " + Files.size(target) + " " + fileName;
+            String expHeader
+                    = ScpReceiveFileCommandDetails.COMMAND_NAME + ScpReceiveFileCommandDetails.DEFAULT_FILE_OCTAL_PERMISSIONS
+                      + " " + Files.size(target) + " " + fileName;
             assertEquals("Mismatched header for " + path, expHeader, header);
 
             String lenValue = header.substring(6, header.indexOf(' ', 6));
@@ -1171,14 +1180,17 @@ public class ScpTest extends BaseTestSupport {
             os.flush();
 
             String header = readLine(is);
-            String expPrefix = "D" + ScpHelper.DEFAULT_DIR_OCTAL_PERMISSIONS + " 0 ";
+            String expPrefix = ScpReceiveDirCommandDetails.COMMAND_NAME
+                               + ScpReceiveDirCommandDetails.DEFAULT_DIR_OCTAL_PERMISSIONS + " 0 ";
             assertTrue("Bad header prefix for " + path + ": " + header, header.startsWith(expPrefix));
             os.write(0);
             os.flush();
 
             header = readLine(is);
             String fileName = Objects.toString(target.getFileName(), null);
-            String expHeader = "C" + ScpHelper.DEFAULT_FILE_OCTAL_PERMISSIONS + " " + Files.size(target) + " " + fileName;
+            String expHeader
+                    = ScpReceiveFileCommandDetails.COMMAND_NAME + ScpReceiveFileCommandDetails.DEFAULT_FILE_OCTAL_PERMISSIONS
+                      + " " + Files.size(target) + " " + fileName;
             assertEquals("Mismatched dir header for " + path, expHeader, header);
             int length = Integer.parseInt(header.substring(6, header.indexOf(' ', 6)));
             os.write(0);
@@ -1233,9 +1245,10 @@ public class ScpTest extends BaseTestSupport {
 
             Path parent = target.getParent();
             Collection<PosixFilePermission> perms = IoUtils.getPermissions(parent);
-            String octalPerms = ScpHelper.getOctalPermissions(perms);
+            String octalPerms = ScpPathCommandDetailsSupport.getOctalPermissions(perms);
             String name = Objects.toString(target.getFileName(), null);
-            assertAckReceived(os, is, "C" + octalPerms + " " + data.length() + " " + name);
+            assertAckReceived(os, is,
+                    ScpReceiveFileCommandDetails.COMMAND_NAME + octalPerms + " " + data.length() + " " + name);
 
             os.write(data.getBytes(StandardCharsets.UTF_8));
             os.flush();
@@ -1298,11 +1311,9 @@ public class ScpTest extends BaseTestSupport {
             os.flush();
             assertAckReceived(is, "Send data of " + path);
 
-            os.write(0);
-            os.flush();
+            ScpIoUtils.ack(os);
+            ScpIoUtils.writeLine(os, ScpDirEndCommandDetails.HEADER);
 
-            os.write("E\n".getBytes(StandardCharsets.UTF_8));
-            os.flush();
             assertAckReceived(is, "Signal end of " + path);
         } finally {
             c.disconnect();


[mina-sshd] 06/06: [SSHD-1005] Create consistent SCP command details hierarchy

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

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

commit 39a0841e0c55101d813f658e65c584bb42fef942
Author: Lyor Goldstein <lg...@apache.org>
AuthorDate: Mon Aug 17 10:18:13 2020 +0300

    [SSHD-1005] Create consistent SCP command details hierarchy
---
 .../org/apache/sshd/common/util/GenericUtils.java  | 28 ++++++-
 .../apache/sshd/scp/client/DefaultScpClient.java   |  5 +-
 .../sshd/scp/client/DefaultScpStreamResolver.java  |  8 +-
 .../java/org/apache/sshd/scp/client/ScpClient.java | 10 ++-
 .../scp/client/ScpRemote2RemoteTransferHelper.java | 10 +--
 .../client/ScpRemote2RemoteTransferListener.java   | 10 +--
 .../org/apache/sshd/scp/common/ScpFileOpener.java  | 10 ++-
 .../java/org/apache/sshd/scp/common/ScpHelper.java | 17 +++--
 .../sshd/scp/common/ScpReceiveLineHandler.java     |  5 +-
 .../sshd/scp/common/ScpSourceStreamResolver.java   |  7 +-
 .../sshd/scp/common/ScpTargetStreamResolver.java   |  3 +-
 .../helpers/LocalFileScpSourceStreamResolver.java  |  7 +-
 .../helpers/LocalFileScpTargetStreamResolver.java  |  5 +-
 .../common/helpers/ScpDirEndCommandDetails.java    | 28 +++++++
 .../apache/sshd/scp/common/helpers/ScpIoUtils.java | 11 ++-
 .../helpers/ScpPathCommandDetailsSupport.java      | 35 ++++++++-
 .../helpers/ScpReceiveDirCommandDetails.java       |  7 +-
 .../ScpTimestampCommandDetails.java}               | 42 ++++++++---
 .../client/ScpRemote2RemoteTransferHelperTest.java |  6 +-
 .../helpers/AbstractScpCommandDetailsTest.java     | 88 ++++++++++++++++++++++
 .../server/ScpReceiveDirCommandDetailsTest.java    | 49 ++++++++++++
 21 files changed, 323 insertions(+), 68 deletions(-)

diff --git a/sshd-common/src/main/java/org/apache/sshd/common/util/GenericUtils.java b/sshd-common/src/main/java/org/apache/sshd/common/util/GenericUtils.java
index ea2c4e6..f48a49f 100644
--- a/sshd-common/src/main/java/org/apache/sshd/common/util/GenericUtils.java
+++ b/sshd-common/src/main/java/org/apache/sshd/common/util/GenericUtils.java
@@ -298,6 +298,30 @@ public final class GenericUtils {
         return !isEmpty(c);
     }
 
+    /**
+     *
+     * @param  <T> Generic element type
+     * @param  c1  First collection
+     * @param  c2  Second collection
+     * @return     {@code true} if the following holds:
+     *             <UL>
+     *             <LI>Same size - <B>Note:</B> {@code null} collections are consider equal to empty ones</LI>
+     *
+     *             <LI>First collection contains all elements of second one and vice versa</LI>
+     *             </UL>
+     */
+    public static <T> boolean equals(Collection<T> c1, Collection<T> c2) {
+        if (isEmpty(c1)) {
+            return isEmpty(c2);
+        } else if (isEmpty(c2)) {
+            return false;
+        }
+
+        return (c1.size() == c2.size())
+                && c1.containsAll(c2)
+                && c2.containsAll(c1);
+    }
+
     public static int size(Map<?, ?> m) {
         return (m == null) ? 0 : m.size();
     }
@@ -1039,7 +1063,7 @@ public final class GenericUtils {
 
     /**
      * Check if a duration is positive
-     * 
+     *
      * @param  d the duration
      * @return   <code>true</code> if the duration is greater than zero
      */
@@ -1049,7 +1073,7 @@ public final class GenericUtils {
 
     /**
      * Check if a duration is negative or zero
-     * 
+     *
      * @param  d the duration
      * @return   <code>true</code> if the duration is negative or zero
      */
diff --git a/sshd-scp/src/main/java/org/apache/sshd/scp/client/DefaultScpClient.java b/sshd-scp/src/main/java/org/apache/sshd/scp/client/DefaultScpClient.java
index 471ef2d..6a032e8 100644
--- a/sshd-scp/src/main/java/org/apache/sshd/scp/client/DefaultScpClient.java
+++ b/sshd-scp/src/main/java/org/apache/sshd/scp/client/DefaultScpClient.java
@@ -38,9 +38,9 @@ import org.apache.sshd.common.file.util.MockPath;
 import org.apache.sshd.common.util.ValidateUtils;
 import org.apache.sshd.scp.common.ScpFileOpener;
 import org.apache.sshd.scp.common.ScpHelper;
-import org.apache.sshd.scp.common.ScpTimestamp;
 import org.apache.sshd.scp.common.ScpTransferEventListener;
 import org.apache.sshd.scp.common.helpers.DefaultScpFileOpener;
+import org.apache.sshd.scp.common.helpers.ScpTimestampCommandDetails;
 
 /**
  * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
@@ -102,7 +102,8 @@ public class DefaultScpClient extends AbstractScpClient {
     }
 
     @Override
-    public void upload(InputStream local, String remote, long size, Collection<PosixFilePermission> perms, ScpTimestamp time)
+    public void upload(
+            InputStream local, String remote, long size, Collection<PosixFilePermission> perms, ScpTimestampCommandDetails time)
             throws IOException {
         int namePos = ValidateUtils.checkNotNullAndNotEmpty(remote, "No remote location specified").lastIndexOf('/');
         String name = (namePos < 0)
diff --git a/sshd-scp/src/main/java/org/apache/sshd/scp/client/DefaultScpStreamResolver.java b/sshd-scp/src/main/java/org/apache/sshd/scp/client/DefaultScpStreamResolver.java
index fb82cf2..eb21379 100644
--- a/sshd-scp/src/main/java/org/apache/sshd/scp/client/DefaultScpStreamResolver.java
+++ b/sshd-scp/src/main/java/org/apache/sshd/scp/client/DefaultScpStreamResolver.java
@@ -28,7 +28,7 @@ import java.util.Set;
 
 import org.apache.sshd.common.session.Session;
 import org.apache.sshd.scp.common.ScpSourceStreamResolver;
-import org.apache.sshd.scp.common.ScpTimestamp;
+import org.apache.sshd.scp.common.helpers.ScpTimestampCommandDetails;
 
 /**
  * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
@@ -37,14 +37,14 @@ public class DefaultScpStreamResolver implements ScpSourceStreamResolver {
     private final String name;
     private final Path mockPath;
     private final Collection<PosixFilePermission> perms;
-    private final ScpTimestamp time;
+    private final ScpTimestampCommandDetails time;
     private final long size;
     private final InputStream local;
     private final String cmd;
 
     public DefaultScpStreamResolver(
                                     String name, Path mockPath, Collection<PosixFilePermission> perms,
-                                    ScpTimestamp time, long size, InputStream local, String cmd) {
+                                    ScpTimestampCommandDetails time, long size, InputStream local, String cmd) {
         this.name = name;
         this.mockPath = mockPath;
         this.perms = perms;
@@ -70,7 +70,7 @@ public class DefaultScpStreamResolver implements ScpSourceStreamResolver {
     }
 
     @Override
-    public ScpTimestamp getTimestamp() throws IOException {
+    public ScpTimestampCommandDetails getTimestamp() throws IOException {
         return time;
     }
 
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 c40d07b..ef781bc 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
@@ -33,7 +33,7 @@ import org.apache.sshd.common.session.SessionHolder;
 import org.apache.sshd.common.util.GenericUtils;
 import org.apache.sshd.common.util.ValidateUtils;
 import org.apache.sshd.scp.common.ScpHelper;
-import org.apache.sshd.scp.common.ScpTimestamp;
+import org.apache.sshd.scp.common.helpers.ScpTimestampCommandDetails;
 
 /**
  * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
@@ -116,20 +116,22 @@ public interface ScpClient extends SessionHolder<ClientSession>, ClientSessionHo
 
     // NOTE: due to SCP command limitations, the amount of data to be uploaded must be known a-priori
     // To upload a dynamic amount of data use SFTP
-    default void upload(byte[] data, String remote, Collection<PosixFilePermission> perms, ScpTimestamp time)
+    default void upload(byte[] data, String remote, Collection<PosixFilePermission> perms, ScpTimestampCommandDetails time)
             throws IOException {
         upload(data, 0, data.length, remote, perms, time);
     }
 
     default void upload(
-            byte[] data, int offset, int len, String remote, Collection<PosixFilePermission> perms, ScpTimestamp time)
+            byte[] data, int offset, int len, String remote, Collection<PosixFilePermission> perms,
+            ScpTimestampCommandDetails time)
             throws IOException {
         try (InputStream local = new ByteArrayInputStream(data, offset, len)) {
             upload(local, remote, len, perms, time);
         }
     }
 
-    void upload(InputStream local, String remote, long size, Collection<PosixFilePermission> perms, ScpTimestamp time)
+    void upload(
+            InputStream local, String remote, long size, Collection<PosixFilePermission> perms, ScpTimestampCommandDetails time)
             throws IOException;
 
     static String createSendCommand(String remote, Collection<Option> options) {
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 1b4852a..d558576 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
@@ -35,10 +35,10 @@ import org.apache.sshd.common.util.io.IoUtils;
 import org.apache.sshd.common.util.io.LimitInputStream;
 import org.apache.sshd.common.util.logging.AbstractLoggingBean;
 import org.apache.sshd.scp.client.ScpClient.Option;
-import org.apache.sshd.scp.common.ScpTimestamp;
 import org.apache.sshd.scp.common.helpers.AbstractScpCommandDetails;
 import org.apache.sshd.scp.common.helpers.ScpIoUtils;
 import org.apache.sshd.scp.common.helpers.ScpReceiveFileCommandDetails;
+import org.apache.sshd.scp.common.helpers.ScpTimestampCommandDetails;
 
 /**
  * Helps transfer files between 2 servers rather than between server and local file system by using 2
@@ -133,10 +133,10 @@ public class ScpRemote2RemoteTransferHelper extends AbstractLoggingBean {
         }
 
         char cmdName = header.charAt(0);
-        ScpTimestamp time = null;
-        if (cmdName == ScpTimestamp.COMMAND_NAME) {
+        ScpTimestampCommandDetails time = null;
+        if (cmdName == ScpTimestampCommandDetails.COMMAND_NAME) {
             // Pass along the "T<mtime> 0 <atime> 0" and wait for response
-            time = ScpTimestamp.parseTime(header);
+            time = ScpTimestampCommandDetails.parseTime(header);
             signalReceivedCommand(time);
 
             ScpIoUtils.writeLine(dstOut, header);
@@ -203,7 +203,7 @@ public class ScpRemote2RemoteTransferHelper extends AbstractLoggingBean {
     protected long transferFileData(
             String source, InputStream srcIn, OutputStream srcOut,
             String destination, InputStream dstIn, OutputStream dstOut,
-            ScpTimestamp time, ScpReceiveFileCommandDetails details)
+            ScpTimestampCommandDetails time, ScpReceiveFileCommandDetails details)
             throws IOException {
         long length = details.getLength();
         if (length < 0L) { // TODO consider throwing an exception...
diff --git a/sshd-scp/src/main/java/org/apache/sshd/scp/client/ScpRemote2RemoteTransferListener.java b/sshd-scp/src/main/java/org/apache/sshd/scp/client/ScpRemote2RemoteTransferListener.java
index 1322495..8ddad59 100644
--- a/sshd-scp/src/main/java/org/apache/sshd/scp/client/ScpRemote2RemoteTransferListener.java
+++ b/sshd-scp/src/main/java/org/apache/sshd/scp/client/ScpRemote2RemoteTransferListener.java
@@ -22,8 +22,8 @@ package org.apache.sshd.scp.client;
 import java.io.IOException;
 
 import org.apache.sshd.client.session.ClientSession;
-import org.apache.sshd.scp.common.ScpTimestamp;
 import org.apache.sshd.scp.common.helpers.ScpReceiveFileCommandDetails;
+import org.apache.sshd.scp.common.helpers.ScpTimestampCommandDetails;
 
 /**
  * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
@@ -36,14 +36,14 @@ public interface ScpRemote2RemoteTransferListener {
      * @param  source      The source path
      * @param  dstSession  The destination {@link ClientSession}
      * @param  destination The destination path
-     * @param  timestamp   The {@link ScpTimestamp timestamp} of the file - may be {@code null}
+     * @param  timestamp   The {@link ScpTimestampCommandDetails timestamp} of the file - may be {@code null}
      * @param  details     The {@link ScpReceiveFileCommandDetails details} of the attempted file transfer
      * @throws IOException If failed to handle the callback
      */
     void startDirectFileTransfer(
             ClientSession srcSession, String source,
             ClientSession dstSession, String destination,
-            ScpTimestamp timestamp, ScpReceiveFileCommandDetails details)
+            ScpTimestampCommandDetails timestamp, ScpReceiveFileCommandDetails details)
             throws IOException;
 
     /**
@@ -53,7 +53,7 @@ public interface ScpRemote2RemoteTransferListener {
      * @param  source      The source path
      * @param  dstSession  The destination {@link ClientSession}
      * @param  destination The destination path
-     * @param  timestamp   The {@link ScpTimestamp timestamp} of the file - may be {@code null}
+     * @param  timestamp   The {@link ScpTimestampCommandDetails timestamp} of the file - may be {@code null}
      * @param  details     The {@link ScpReceiveFileCommandDetails details} of the attempted file transfer
      * @param  xferSize    Number of successfully transfered bytes - zero if <tt>thrown</tt> not {@code null}
      * @param  thrown      Error thrown during transfer attempt - {@code null} if successful
@@ -62,7 +62,7 @@ public interface ScpRemote2RemoteTransferListener {
     void endDirectFileTransfer(
             ClientSession srcSession, String source,
             ClientSession dstSession, String destination,
-            ScpTimestamp timestamp, ScpReceiveFileCommandDetails details,
+            ScpTimestampCommandDetails timestamp, ScpReceiveFileCommandDetails details,
             long xferSize, Throwable thrown)
             throws IOException;
 }
diff --git a/sshd-scp/src/main/java/org/apache/sshd/scp/common/ScpFileOpener.java b/sshd-scp/src/main/java/org/apache/sshd/scp/common/ScpFileOpener.java
index 1967e3e..fec0e66 100644
--- a/sshd-scp/src/main/java/org/apache/sshd/scp/common/ScpFileOpener.java
+++ b/sshd-scp/src/main/java/org/apache/sshd/scp/common/ScpFileOpener.java
@@ -43,6 +43,7 @@ import org.apache.sshd.common.session.Session;
 import org.apache.sshd.common.util.SelectorUtils;
 import org.apache.sshd.common.util.io.DirectoryScanner;
 import org.apache.sshd.common.util.io.IoUtils;
+import org.apache.sshd.scp.common.helpers.ScpTimestampCommandDetails;
 
 /**
  * Plug-in mechanism for users to intervene in the SCP process - e.g., apply some kind of traffic shaping mechanism,
@@ -60,14 +61,14 @@ public interface ScpFileOpener {
      * @param  name        The target file name
      * @param  preserve    Whether requested to preserve the permissions and timestamp
      * @param  permissions The requested file permissions
-     * @param  time        The requested {@link ScpTimestamp} - may be {@code null} if nothing to update
+     * @param  time        The requested {@link ScpTimestampCommandDetails} - may be {@code null} if nothing to update
      * @return             The actual target file path for the incoming file/directory
      * @throws IOException If failed to resolve the file path
-     * @see                #updateFileProperties(Path, Set, ScpTimestamp) updateFileProperties
+     * @see                #updateFileProperties(Path, Set, ScpTimestampCommandDetails) updateFileProperties
      */
     default Path resolveIncomingFilePath(
             Session session, Path localPath, String name, boolean preserve, Set<PosixFilePermission> permissions,
-            ScpTimestamp time)
+            ScpTimestampCommandDetails time)
             throws IOException {
         LinkOption[] options = IoUtils.getLinkOptions(true);
         Boolean status = IoUtils.checkFileExists(localPath, options);
@@ -331,7 +332,8 @@ public interface ScpFileOpener {
 
     ScpTargetStreamResolver createScpTargetStreamResolver(Session session, Path path) throws IOException;
 
-    static void updateFileProperties(Path file, Set<PosixFilePermission> perms, ScpTimestamp time) throws IOException {
+    static void updateFileProperties(Path file, Set<PosixFilePermission> perms, ScpTimestampCommandDetails time)
+            throws IOException {
         IoUtils.setPermissions(file, perms);
 
         if (time != null) {
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 53c3f94..4a6b111 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
@@ -51,6 +51,7 @@ import org.apache.sshd.scp.common.helpers.ScpIoUtils;
 import org.apache.sshd.scp.common.helpers.ScpPathCommandDetailsSupport;
 import org.apache.sshd.scp.common.helpers.ScpReceiveDirCommandDetails;
 import org.apache.sshd.scp.common.helpers.ScpReceiveFileCommandDetails;
+import org.apache.sshd.scp.common.helpers.ScpTimestampCommandDetails;
 
 /**
  * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
@@ -130,7 +131,7 @@ public class ScpHelper extends AbstractLoggingBean implements SessionHolder<Sess
                                                      // https://bugs.eclipse.org/bugs/show_bug.cgi?id=537593
                 public void postProcessReceivedData(
                         String name, boolean preserve, Set<PosixFilePermission> perms,
-                        ScpTimestamp time)
+                        ScpTimestampCommandDetails time)
                         throws IOException {
                     if (log.isDebugEnabled()) {
                         log.debug("postProcessReceivedData({}) name={}, perms={}, preserve={} time={}", ScpHelper.this,
@@ -163,7 +164,7 @@ public class ScpHelper extends AbstractLoggingBean implements SessionHolder<Sess
         ScpIoUtils.receive(getSession(), in, out, log, this, handler);
     }
 
-    public void receiveDir(String header, Path local, ScpTimestamp time, boolean preserve, int bufferSize)
+    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();
         boolean debugEnabled = log.isDebugEnabled();
@@ -205,8 +206,8 @@ public class ScpHelper extends AbstractLoggingBean implements SessionHolder<Sess
                 } else if (cmdChar == ScpDirEndCommandDetails.COMMAND_NAME) {
                     ack();
                     break;
-                } else if (cmdChar == ScpTimestamp.COMMAND_NAME) {
-                    time = ScpTimestamp.parseTime(header);
+                } else if (cmdChar == ScpTimestampCommandDetails.COMMAND_NAME) {
+                    time = ScpTimestampCommandDetails.parseTime(header);
                     ack();
                 } else {
                     throw new IOException("Unexpected message: '" + header + "'");
@@ -219,7 +220,7 @@ public class ScpHelper extends AbstractLoggingBean implements SessionHolder<Sess
         listener.endFolderEvent(session, FileOperation.RECEIVE, path, perms, null);
     }
 
-    public void receiveFile(String header, Path local, ScpTimestamp time, boolean preserve, int bufferSize)
+    public void receiveFile(String header, Path local, ScpTimestampCommandDetails time, boolean preserve, int bufferSize)
             throws IOException {
         Path path = Objects.requireNonNull(local, "No local path").normalize().toAbsolutePath();
         if (log.isDebugEnabled()) {
@@ -232,7 +233,7 @@ public class ScpHelper extends AbstractLoggingBean implements SessionHolder<Sess
     }
 
     public void receiveStream(
-            String header, ScpTargetStreamResolver resolver, ScpTimestamp time, boolean preserve,
+            String header, ScpTargetStreamResolver resolver, ScpTimestampCommandDetails time, boolean preserve,
             int bufferSize)
             throws IOException {
         if (bufferSize < MIN_RECEIVE_BUFFER_SIZE) {
@@ -451,7 +452,7 @@ public class ScpHelper extends AbstractLoggingBean implements SessionHolder<Sess
             bufSize = MIN_SEND_BUFFER_SIZE;
         }
 
-        ScpTimestamp time = resolver.getTimestamp();
+        ScpTimestampCommandDetails time = resolver.getTimestamp();
         if (preserve && (time != null)) {
             int readyCode = ScpIoUtils.sendTimeCommand(in, out, time, log, this);
             String cmd = time.toHeader();
@@ -530,7 +531,7 @@ public class ScpHelper extends AbstractLoggingBean implements SessionHolder<Sess
             BasicFileAttributes basic = opener.getLocalBasicFileAttributes(session, path, options);
             FileTime lastModified = basic.lastModifiedTime();
             FileTime lastAccess = basic.lastAccessTime();
-            ScpTimestamp time = new ScpTimestamp(lastModified, lastAccess);
+            ScpTimestampCommandDetails time = new ScpTimestampCommandDetails(lastModified, lastAccess);
             String cmd = time.toHeader();
             if (debugEnabled) {
                 log.debug("sendDir({})[{}] send last-modified={}, last-access={} command: {}", this, path, lastModified,
diff --git a/sshd-scp/src/main/java/org/apache/sshd/scp/common/ScpReceiveLineHandler.java b/sshd-scp/src/main/java/org/apache/sshd/scp/common/ScpReceiveLineHandler.java
index 38db119..9aac7da 100644
--- a/sshd-scp/src/main/java/org/apache/sshd/scp/common/ScpReceiveLineHandler.java
+++ b/sshd-scp/src/main/java/org/apache/sshd/scp/common/ScpReceiveLineHandler.java
@@ -22,6 +22,7 @@ package org.apache.sshd.scp.common;
 import java.io.IOException;
 
 import org.apache.sshd.common.session.Session;
+import org.apache.sshd.scp.common.helpers.ScpTimestampCommandDetails;
 
 /**
  * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
@@ -32,8 +33,8 @@ public interface ScpReceiveLineHandler {
      * @param  session     The client/server {@link Session} through which the transfer is being executed
      * @param  line        Received SCP input line
      * @param  isDir       Does the input line refer to a directory
-     * @param  time        The received {@link ScpTimestamp} - may be {@code null}
+     * @param  time        The received {@link ScpTimestampCommandDetails} - may be {@code null}
      * @throws IOException If failed to process the line
      */
-    void process(Session session, String line, boolean isDir, ScpTimestamp time) throws IOException;
+    void process(Session session, String line, boolean isDir, ScpTimestampCommandDetails time) throws IOException;
 }
diff --git a/sshd-scp/src/main/java/org/apache/sshd/scp/common/ScpSourceStreamResolver.java b/sshd-scp/src/main/java/org/apache/sshd/scp/common/ScpSourceStreamResolver.java
index 0d79d3b..3fe7d26 100644
--- a/sshd-scp/src/main/java/org/apache/sshd/scp/common/ScpSourceStreamResolver.java
+++ b/sshd-scp/src/main/java/org/apache/sshd/scp/common/ScpSourceStreamResolver.java
@@ -28,6 +28,7 @@ import java.util.Collection;
 import java.util.Set;
 
 import org.apache.sshd.common.session.Session;
+import org.apache.sshd.scp.common.helpers.ScpTimestampCommandDetails;
 
 /**
  * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
@@ -51,11 +52,11 @@ public interface ScpSourceStreamResolver {
     Collection<PosixFilePermission> getPermissions() throws IOException;
 
     /**
-     * @return             The {@link ScpTimestamp} to use for uploading the file if {@code null} then no need to send
-     *                     this information
+     * @return             The {@link ScpTimestampCommandDetails} to use for uploading the file if {@code null} then no
+     *                     need to send this information
      * @throws IOException If failed to generate the required data
      */
-    ScpTimestamp getTimestamp() throws IOException;
+    ScpTimestampCommandDetails getTimestamp() throws IOException;
 
     /**
      * @return             An estimated size of the expected number of bytes to be uploaded. If non-positive then
diff --git a/sshd-scp/src/main/java/org/apache/sshd/scp/common/ScpTargetStreamResolver.java b/sshd-scp/src/main/java/org/apache/sshd/scp/common/ScpTargetStreamResolver.java
index d894224..5fd7721 100644
--- a/sshd-scp/src/main/java/org/apache/sshd/scp/common/ScpTargetStreamResolver.java
+++ b/sshd-scp/src/main/java/org/apache/sshd/scp/common/ScpTargetStreamResolver.java
@@ -27,6 +27,7 @@ import java.nio.file.attribute.PosixFilePermission;
 import java.util.Set;
 
 import org.apache.sshd.common.session.Session;
+import org.apache.sshd.scp.common.helpers.ScpTimestampCommandDetails;
 
 /**
  * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
@@ -83,6 +84,6 @@ public interface ScpTargetStreamResolver {
      * @throws IOException If failed to post-process the incoming data
      */
     void postProcessReceivedData(
-            String name, boolean preserve, Set<PosixFilePermission> perms, ScpTimestamp time)
+            String name, boolean preserve, Set<PosixFilePermission> perms, ScpTimestampCommandDetails time)
             throws IOException;
 }
diff --git a/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/LocalFileScpSourceStreamResolver.java b/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/LocalFileScpSourceStreamResolver.java
index 3fca826..a501ad4 100644
--- a/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/LocalFileScpSourceStreamResolver.java
+++ b/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/LocalFileScpSourceStreamResolver.java
@@ -36,7 +36,6 @@ import org.apache.sshd.common.util.io.IoUtils;
 import org.apache.sshd.common.util.logging.AbstractLoggingBean;
 import org.apache.sshd.scp.common.ScpFileOpener;
 import org.apache.sshd.scp.common.ScpSourceStreamResolver;
-import org.apache.sshd.scp.common.ScpTimestamp;
 
 /**
  * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
@@ -47,7 +46,7 @@ public class LocalFileScpSourceStreamResolver extends AbstractLoggingBean implem
     protected final Path name;
     protected final Set<PosixFilePermission> perms;
     protected final long size;
-    protected final ScpTimestamp time;
+    protected final ScpTimestampCommandDetails time;
 
     public LocalFileScpSourceStreamResolver(Path path, ScpFileOpener opener) throws IOException {
         this.path = Objects.requireNonNull(path, "No path specified");
@@ -58,7 +57,7 @@ public class LocalFileScpSourceStreamResolver extends AbstractLoggingBean implem
         BasicFileAttributeView view = Files.getFileAttributeView(path, BasicFileAttributeView.class);
         BasicFileAttributes basic = view.readAttributes();
         this.size = basic.size();
-        this.time = new ScpTimestamp(basic.lastModifiedTime().toMillis(), basic.lastAccessTime().toMillis());
+        this.time = new ScpTimestampCommandDetails(basic.lastModifiedTime().toMillis(), basic.lastAccessTime().toMillis());
     }
 
     @Override
@@ -72,7 +71,7 @@ public class LocalFileScpSourceStreamResolver extends AbstractLoggingBean implem
     }
 
     @Override
-    public ScpTimestamp getTimestamp() throws IOException {
+    public ScpTimestampCommandDetails getTimestamp() throws IOException {
         return time;
     }
 
diff --git a/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/LocalFileScpTargetStreamResolver.java b/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/LocalFileScpTargetStreamResolver.java
index 523c7aa..c04aa04 100644
--- a/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/LocalFileScpTargetStreamResolver.java
+++ b/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/LocalFileScpTargetStreamResolver.java
@@ -39,7 +39,6 @@ import org.apache.sshd.common.util.io.IoUtils;
 import org.apache.sshd.common.util.logging.AbstractLoggingBean;
 import org.apache.sshd.scp.common.ScpFileOpener;
 import org.apache.sshd.scp.common.ScpTargetStreamResolver;
-import org.apache.sshd.scp.common.ScpTimestamp;
 
 /**
  * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
@@ -137,7 +136,7 @@ public class LocalFileScpTargetStreamResolver extends AbstractLoggingBean implem
 
     @Override
     public void postProcessReceivedData(
-            String name, boolean preserve, Set<PosixFilePermission> perms, ScpTimestamp time)
+            String name, boolean preserve, Set<PosixFilePermission> perms, ScpTimestampCommandDetails time)
             throws IOException {
         if (file == null) {
             throw new StreamCorruptedException(
@@ -150,7 +149,7 @@ public class LocalFileScpTargetStreamResolver extends AbstractLoggingBean implem
     }
 
     protected void updateFileProperties(
-            String name, Path path, Set<PosixFilePermission> perms, ScpTimestamp time)
+            String name, Path path, Set<PosixFilePermission> perms, ScpTimestampCommandDetails time)
             throws IOException {
         boolean traceEnabled = log.isTraceEnabled();
         if (traceEnabled) {
diff --git a/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/ScpDirEndCommandDetails.java b/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/ScpDirEndCommandDetails.java
index b1a638f..3075384 100644
--- a/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/ScpDirEndCommandDetails.java
+++ b/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/ScpDirEndCommandDetails.java
@@ -32,8 +32,36 @@ public class ScpDirEndCommandDetails extends AbstractScpCommandDetails {
         super(COMMAND_NAME);
     }
 
+    public ScpDirEndCommandDetails(String header) {
+        super(COMMAND_NAME);
+        if (!HEADER.equals(header)) {
+            throw new IllegalArgumentException("Mismatched header - expected '" + HEADER + "' but got '" + header + "'");
+        }
+    }
+
     @Override
     public String toHeader() {
         return HEADER;
     }
+
+    @Override
+    public int hashCode() {
+        return HEADER.hashCode();
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (obj == null) {
+            return false;
+        }
+        if (obj == this) {
+            return true;
+        }
+        if (getClass() != obj.getClass()) {
+            return false;
+        }
+
+        // All ScpDirEndCommandDetails are equal to each other
+        return true;
+    }
 }
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 b8e271c..0f95834 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
@@ -41,7 +41,6 @@ import org.apache.sshd.core.CoreModuleProperties;
 import org.apache.sshd.scp.ScpModuleProperties;
 import org.apache.sshd.scp.common.ScpException;
 import org.apache.sshd.scp.common.ScpReceiveLineHandler;
-import org.apache.sshd.scp.common.ScpTimestamp;
 import org.slf4j.Logger;
 
 /**
@@ -93,14 +92,14 @@ public final class ScpIoUtils {
      *
      * @param  in          The {@link InputStream} to read from
      * @param  out         The target {@link OutputStream}
-     * @param  time        The {@link ScpTimestamp} value to send
+     * @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, ScpTimestamp time, Logger log, Object logHint)
+            InputStream in, OutputStream out, ScpTimestampCommandDetails time, Logger log, Object logHint)
             throws IOException {
         String cmd = time.toHeader();
         if ((log != null) && log.isDebugEnabled()) {
@@ -201,7 +200,7 @@ public final class ScpIoUtils {
         ack(out);
 
         boolean debugEnabled = (log != null) && log.isDebugEnabled();
-        for (ScpTimestamp time = null;;) {
+        for (ScpTimestampCommandDetails time = null;;) {
             String line;
             boolean isDir = false;
             int c = readAck(in, true, log, logHint);
@@ -223,13 +222,13 @@ public final class ScpIoUtils {
                         log.debug("receive({}) - Received 'C' header: {}", logHint, line);
                     }
                     break;
-                case ScpTimestamp.COMMAND_NAME:
+                case ScpTimestampCommandDetails.COMMAND_NAME:
                     line = readLine(in);
                     line = Character.toString((char) c) + line;
                     if (debugEnabled) {
                         log.debug("receive({}) - Received 'T' header: {}", logHint, line);
                     }
-                    time = ScpTimestamp.parseTime(line);
+                    time = ScpTimestampCommandDetails.parseTime(line);
                     ack(out);
                     continue;
                 case ScpDirEndCommandDetails.COMMAND_NAME:
diff --git a/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/ScpPathCommandDetailsSupport.java b/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/ScpPathCommandDetailsSupport.java
index 5197656..b2a8dfc 100644
--- a/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/ScpPathCommandDetailsSupport.java
+++ b/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/ScpPathCommandDetailsSupport.java
@@ -22,9 +22,11 @@ package org.apache.sshd.scp.common.helpers;
 import java.nio.file.attribute.PosixFilePermission;
 import java.util.Collection;
 import java.util.EnumSet;
+import java.util.Objects;
 import java.util.Set;
 
 import org.apache.sshd.common.NamedResource;
+import org.apache.sshd.common.util.GenericUtils;
 import org.apache.sshd.common.util.ValidateUtils;
 
 /**
@@ -76,6 +78,10 @@ public abstract class ScpPathCommandDetailsSupport extends AbstractScpCommandDet
         return length;
     }
 
+    protected long getEffectiveLength() {
+        return getLength();
+    }
+
     public void setLength(long length) {
         this.length = length;
     }
@@ -91,7 +97,34 @@ public abstract class ScpPathCommandDetailsSupport extends AbstractScpCommandDet
 
     @Override
     public String toHeader() {
-        return getCommand() + getOctalPermissions(getPermissions()) + " " + getLength() + " " + getName();
+        return getCommand() + getOctalPermissions(getPermissions()) + " " + getEffectiveLength() + " " + getName();
+    }
+
+    @Override
+    public int hashCode() {
+        return Character.hashCode(getCommand())
+               + 31 * Objects.hashCode(getName())
+               + 37 * Long.hashCode(getEffectiveLength())
+               + 41 * GenericUtils.size(getPermissions());
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (obj == null) {
+            return false;
+        }
+        if (obj == this) {
+            return true;
+        }
+        if (getClass() != obj.getClass()) {
+            return false;
+        }
+
+        ScpPathCommandDetailsSupport other = (ScpPathCommandDetailsSupport) obj;
+        return (getCommand() == other.getCommand())
+                && (getEffectiveLength() == other.getEffectiveLength())
+                && Objects.equals(getName(), other.getName())
+                && GenericUtils.equals(getPermissions(), other.getPermissions());
     }
 
     @Override
diff --git a/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/ScpReceiveDirCommandDetails.java b/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/ScpReceiveDirCommandDetails.java
index f9f67ee..efcc77f 100644
--- a/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/ScpReceiveDirCommandDetails.java
+++ b/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/ScpReceiveDirCommandDetails.java
@@ -23,7 +23,7 @@ import org.apache.sshd.common.util.GenericUtils;
 
 /**
  * Holds the details of a &quot;Dmmmm <length> <directory>&quot; command - e.g., &quot;D0755 0 dirname&quot;
- * 
+ *
  * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
  */
 public class ScpReceiveDirCommandDetails extends ScpPathCommandDetailsSupport {
@@ -38,6 +38,11 @@ public class ScpReceiveDirCommandDetails extends ScpPathCommandDetailsSupport {
         super(COMMAND_NAME, header);
     }
 
+    @Override   // length is irrelevant for 'D' commands
+    protected long getEffectiveLength() {
+        return 0L;
+    }
+
     public static ScpReceiveDirCommandDetails parse(String header) {
         return GenericUtils.isEmpty(header) ? null : new ScpReceiveDirCommandDetails(header);
     }
diff --git a/sshd-scp/src/main/java/org/apache/sshd/scp/common/ScpTimestamp.java b/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/ScpTimestampCommandDetails.java
similarity index 70%
rename from sshd-scp/src/main/java/org/apache/sshd/scp/common/ScpTimestamp.java
rename to sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/ScpTimestampCommandDetails.java
index 23fcb5d..e1a085d 100644
--- a/sshd-scp/src/main/java/org/apache/sshd/scp/common/ScpTimestamp.java
+++ b/sshd-scp/src/main/java/org/apache/sshd/scp/common/helpers/ScpTimestampCommandDetails.java
@@ -17,27 +17,26 @@
  * under the License.
  */
 
-package org.apache.sshd.scp.common;
+package org.apache.sshd.scp.common.helpers;
 
 import java.nio.file.attribute.FileTime;
 import java.util.Date;
 import java.util.concurrent.TimeUnit;
 
 import org.apache.sshd.common.util.GenericUtils;
-import org.apache.sshd.scp.common.helpers.AbstractScpCommandDetails;
 
 /**
  * Represents an SCP timestamp definition
  *
  * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
  */
-public class ScpTimestamp extends AbstractScpCommandDetails {
+public class ScpTimestampCommandDetails extends AbstractScpCommandDetails {
     public static final char COMMAND_NAME = 'T';
 
     private final long lastModifiedTime;
     private final long lastAccessTime;
 
-    public ScpTimestamp(String header) {
+    public ScpTimestampCommandDetails(String header) {
         super(COMMAND_NAME);
 
         if (header.charAt(0) != COMMAND_NAME) {
@@ -49,11 +48,11 @@ public class ScpTimestamp extends AbstractScpCommandDetails {
         lastAccessTime = TimeUnit.SECONDS.toMillis(Long.parseLong(numbers[2]));
     }
 
-    public ScpTimestamp(FileTime modTime, FileTime accTime) {
+    public ScpTimestampCommandDetails(FileTime modTime, FileTime accTime) {
         this(modTime.to(TimeUnit.MILLISECONDS), accTime.to(TimeUnit.MILLISECONDS));
     }
 
-    public ScpTimestamp(long modTime, long accTime) {
+    public ScpTimestampCommandDetails(long modTime, long accTime) {
         super(COMMAND_NAME);
 
         lastModifiedTime = modTime;
@@ -71,7 +70,29 @@ public class ScpTimestamp extends AbstractScpCommandDetails {
     @Override
     public String toHeader() {
         return Character.toString(getCommand()) + TimeUnit.MILLISECONDS.toSeconds(getLastModifiedTime())
-               + " 0 " + TimeUnit.MILLISECONDS.toSeconds(getLastAccessTime()) + "0";
+               + " 0 " + TimeUnit.MILLISECONDS.toSeconds(getLastAccessTime()) + " 0";
+    }
+
+    @Override
+    public int hashCode() {
+        return Long.hashCode(getLastModifiedTime()) + 31 * Long.hashCode(getLastAccessTime());
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (obj == null) {
+            return false;
+        }
+        if (obj == this) {
+            return true;
+        }
+        if (getClass() != obj.getClass()) {
+            return false;
+        }
+
+        ScpTimestampCommandDetails other = (ScpTimestampCommandDetails) obj;
+        return (getLastModifiedTime() == other.getLastModifiedTime())
+                && (getLastAccessTime() == other.getLastAccessTime());
     }
 
     @Override
@@ -84,12 +105,13 @@ public class ScpTimestamp extends AbstractScpCommandDetails {
      * @param  line                  The time specification - format:
      *                               {@code T<mtime-sec> <mtime-micros> <atime-sec> <atime-micros>} where specified
      *                               times are in seconds since UTC - ignored if {@code null}
-     * @return                       The {@link ScpTimestamp} value with the timestamps converted to <U>milliseconds</U>
+     * @return                       The {@link ScpTimestampCommandDetails} value with the timestamps converted to
+     *                               <U>milliseconds</U>
      * @throws NumberFormatException if bad numerical values - <B>Note:</B> validates that 1st character is 'T'.
      * @see                          <A HREF="https://blogs.oracle.com/janp/entry/how_the_scp_protocol_works">How the
      *                               SCP protocol works</A>
      */
-    public static ScpTimestamp parseTime(String line) throws NumberFormatException {
-        return GenericUtils.isEmpty(line) ? null : new ScpTimestamp(line);
+    public static ScpTimestampCommandDetails parseTime(String line) throws NumberFormatException {
+        return GenericUtils.isEmpty(line) ? null : new ScpTimestampCommandDetails(line);
     }
 }
diff --git a/sshd-scp/src/test/java/org/apache/sshd/scp/client/ScpRemote2RemoteTransferHelperTest.java b/sshd-scp/src/test/java/org/apache/sshd/scp/client/ScpRemote2RemoteTransferHelperTest.java
index 7c0508b..f28739c 100644
--- a/sshd-scp/src/test/java/org/apache/sshd/scp/client/ScpRemote2RemoteTransferHelperTest.java
+++ b/sshd-scp/src/test/java/org/apache/sshd/scp/client/ScpRemote2RemoteTransferHelperTest.java
@@ -27,8 +27,8 @@ import java.util.concurrent.atomic.AtomicLong;
 import org.apache.sshd.client.session.ClientSession;
 import org.apache.sshd.common.util.io.IoUtils;
 import org.apache.sshd.scp.common.ScpHelper;
-import org.apache.sshd.scp.common.ScpTimestamp;
 import org.apache.sshd.scp.common.helpers.ScpReceiveFileCommandDetails;
+import org.apache.sshd.scp.common.helpers.ScpTimestampCommandDetails;
 import org.apache.sshd.util.test.CommonTestSupportUtils;
 import org.junit.BeforeClass;
 import org.junit.FixMethodOrder;
@@ -76,7 +76,7 @@ public class ScpRemote2RemoteTransferHelperTest extends AbstractScpTestSupport {
                         public void startDirectFileTransfer(
                                 ClientSession srcSession, String source,
                                 ClientSession dstSession, String destination,
-                                ScpTimestamp timestamp, ScpReceiveFileCommandDetails details)
+                                ScpTimestampCommandDetails timestamp, ScpReceiveFileCommandDetails details)
                                 throws IOException {
                             assertEquals("Mismatched start xfer source path", srcPath, source);
                             assertEquals("Mismatched start xfer destination path", dstPath, destination);
@@ -86,7 +86,7 @@ public class ScpRemote2RemoteTransferHelperTest extends AbstractScpTestSupport {
                         public void endDirectFileTransfer(
                                 ClientSession srcSession, String source,
                                 ClientSession dstSession, String destination,
-                                ScpTimestamp timestamp, ScpReceiveFileCommandDetails details,
+                                ScpTimestampCommandDetails timestamp, ScpReceiveFileCommandDetails details,
                                 long xferSize, Throwable thrown)
                                 throws IOException {
                             assertEquals("Mismatched end xfer source path", srcPath, source);
diff --git a/sshd-scp/src/test/java/org/apache/sshd/scp/common/helpers/AbstractScpCommandDetailsTest.java b/sshd-scp/src/test/java/org/apache/sshd/scp/common/helpers/AbstractScpCommandDetailsTest.java
new file mode 100644
index 0000000..8bfcfc7
--- /dev/null
+++ b/sshd-scp/src/test/java/org/apache/sshd/scp/common/helpers/AbstractScpCommandDetailsTest.java
@@ -0,0 +1,88 @@
+/*
+ * 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.lang.reflect.Constructor;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.apache.sshd.util.test.JUnit4ClassRunnerWithParametersFactory;
+import org.apache.sshd.util.test.JUnitTestSupport;
+import org.apache.sshd.util.test.NoIoTestCase;
+import org.junit.FixMethodOrder;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+import org.junit.runner.RunWith;
+import org.junit.runners.MethodSorters;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameters;
+import org.junit.runners.Parameterized.UseParametersRunnerFactory;
+
+/**
+ * @param  <C> Generic {@link AbstractScpCommandDetails} type
+ *
+ * @author     <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
+ */
+@FixMethodOrder(MethodSorters.NAME_ASCENDING)
+@Category({ NoIoTestCase.class })
+@RunWith(Parameterized.class) // see https://github.com/junit-team/junit/wiki/Parameterized-tests
+@UseParametersRunnerFactory(JUnit4ClassRunnerWithParametersFactory.class)
+public class AbstractScpCommandDetailsTest<C extends AbstractScpCommandDetails> extends JUnitTestSupport {
+    private final String header;
+    private final Constructor<C> ctor;
+
+    public AbstractScpCommandDetailsTest(String header, Class<C> cmdClass) throws Exception {
+        this.header = header;
+        this.ctor = cmdClass.getDeclaredConstructor(String.class);
+    }
+
+    @Parameters(name = "cmd={0}")
+    public static List<Object[]> parameters() {
+        return new ArrayList<Object[]>() {
+            // not serializing it
+            private static final long serialVersionUID = 1L;
+
+            {
+                addTestCase("T123456789 0 987654321 0", ScpTimestampCommandDetails.class);
+                addTestCase("C0644 12345 file", ScpReceiveFileCommandDetails.class);
+                addTestCase("D0755 0 dir", ScpReceiveDirCommandDetails.class);
+                addTestCase(ScpDirEndCommandDetails.HEADER, ScpDirEndCommandDetails.class);
+            }
+
+            private void addTestCase(String header, Class<? extends AbstractScpCommandDetails> cmdClass) {
+                add(new Object[] { header, cmdClass });
+            }
+        };
+    }
+
+    @Test
+    public void testHeaderEquality() throws Exception {
+        C details = ctor.newInstance(header);
+        assertEquals(header, details.toHeader());
+    }
+
+    @Test
+    public void testDetailsEquality() throws Exception {
+        C d1 = ctor.newInstance(header);
+        C d2 = ctor.newInstance(header);
+        assertEquals("HASH ?", d1.hashCode(), d2.hashCode());
+        assertEquals("EQ ?", d1, d2);
+    }
+}
diff --git a/sshd-scp/src/test/java/org/apache/sshd/scp/server/ScpReceiveDirCommandDetailsTest.java b/sshd-scp/src/test/java/org/apache/sshd/scp/server/ScpReceiveDirCommandDetailsTest.java
new file mode 100644
index 0000000..78bccf3
--- /dev/null
+++ b/sshd-scp/src/test/java/org/apache/sshd/scp/server/ScpReceiveDirCommandDetailsTest.java
@@ -0,0 +1,49 @@
+/*
+ * 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.server;
+
+import org.apache.sshd.scp.common.helpers.ScpReceiveDirCommandDetails;
+import org.apache.sshd.util.test.JUnitTestSupport;
+import org.apache.sshd.util.test.NoIoTestCase;
+import org.junit.FixMethodOrder;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+import org.junit.runners.MethodSorters;
+
+/**
+ * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
+ */
+@FixMethodOrder(MethodSorters.NAME_ASCENDING)
+@Category({ NoIoTestCase.class })
+public class ScpReceiveDirCommandDetailsTest extends JUnitTestSupport {
+    public ScpReceiveDirCommandDetailsTest() {
+        super();
+    }
+
+    @Test
+    public void testLengthDoesNotInfluenceEquality() {
+        ScpReceiveDirCommandDetails d1 = new ScpReceiveDirCommandDetails("D0555 0 " + getCurrentTestName());
+        ScpReceiveDirCommandDetails d2 = new ScpReceiveDirCommandDetails(d1.toHeader());
+        d2.setLength(d1.getLength() + 1234L);
+        assertNotEquals("Len ?", d1.getLength(), d2.getLength());
+        assertEquals("Hash ?", d1.hashCode(), d2.hashCode());
+        assertEquals("EQ", d1, d2);
+    }
+}


[mina-sshd] 01/06: Added support for 'keeaplive@*' global request pattern

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

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

commit faa087e8dda07d0c34ae5bd4a05da9ece79287c0
Author: Lyor Goldstein <lg...@apache.org>
AuthorDate: Mon Aug 17 12:08:35 2020 +0300

    Added support for 'keeaplive@*' global request pattern
---
 .../src/main/java/org/apache/sshd/server/global/KeepAliveHandler.java  | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/sshd-core/src/main/java/org/apache/sshd/server/global/KeepAliveHandler.java b/sshd-core/src/main/java/org/apache/sshd/server/global/KeepAliveHandler.java
index 5ee1f2c..0b67283 100644
--- a/sshd-core/src/main/java/org/apache/sshd/server/global/KeepAliveHandler.java
+++ b/sshd-core/src/main/java/org/apache/sshd/server/global/KeepAliveHandler.java
@@ -40,7 +40,8 @@ public class KeepAliveHandler extends AbstractConnectionServiceRequestHandler {
     public Result process(
             ConnectionService connectionService, String request, boolean wantReply, Buffer buffer)
             throws Exception {
-        if (!request.startsWith("keepalive@")) {
+        // some clients use different strings - e.g., keep-alive@bitvise.com, keepalive@putty.projects.tartarus.org
+        if ((!request.startsWith("keepalive@")) && (!request.startsWith("keep-alive@"))) {
             return super.process(connectionService, request, wantReply, buffer);
         }
 


[mina-sshd] 05/06: [SSHD-1005] Added ScpTransferHelper support

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

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

commit 2be166000b540313337e3c8f9c3d61af74611284
Author: Lyor Goldstein <lg...@apache.org>
AuthorDate: Fri Aug 14 18:57:40 2020 +0300

    [SSHD-1005] Added ScpTransferHelper support
---
 CHANGES.md                                         |   1 +
 docs/scp.md                                        |  15 ++
 .../scp/client/ScpRemote2RemoteTransferHelper.java | 254 +++++++++++++++++++++
 .../client/ScpRemote2RemoteTransferListener.java   |  68 ++++++
 .../org/apache/sshd/scp/server/ScpCommand.java     |   2 +-
 .../sshd/scp/client/AbstractScpTestSupport.java    | 158 +++++++++++++
 .../client/ScpRemote2RemoteTransferHelperTest.java | 124 ++++++++++
 .../java/org/apache/sshd/scp/client/ScpTest.java   | 190 ++-------------
 8 files changed, 644 insertions(+), 168 deletions(-)

diff --git a/CHANGES.md b/CHANGES.md
index bd4bae8..33ba98d 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -31,6 +31,7 @@ or `-key-file` command line option.
 * [SSHD-1004](https://issues.apache.org/jira/browse/SSHD-1004) Deprecate DES, RC4 and Blowfish ciphers from default setup.
 * [SSHD-1004](https://issues.apache.org/jira/browse/SSHD-1004) Deprecate SHA-1 based key exchanges and signatures from default setup.
 * [SSHD-1004](https://issues.apache.org/jira/browse/SSHD-1004) Deprecate MD5-based and truncated HMAC algorithms from default setup.
+* [SSHD-1005](https://issues.apache.org/jira/browse/SSHD-1005) Added support for SCP remote-to-remote file transfer
 * [SSHD-1020](https://issues.apache.org/jira/browse/SSHD-1020) SSH connections getting closed abruptly with timeout exceptions.
 * [SSHD-1026](https://issues.apache.org/jira/browse/SSHD-1026) Improve build reproductibility.
 * [SSHD-1028](https://issues.apache.org/jira/browse/SSHD-1028) Fix SSH_MSG_DISCONNECT: Too many concurrent connections.
diff --git a/docs/scp.md b/docs/scp.md
index a91a09d..499241c 100644
--- a/docs/scp.md
+++ b/docs/scp.md
@@ -172,6 +172,21 @@ is likely to require. For this purpose, the `ScpCommandFactory` also implements
 
 **Note:** a similar result can be achieved if activating SSHD from the command line by specifying `-o ShellFactory=scp`
 
+## Remote-to-remote transfer
+
+The code provides an `ScpTransferHelper` class that enables copying files between 2 remote accounts without going through
+the local file system.
+
+```java
+    ClientSession src = ...;
+    ClientSession dst = ...;
+    // Can also provide a listener in the constructor in order to be informed of the actual transfer progress
+    ScpRemote2RemoteTransferHelper helper = new ScpRemote2RemoteTransferHelper(src, dst);
+    // can be repeated for as many files as necessary using the same helper
+    helper.transferFile("remote/src/path/file1", "remote/dst/path/file2");
+    
+```
+
 ## References
 
 * [How the SCP protocol works](https://chuacw.ath.cx/development/b/chuacw/archive/2019/02/04/how-the-scp-protocol-works.aspx)
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
new file mode 100644
index 0000000..1b4852a
--- /dev/null
+++ b/sshd-scp/src/main/java/org/apache/sshd/scp/client/ScpRemote2RemoteTransferHelper.java
@@ -0,0 +1,254 @@
+/*
+ * 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.client;
+
+import java.io.EOFException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.StreamCorruptedException;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.Objects;
+
+import org.apache.sshd.client.channel.ChannelExec;
+import org.apache.sshd.client.session.ClientSession;
+import org.apache.sshd.common.util.io.IoUtils;
+import org.apache.sshd.common.util.io.LimitInputStream;
+import org.apache.sshd.common.util.logging.AbstractLoggingBean;
+import org.apache.sshd.scp.client.ScpClient.Option;
+import org.apache.sshd.scp.common.ScpTimestamp;
+import org.apache.sshd.scp.common.helpers.AbstractScpCommandDetails;
+import org.apache.sshd.scp.common.helpers.ScpIoUtils;
+import org.apache.sshd.scp.common.helpers.ScpReceiveFileCommandDetails;
+
+/**
+ * Helps transfer files between 2 servers rather than between server and local file system by using 2
+ * {@link ClientSession}-s and simply copying from one server to the other
+ *
+ * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
+ */
+public class ScpRemote2RemoteTransferHelper extends AbstractLoggingBean {
+    protected final ScpRemote2RemoteTransferListener listener;
+
+    private final ClientSession sourceSession;
+    private final ClientSession destSession;
+
+    public ScpRemote2RemoteTransferHelper(ClientSession sourceSession, ClientSession destSession) {
+        this(sourceSession, destSession, null);
+    }
+
+    /**
+     * @param sourceSession The source {@link ClientSession}
+     * @param destSession   The destination {@link ClientSession}
+     * @param listener      An optional {@link ScpRemote2RemoteTransferListener}
+     */
+    public ScpRemote2RemoteTransferHelper(ClientSession sourceSession, ClientSession destSession,
+                                          ScpRemote2RemoteTransferListener listener) {
+        this.sourceSession = Objects.requireNonNull(sourceSession, "No source session provided");
+        this.destSession = Objects.requireNonNull(destSession, "No destination session provided");
+        this.listener = listener;
+    }
+
+    public ClientSession getSourceSession() {
+        return sourceSession;
+    }
+
+    public ClientSession getDestinationSession() {
+        return destSession;
+    }
+
+    /**
+     * Transfers a single file
+     *
+     * @param  source             Source path in the source session
+     * @param  destination        Destination path in the destination session
+     * @param  preserveAttributes Whether to preserve the attributes of the transferred file (e.g., permissions, file
+     *                            associated timestamps, etc.)
+     * @throws IOException        If failed to transfer
+     */
+    public void transferFile(String source, String destination, boolean preserveAttributes) throws IOException {
+        Collection<Option> options = preserveAttributes
+                ? Collections.unmodifiableSet(EnumSet.of(Option.PreserveAttributes))
+                : Collections.emptySet();
+        String srcCmd = ScpClient.createReceiveCommand(source, options);
+        ClientSession srcSession = getSourceSession();
+        ClientSession dstSession = getDestinationSession();
+        boolean debugEnabled = log.isDebugEnabled();
+        if (debugEnabled) {
+            log.debug("transferFile({})[srcCmd='{}']) {} => {}",
+                    this, srcCmd, source, destination);
+        }
+
+        ChannelExec srcChannel = ScpIoUtils.openCommandChannel(srcSession, srcCmd, log);
+        try (InputStream srcIn = srcChannel.getInvertedOut();
+             OutputStream srcOut = srcChannel.getInvertedIn()) {
+            String dstCmd = ScpClient.createSendCommand(destination, options);
+            if (debugEnabled) {
+                log.debug("transferFile({})[dstCmd='{}'} {} => {}",
+                        this, dstCmd, source, destination);
+            }
+
+            ChannelExec dstChannel = ScpIoUtils.openCommandChannel(dstSession, dstCmd, log);
+            try (InputStream dstIn = dstChannel.getInvertedOut();
+                 OutputStream dstOut = dstChannel.getInvertedIn()) {
+                redirectReceivedFile(source, srcIn, srcOut, destination, dstIn, dstOut);
+            } finally {
+                dstChannel.close(false);
+            }
+        } finally {
+            srcChannel.close(false);
+        }
+    }
+
+    protected long redirectReceivedFile(
+            String source, InputStream srcIn, OutputStream srcOut,
+            String destination, InputStream dstIn, OutputStream dstOut)
+            throws IOException {
+        int statusCode = transferStatusCode("XFER-FILE", dstIn, srcOut);
+        ScpIoUtils.validateCommandStatusCode("XFER-FILE", "redirectReceivedFile", statusCode, false);
+
+        boolean debugEnabled = log.isDebugEnabled();
+        String header = ScpIoUtils.readLine(srcIn, false);
+        if (debugEnabled) {
+            log.debug("redirectReceivedFile({}) header={}", this, header);
+        }
+
+        char cmdName = header.charAt(0);
+        ScpTimestamp time = null;
+        if (cmdName == ScpTimestamp.COMMAND_NAME) {
+            // Pass along the "T<mtime> 0 <atime> 0" and wait for response
+            time = ScpTimestamp.parseTime(header);
+            signalReceivedCommand(time);
+
+            ScpIoUtils.writeLine(dstOut, header);
+            statusCode = transferStatusCode(header, dstIn, srcOut);
+            ScpIoUtils.validateCommandStatusCode("[DST] " + header, "redirectReceivedFile", statusCode, false);
+
+            // Read the next command - which must be a 'C' command
+            header = ScpIoUtils.readLine(srcIn, false);
+            if (debugEnabled) {
+                log.debug("redirectReceivedFile({}) header={}", this, header);
+            }
+
+            cmdName = header.charAt(0);
+        }
+
+        if (cmdName != ScpReceiveFileCommandDetails.COMMAND_NAME) {
+            throw new StreamCorruptedException("Unexpected file command: " + header);
+        }
+
+        ScpReceiveFileCommandDetails details = new ScpReceiveFileCommandDetails(header);
+        signalReceivedCommand(details);
+
+        // Pass along the "Cmmmm <length> <filename" command and wait for ACK
+        ScpIoUtils.writeLine(dstOut, header);
+        statusCode = transferStatusCode(header, dstIn, srcOut);
+        ScpIoUtils.validateCommandStatusCode("[DST] " + header, "redirectReceivedFile", statusCode, false);
+        // Wait with ACK ready for transfer until ready to transfer data
+        long xferCount = transferFileData(source, srcIn, srcOut, destination, dstIn, dstOut, time, details);
+
+        // wait for source to signal data finished and pass it along
+        statusCode = transferStatusCode("SRC-EOF", srcIn, dstOut);
+        ScpIoUtils.validateCommandStatusCode("[SRC-EOF] " + header, "redirectReceivedFile", statusCode, false);
+
+        // wait for destination to signal data received
+        statusCode = ScpIoUtils.readAck(dstIn, false, log, "DST-EOF");
+        ScpIoUtils.validateCommandStatusCode("[DST-EOF] " + header, "redirectReceivedFile", statusCode, false);
+        return xferCount;
+    }
+
+    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");
+        }
+
+        if (statusCode != ScpIoUtils.OK) {
+            String line = ScpIoUtils.readLine(in);
+            if (log.isDebugEnabled()) {
+                log.debug("transferStatusCode({})[{}] status={}, line='{}'", this, logHint, statusCode, line);
+            }
+            out.write(statusCode);
+            ScpIoUtils.writeLine(out, line);
+        } else {
+            if (log.isDebugEnabled()) {
+                log.debug("transferStatusCode({})[{}] status={}", this, logHint, statusCode);
+            }
+            out.write(statusCode);
+            out.flush();
+        }
+
+        return statusCode;
+    }
+
+    protected long transferFileData(
+            String source, InputStream srcIn, OutputStream srcOut,
+            String destination, InputStream dstIn, OutputStream dstOut,
+            ScpTimestamp time, ScpReceiveFileCommandDetails details)
+            throws IOException {
+        long length = details.getLength();
+        if (length < 0L) { // TODO consider throwing an exception...
+            log.warn("transferFileData({})[{} => {}] bad length in header: {}",
+                    this, source, destination, details.toHeader());
+        }
+
+        ClientSession srcSession = getSourceSession();
+        ClientSession dstSession = getDestinationSession();
+        if (listener != null) {
+            listener.startDirectFileTransfer(srcSession, source, dstSession, destination, time, details);
+        }
+
+        long xferCount;
+        try (InputStream inputStream = new LimitInputStream(srcIn, length)) {
+            ScpIoUtils.ack(srcOut); // ready to receive the data from source
+            xferCount = IoUtils.copy(inputStream, dstOut);
+            dstOut.flush(); // make sure all data sent to destination
+        } catch (IOException | RuntimeException | Error e) {
+            if (listener != null) {
+                listener.endDirectFileTransfer(srcSession, source, dstSession, destination, time, details, 0L, e);
+            }
+            throw e;
+        }
+
+        if (log.isDebugEnabled()) {
+            log.debug("transferFileData({})[{} => {}] xfer {}/{} for {}",
+                    this, source, destination, xferCount, length, details.getName());
+        }
+        if (listener != null) {
+            listener.endDirectFileTransfer(srcSession, source, dstSession, destination, time, details, xferCount, null);
+        }
+
+        return xferCount;
+    }
+
+    // Useful "hook" for implementors
+    protected void signalReceivedCommand(AbstractScpCommandDetails details) throws IOException {
+        if (log.isDebugEnabled()) {
+            log.debug("signalReceivedCommand({}) {}", this, details.toHeader());
+        }
+    }
+
+    @Override
+    public String toString() {
+        return getClass().getSimpleName() + "[src=" + getSourceSession() + ",dst=" + getDestinationSession() + "]";
+    }
+}
diff --git a/sshd-scp/src/main/java/org/apache/sshd/scp/client/ScpRemote2RemoteTransferListener.java b/sshd-scp/src/main/java/org/apache/sshd/scp/client/ScpRemote2RemoteTransferListener.java
new file mode 100644
index 0000000..1322495
--- /dev/null
+++ b/sshd-scp/src/main/java/org/apache/sshd/scp/client/ScpRemote2RemoteTransferListener.java
@@ -0,0 +1,68 @@
+/*
+ * 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.client;
+
+import java.io.IOException;
+
+import org.apache.sshd.client.session.ClientSession;
+import org.apache.sshd.scp.common.ScpTimestamp;
+import org.apache.sshd.scp.common.helpers.ScpReceiveFileCommandDetails;
+
+/**
+ * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
+ */
+public interface ScpRemote2RemoteTransferListener {
+    /**
+     * Indicates start of direct file transfer
+     *
+     * @param  srcSession  The source {@link ClientSession}
+     * @param  source      The source path
+     * @param  dstSession  The destination {@link ClientSession}
+     * @param  destination The destination path
+     * @param  timestamp   The {@link ScpTimestamp timestamp} of the file - may be {@code null}
+     * @param  details     The {@link ScpReceiveFileCommandDetails details} of the attempted file transfer
+     * @throws IOException If failed to handle the callback
+     */
+    void startDirectFileTransfer(
+            ClientSession srcSession, String source,
+            ClientSession dstSession, String destination,
+            ScpTimestamp timestamp, ScpReceiveFileCommandDetails details)
+            throws IOException;
+
+    /**
+     * Indicates end of direct file transfer
+     *
+     * @param  srcSession  The source {@link ClientSession}
+     * @param  source      The source path
+     * @param  dstSession  The destination {@link ClientSession}
+     * @param  destination The destination path
+     * @param  timestamp   The {@link ScpTimestamp timestamp} of the file - may be {@code null}
+     * @param  details     The {@link ScpReceiveFileCommandDetails details} of the attempted file transfer
+     * @param  xferSize    Number of successfully transfered bytes - zero if <tt>thrown</tt> not {@code null}
+     * @param  thrown      Error thrown during transfer attempt - {@code null} if successful
+     * @throws IOException If failed to handle the callback
+     */
+    void endDirectFileTransfer(
+            ClientSession srcSession, String source,
+            ClientSession dstSession, String destination,
+            ScpTimestamp timestamp, ScpReceiveFileCommandDetails details,
+            long xferSize, Throwable thrown)
+            throws IOException;
+}
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 fcaf577..4cae4e7 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
@@ -190,7 +190,7 @@ public class ScpCommand extends AbstractFileSystemCommand {
                 writeCommandResponseMessage(command, exitValue, exitMessage);
             } catch (IOException e2) {
                 log.error("run({})[{}] Failed ({}) to send error response: {}",
-                        session, command, e.getClass().getSimpleName(), e.getMessage());
+                        session, command, e2.getClass().getSimpleName(), e2.getMessage());
                 if (debugEnabled) {
                     log.error("run(" + session + ")[" + command + "] error response failure details", e2);
                 }
diff --git a/sshd-scp/src/test/java/org/apache/sshd/scp/client/AbstractScpTestSupport.java b/sshd-scp/src/test/java/org/apache/sshd/scp/client/AbstractScpTestSupport.java
new file mode 100644
index 0000000..eb058b2
--- /dev/null
+++ b/sshd-scp/src/test/java/org/apache/sshd/scp/client/AbstractScpTestSupport.java
@@ -0,0 +1,158 @@
+/*
+ * 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.client;
+
+import java.nio.file.Path;
+import java.nio.file.attribute.PosixFilePermission;
+import java.util.Collection;
+import java.util.Set;
+
+import org.apache.sshd.client.SshClient;
+import org.apache.sshd.client.session.ClientSession;
+import org.apache.sshd.common.config.keys.KeyUtils;
+import org.apache.sshd.common.file.FileSystemFactory;
+import org.apache.sshd.common.file.virtualfs.VirtualFileSystemFactory;
+import org.apache.sshd.common.session.Session;
+import org.apache.sshd.scp.common.ScpTransferEventListener;
+import org.apache.sshd.scp.server.ScpCommandFactory;
+import org.apache.sshd.server.SshServer;
+import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider;
+import org.apache.sshd.util.test.BaseTestSupport;
+import org.apache.sshd.util.test.CommonTestSupportUtils;
+import org.apache.sshd.util.test.CoreTestSupportUtils;
+import org.junit.AfterClass;
+import org.junit.Before;
+
+/**
+ * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
+ */
+public abstract class AbstractScpTestSupport extends BaseTestSupport {
+    protected static final ScpTransferEventListener DEBUG_LISTENER = new ScpTransferEventListener() {
+        @Override
+        public void startFolderEvent(
+                Session s, FileOperation op, Path file, Set<PosixFilePermission> perms) {
+            logEvent("starFolderEvent", s, op, file, false, -1L, perms, null);
+        }
+
+        @Override
+        public void startFileEvent(
+                Session s, FileOperation op, Path file, long length, Set<PosixFilePermission> perms) {
+            logEvent("startFileEvent", s, op, file, true, length, perms, null);
+        }
+
+        @Override
+        public void endFolderEvent(
+                Session s, FileOperation op, Path file, Set<PosixFilePermission> perms, Throwable thrown) {
+            logEvent("endFolderEvent", s, op, file, false, -1L, perms, thrown);
+        }
+
+        @Override
+        public void endFileEvent(
+                Session s, FileOperation op, Path file, long length, Set<PosixFilePermission> perms, Throwable thrown) {
+            logEvent("endFileEvent", s, op, file, true, length, perms, thrown);
+        }
+
+        private void logEvent(
+                String type, Session s, FileOperation op, Path path, boolean isFile,
+                long length, Collection<PosixFilePermission> perms, Throwable t) {
+            if (!OUTPUT_DEBUG_MESSAGES) {
+                return; // just in case
+            }
+            StringBuilder sb = new StringBuilder(Byte.MAX_VALUE);
+            sb.append("    ").append(type)
+                    .append('[').append(s).append(']')
+                    .append('[').append(op).append(']')
+                    .append(' ').append(isFile ? "File" : "Directory").append('=').append(path)
+                    .append(' ').append("length=").append(length)
+                    .append(' ').append("perms=").append(perms);
+            if (t != null) {
+                sb.append(' ').append("ERROR=").append(t.getClass().getSimpleName()).append(": ").append(t.getMessage());
+            }
+            outputDebugMessage(sb.toString());
+        }
+    };
+
+    protected static SshServer sshd;
+    protected static int port;
+    protected static SshClient client;
+
+    protected final FileSystemFactory fileSystemFactory;
+
+    protected AbstractScpTestSupport() {
+        Path targetPath = detectTargetFolder();
+        Path parentPath = targetPath.getParent();
+        fileSystemFactory = new VirtualFileSystemFactory(parentPath);
+    }
+
+    protected static void setupClientAndServer(Class<?> anchor) throws Exception {
+        // Need to use RSA since Ganymede/Jsch does not support EC
+        SimpleGeneratorHostKeyProvider provider = new SimpleGeneratorHostKeyProvider();
+        provider.setAlgorithm(KeyUtils.RSA_ALGORITHM);
+        provider.setKeySize(1024);
+
+        Path targetDir = CommonTestSupportUtils.detectTargetFolder(anchor);
+        provider.setPath(targetDir.resolve(anchor.getSimpleName() + "-key"));
+        sshd = CoreTestSupportUtils.setupTestFullSupportServer(anchor);
+        sshd.setKeyPairProvider(provider);
+
+        ScpCommandFactory factory = new ScpCommandFactory();
+        sshd.setCommandFactory(factory);
+        sshd.setShellFactory(factory);
+        sshd.start();
+        port = sshd.getPort();
+
+        client = CoreTestSupportUtils.setupTestFullSupportClient(anchor);
+        client.start();
+    }
+
+    @AfterClass
+    public static void tearDownClientAndServer() throws Exception {
+        if (sshd != null) {
+            try {
+                sshd.stop(true);
+            } finally {
+                sshd = null;
+            }
+        }
+
+        if (client != null) {
+            try {
+                client.stop();
+            } finally {
+                client = null;
+            }
+        }
+    }
+
+    protected static ScpTransferEventListener getScpTransferEventListener(ClientSession session) {
+        return OUTPUT_DEBUG_MESSAGES ? DEBUG_LISTENER : ScpTransferEventListener.EMPTY;
+    }
+
+    protected static ScpClient createScpClient(ClientSession session) {
+        ScpClientCreator creator = ScpClientCreator.instance();
+        ScpTransferEventListener listener = getScpTransferEventListener(session);
+        return creator.createScpClient(session, listener);
+    }
+
+    @Before
+    public void setUp() throws Exception {
+        sshd.setFileSystemFactory(fileSystemFactory);
+    }
+}
diff --git a/sshd-scp/src/test/java/org/apache/sshd/scp/client/ScpRemote2RemoteTransferHelperTest.java b/sshd-scp/src/test/java/org/apache/sshd/scp/client/ScpRemote2RemoteTransferHelperTest.java
new file mode 100644
index 0000000..7c0508b
--- /dev/null
+++ b/sshd-scp/src/test/java/org/apache/sshd/scp/client/ScpRemote2RemoteTransferHelperTest.java
@@ -0,0 +1,124 @@
+/*
+ * 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.client;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.concurrent.atomic.AtomicLong;
+
+import org.apache.sshd.client.session.ClientSession;
+import org.apache.sshd.common.util.io.IoUtils;
+import org.apache.sshd.scp.common.ScpHelper;
+import org.apache.sshd.scp.common.ScpTimestamp;
+import org.apache.sshd.scp.common.helpers.ScpReceiveFileCommandDetails;
+import org.apache.sshd.util.test.CommonTestSupportUtils;
+import org.junit.BeforeClass;
+import org.junit.FixMethodOrder;
+import org.junit.Test;
+import org.junit.runners.MethodSorters;
+
+/**
+ * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
+ */
+@FixMethodOrder(MethodSorters.NAME_ASCENDING)
+public class ScpRemote2RemoteTransferHelperTest extends AbstractScpTestSupport {
+    public ScpRemote2RemoteTransferHelperTest() {
+        super();
+    }
+
+    @BeforeClass
+    public static void setupClientAndServer() throws Exception {
+        setupClientAndServer(ScpRemote2RemoteTransferHelperTest.class);
+    }
+
+    @Test
+    public void testTransferFiles() throws Exception {
+        Path targetPath = detectTargetFolder();
+        Path parentPath = targetPath.getParent();
+        Path scpRoot = CommonTestSupportUtils.resolve(targetPath,
+                ScpHelper.SCP_COMMAND_PREFIX, getClass().getSimpleName(), getCurrentTestName());
+        CommonTestSupportUtils.deleteRecursive(scpRoot);    // start clean
+
+        Path srcDir = assertHierarchyTargetFolderExists(scpRoot.resolve("srcdir"));
+        Path srcFile = srcDir.resolve("source.txt");
+        byte[] expectedData
+                = CommonTestSupportUtils.writeFile(srcFile, getClass().getName() + "#" + getCurrentTestName() + IoUtils.EOL);
+        String srcPath = CommonTestSupportUtils.resolveRelativeRemotePath(parentPath, srcFile);
+
+        Path dstDir = assertHierarchyTargetFolderExists(scpRoot.resolve("dstdir"));
+        Path dstFile = dstDir.resolve("destination.txt");
+        String dstPath = CommonTestSupportUtils.resolveRelativeRemotePath(parentPath, dstFile);
+
+        AtomicLong xferCount = new AtomicLong();
+        try (ClientSession srcSession = createClientSession(getCurrentTestName() + "-src");
+             ClientSession dstSession = createClientSession(getCurrentTestName() + "-dst")) {
+            ScpRemote2RemoteTransferHelper helper = new ScpRemote2RemoteTransferHelper(
+                    srcSession, dstSession, new ScpRemote2RemoteTransferListener() {
+                        @Override
+                        public void startDirectFileTransfer(
+                                ClientSession srcSession, String source,
+                                ClientSession dstSession, String destination,
+                                ScpTimestamp timestamp, ScpReceiveFileCommandDetails details)
+                                throws IOException {
+                            assertEquals("Mismatched start xfer source path", srcPath, source);
+                            assertEquals("Mismatched start xfer destination path", dstPath, destination);
+                        }
+
+                        @Override
+                        public void endDirectFileTransfer(
+                                ClientSession srcSession, String source,
+                                ClientSession dstSession, String destination,
+                                ScpTimestamp timestamp, ScpReceiveFileCommandDetails details,
+                                long xferSize, Throwable thrown)
+                                throws IOException {
+                            assertEquals("Mismatched end xfer source path", srcPath, source);
+                            assertEquals("Mismatched end xfer destination path", dstPath, destination);
+
+                            long prev = xferCount.getAndSet(xferSize);
+                            assertEquals("Mismatched 1st end file xfer size", 0L, prev);
+                        }
+                    });
+            helper.transferFile(srcPath, dstPath, true);
+        }
+        assertEquals("Mismatched transfer size", expectedData.length, xferCount.getAndSet(0L));
+
+        byte[] actualData = Files.readAllBytes(dstFile);
+        assertArrayEquals("Mismatched transfer contents", expectedData, actualData);
+    }
+
+    private ClientSession createClientSession(String username) throws IOException {
+        ClientSession session = client.connect(username, TEST_LOCALHOST, port)
+                .verify(CONNECT_TIMEOUT)
+                .getSession();
+        try {
+            session.addPasswordIdentity(username);
+            session.auth().verify(AUTH_TIMEOUT);
+
+            ClientSession result = session;
+            session = null; // avoid auto-close at finally clause
+            return result;
+        } finally {
+            if (session != null) {
+                session.close();
+            }
+        }
+    }
+}
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 788c0fa..0d5583b 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
@@ -18,7 +18,6 @@
  */
 package org.apache.sshd.scp.client;
 
-import java.io.ByteArrayOutputStream;
 import java.io.File;
 import java.io.IOException;
 import java.io.InputStream;
@@ -40,12 +39,15 @@ import java.util.Set;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
 
-import org.apache.sshd.client.SshClient;
+import ch.ethz.ssh2.Connection;
+import ch.ethz.ssh2.ConnectionInfo;
+import ch.ethz.ssh2.SCPClient;
+import com.jcraft.jsch.ChannelExec;
+import com.jcraft.jsch.JSch;
+import com.jcraft.jsch.JSchException;
 import org.apache.sshd.client.session.ClientSession;
 import org.apache.sshd.common.Factory;
 import org.apache.sshd.common.channel.Channel;
-import org.apache.sshd.common.config.keys.KeyUtils;
-import org.apache.sshd.common.file.FileSystemFactory;
 import org.apache.sshd.common.file.virtualfs.VirtualFileSystemFactory;
 import org.apache.sshd.common.io.BuiltinIoServiceFactoryFactories;
 import org.apache.sshd.common.random.Random;
@@ -67,91 +69,25 @@ import org.apache.sshd.scp.common.helpers.ScpReceiveDirCommandDetails;
 import org.apache.sshd.scp.common.helpers.ScpReceiveFileCommandDetails;
 import org.apache.sshd.scp.server.ScpCommand;
 import org.apache.sshd.scp.server.ScpCommandFactory;
-import org.apache.sshd.server.SshServer;
 import org.apache.sshd.server.channel.ChannelSession;
 import org.apache.sshd.server.command.Command;
-import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider;
-import org.apache.sshd.util.test.BaseTestSupport;
 import org.apache.sshd.util.test.CommonTestSupportUtils;
-import org.apache.sshd.util.test.CoreTestSupportUtils;
 import org.apache.sshd.util.test.JSchLogger;
 import org.apache.sshd.util.test.SimpleUserInfo;
-import org.junit.AfterClass;
-import org.junit.Before;
 import org.junit.BeforeClass;
 import org.junit.FixMethodOrder;
 import org.junit.Test;
 import org.junit.runners.MethodSorters;
 
-import com.jcraft.jsch.ChannelExec;
-import com.jcraft.jsch.JSch;
-import com.jcraft.jsch.JSchException;
-
-import ch.ethz.ssh2.Connection;
-import ch.ethz.ssh2.ConnectionInfo;
-import ch.ethz.ssh2.SCPClient;
-
 /**
  * Test for SCP support.
  *
  * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
  */
 @FixMethodOrder(MethodSorters.NAME_ASCENDING)
-public class ScpTest extends BaseTestSupport {
-    private static final ScpTransferEventListener DEBUG_LISTENER = new ScpTransferEventListener() {
-        @Override
-        public void startFolderEvent(
-                Session s, FileOperation op, Path file, Set<PosixFilePermission> perms) {
-            logEvent("starFolderEvent", s, op, file, false, -1L, perms, null);
-        }
-
-        @Override
-        public void startFileEvent(
-                Session s, FileOperation op, Path file, long length, Set<PosixFilePermission> perms) {
-            logEvent("startFileEvent", s, op, file, true, length, perms, null);
-        }
-
-        @Override
-        public void endFolderEvent(
-                Session s, FileOperation op, Path file, Set<PosixFilePermission> perms, Throwable thrown) {
-            logEvent("endFolderEvent", s, op, file, false, -1L, perms, thrown);
-        }
-
-        @Override
-        public void endFileEvent(
-                Session s, FileOperation op, Path file, long length, Set<PosixFilePermission> perms, Throwable thrown) {
-            logEvent("endFileEvent", s, op, file, true, length, perms, thrown);
-        }
-
-        private void logEvent(
-                String type, Session s, FileOperation op, Path path, boolean isFile,
-                long length, Collection<PosixFilePermission> perms, Throwable t) {
-            if (!OUTPUT_DEBUG_MESSAGES) {
-                return; // just in case
-            }
-            StringBuilder sb = new StringBuilder(Byte.MAX_VALUE);
-            sb.append("    ").append(type)
-                    .append('[').append(s).append(']')
-                    .append('[').append(op).append(']')
-                    .append(' ').append(isFile ? "File" : "Directory").append('=').append(path)
-                    .append(' ').append("length=").append(length)
-                    .append(' ').append("perms=").append(perms);
-            if (t != null) {
-                sb.append(' ').append("ERROR=").append(t.getClass().getSimpleName()).append(": ").append(t.getMessage());
-            }
-            outputDebugMessage(sb.toString());
-        }
-    };
-
-    private static SshServer sshd;
-    private static int port;
-    private static SshClient client;
-    private final FileSystemFactory fileSystemFactory;
-
+public class ScpTest extends AbstractScpTestSupport {
     public ScpTest() throws IOException {
-        Path targetPath = detectTargetFolder();
-        Path parentPath = targetPath.getParent();
-        fileSystemFactory = new VirtualFileSystemFactory(parentPath);
+        super();
     }
 
     @BeforeClass
@@ -160,51 +96,6 @@ public class ScpTest extends BaseTestSupport {
         setupClientAndServer(ScpTest.class);
     }
 
-    protected static void setupClientAndServer(Class<?> anchor) throws Exception {
-        // Need to use RSA since Ganymede does not support EC
-        SimpleGeneratorHostKeyProvider provider = new SimpleGeneratorHostKeyProvider();
-        provider.setAlgorithm(KeyUtils.RSA_ALGORITHM);
-        provider.setKeySize(1024);
-
-        Path targetDir = CommonTestSupportUtils.detectTargetFolder(anchor);
-        provider.setPath(targetDir.resolve(anchor.getSimpleName() + "-key"));
-        sshd = CoreTestSupportUtils.setupTestFullSupportServer(anchor);
-        sshd.setKeyPairProvider(provider);
-
-        ScpCommandFactory factory = new ScpCommandFactory();
-        sshd.setCommandFactory(factory);
-        sshd.setShellFactory(factory);
-        sshd.start();
-        port = sshd.getPort();
-
-        client = CoreTestSupportUtils.setupTestFullSupportClient(anchor);
-        client.start();
-    }
-
-    @AfterClass
-    public static void tearDownClientAndServer() throws Exception {
-        if (sshd != null) {
-            try {
-                sshd.stop(true);
-            } finally {
-                sshd = null;
-            }
-        }
-
-        if (client != null) {
-            try {
-                client.stop();
-            } finally {
-                client = null;
-            }
-        }
-    }
-
-    @Before
-    public void setUp() throws Exception {
-        sshd.setFileSystemFactory(fileSystemFactory);
-    }
-
     @Test
     public void testNormalizedScpRemotePaths() throws Exception {
         // see SSHD-822
@@ -1144,7 +1035,7 @@ public class ScpTest extends BaseTestSupport {
             os.write(0);
             os.flush();
 
-            String header = readLine(is);
+            String header = ScpIoUtils.readLine(is, false);
             String expHeader
                     = ScpReceiveFileCommandDetails.COMMAND_NAME + ScpReceiveFileCommandDetails.DEFAULT_FILE_OCTAL_PERMISSIONS
                       + " " + Files.size(target) + " " + fileName;
@@ -1176,38 +1067,33 @@ public class ScpTest extends BaseTestSupport {
 
         try (OutputStream os = c.getOutputStream();
              InputStream is = c.getInputStream()) {
-            os.write(0);
-            os.flush();
+            ScpIoUtils.ack(os);
 
-            String header = readLine(is);
+            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));
-            os.write(0);
-            os.flush();
+            ScpIoUtils.ack(os);
 
-            header = readLine(is);
+            header = ScpIoUtils.readLine(is, false);
             String fileName = Objects.toString(target.getFileName(), null);
             String expHeader
                     = ScpReceiveFileCommandDetails.COMMAND_NAME + ScpReceiveFileCommandDetails.DEFAULT_FILE_OCTAL_PERMISSIONS
                       + " " + Files.size(target) + " " + fileName;
             assertEquals("Mismatched dir header for " + path, expHeader, header);
             int length = Integer.parseInt(header.substring(6, header.indexOf(' ', 6)));
-            os.write(0);
-            os.flush();
+            ScpIoUtils.ack(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);
 
-            os.write(0);
-            os.flush();
+            ScpIoUtils.ack(os);
 
-            header = readLine(is);
+            header = ScpIoUtils.readLine(is, false);
             assertEquals("Mismatched end value for " + path, "E", header);
-            os.write(0);
-            os.flush();
+            ScpIoUtils.ack(os);
 
             return new String(buffer, StandardCharsets.UTF_8);
         } finally {
@@ -1224,9 +1110,8 @@ public class ScpTest extends BaseTestSupport {
         try (OutputStream os = c.getOutputStream();
              InputStream is = c.getInputStream()) {
 
-            os.write(0);
-            os.flush();
-            assertEquals("Mismatched response for command: " + command, 2, is.read());
+            ScpIoUtils.ack(os);
+            assertEquals("Mismatched response for command: " + command, ScpIoUtils.ERROR, is.read());
         } finally {
             c.disconnect();
         }
@@ -1254,8 +1139,7 @@ public class ScpTest extends BaseTestSupport {
             os.flush();
             assertAckReceived(is, "Sent data (length=" + data.length() + ") for " + path + "[" + name + "]");
 
-            os.write(0);
-            os.flush();
+            ScpIoUtils.ack(os);
 
             Thread.sleep(100);
         } finally {
@@ -1264,8 +1148,7 @@ public class ScpTest extends BaseTestSupport {
     }
 
     protected void assertAckReceived(OutputStream os, InputStream is, String command) throws IOException {
-        os.write((command + "\n").getBytes(StandardCharsets.UTF_8));
-        os.flush();
+        ScpIoUtils.writeLine(os, command);
         assertAckReceived(is, command);
     }
 
@@ -1285,9 +1168,8 @@ public class ScpTest extends BaseTestSupport {
             assertAckReceived(is, command);
 
             command = "C7777 " + data.length() + " " + name;
-            os.write((command + "\n").getBytes(StandardCharsets.UTF_8));
-            os.flush();
-            assertEquals("Mismatched response for command=" + command, 2, is.read());
+            ScpIoUtils.writeLine(os, command);
+            assertEquals("Mismatched response for command=" + command, ScpIoUtils.ERROR, is.read());
         } finally {
             c.disconnect();
         }
@@ -1313,35 +1195,9 @@ public class ScpTest extends BaseTestSupport {
 
             ScpIoUtils.ack(os);
             ScpIoUtils.writeLine(os, ScpDirEndCommandDetails.HEADER);
-
             assertAckReceived(is, "Signal end of " + path);
         } finally {
             c.disconnect();
         }
     }
-
-    private static String readLine(InputStream in) throws IOException {
-        try (OutputStream baos = new ByteArrayOutputStream()) {
-            for (;;) {
-                int c = in.read();
-                if (c == '\n') {
-                    return baos.toString();
-                } else if (c == -1) {
-                    throw new IOException("End of stream");
-                } else {
-                    baos.write(c);
-                }
-            }
-        }
-    }
-
-    private static ScpClient createScpClient(ClientSession session) {
-        ScpClientCreator creator = ScpClientCreator.instance();
-        ScpTransferEventListener listener = getScpTransferEventListener(session);
-        return creator.createScpClient(session, listener);
-    }
-
-    private static ScpTransferEventListener getScpTransferEventListener(ClientSession session) {
-        return OUTPUT_DEBUG_MESSAGES ? DEBUG_LISTENER : ScpTransferEventListener.EMPTY;
-    }
 }