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 <lgoldst...@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:d...@mina.apache.org";>Apache MINA SSHD Project</a>
+ */
+@FixMethodOrder(MethodSorters.NAME_ASCENDING)
+@RunWith(Parameterized.class) // see 
https://github.com/junit-team/junit/wiki/Parameterized-tests
+@UseParametersRunnerFactory(JUnit4ClassRunnerWithParametersFactory.class)
+@Category({ NoIoTestCase.class })
+public class 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:d...@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:d...@mina.apache.org";>Apache MINA SSHD Project</a>
  */
 @FixMethodOrder(MethodSorters.NAME_ASCENDING)
+@RunWith(Parameterized.class) // see 
https://github.com/junit-team/junit/wiki/Parameterized-tests
+@UseParametersRunnerFactory(JUnit4ClassRunnerWithParametersFactory.class)
 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 + "]";
+    }
 }

Reply via email to