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 "slashification" 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 "slashification" 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 "slash" 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 "E" 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 "E" 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 + "]"; + } }