[prev in list] [next in list] [prev in thread] [next in thread] 

List:       mina-commits
Subject:    [mina-sshd] 04/05: [SSHD-1056] Add SCP remote-to-remote transfer of directories
From:       lgoldstein () apache ! org
Date:       2020-08-21 7:15:19
Message-ID: 20200821071516.16B56890BC () gitbox ! apache ! org
[Download RAW message or body]

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 <lgoldstein@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 + "]"; +    }
 }


[prev in list] [next in list] [prev in thread] [next in thread] 

Configure | About | News | Add a list | Sponsored by KoreLogic