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

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

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

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

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

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

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