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:09 UTC

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

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);
+    }
+}