You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@mina.apache.org by lg...@apache.org on 2018/04/25 05:01:24 UTC
[3/8] mina-sshd git commit: [SSHD-818] Split SCP code (client +
server) to its own module
http://git-wip-us.apache.org/repos/asf/mina-sshd/blob/af415e5f/sshd-scp/src/main/java/org/apache/sshd/common/scp/ScpHelper.java
----------------------------------------------------------------------
diff --git a/sshd-scp/src/main/java/org/apache/sshd/common/scp/ScpHelper.java b/sshd-scp/src/main/java/org/apache/sshd/common/scp/ScpHelper.java
new file mode 100644
index 0000000..1cbea2d
--- /dev/null
+++ b/sshd-scp/src/main/java/org/apache/sshd/common/scp/ScpHelper.java
@@ -0,0 +1,837 @@
+/*
+ * 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.scp;
+
+import java.io.ByteArrayOutputStream;
+import java.io.EOFException;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.StreamCorruptedException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.DirectoryStream;
+import java.nio.file.FileSystem;
+import java.nio.file.InvalidPathException;
+import java.nio.file.LinkOption;
+import java.nio.file.OpenOption;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.nio.file.attribute.FileTime;
+import java.nio.file.attribute.PosixFilePermission;
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.sshd.common.file.util.MockPath;
+import org.apache.sshd.common.scp.ScpTransferEventListener.FileOperation;
+import org.apache.sshd.common.scp.helpers.DefaultScpFileOpener;
+import org.apache.sshd.common.session.Session;
+import org.apache.sshd.common.session.SessionHolder;
+import org.apache.sshd.common.util.GenericUtils;
+import org.apache.sshd.common.util.io.IoUtils;
+import org.apache.sshd.common.util.io.LimitInputStream;
+import org.apache.sshd.common.util.logging.AbstractLoggingBean;
+
+/**
+ * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
+ */
+@SuppressWarnings("PMD.AvoidUsingOctalValues")
+public class ScpHelper extends AbstractLoggingBean implements SessionHolder<Session> {
+ /**
+ * Command prefix used to identify SCP commands
+ */
+ public static final String SCP_COMMAND_PREFIX = "scp";
+
+ public static final int OK = 0;
+ public static final int WARNING = 1;
+ public static final int ERROR = 2;
+
+ /**
+ * Default size (in bytes) of send / receive buffer size
+ */
+ public static final int DEFAULT_COPY_BUFFER_SIZE = IoUtils.DEFAULT_COPY_SIZE;
+ public static final int DEFAULT_RECEIVE_BUFFER_SIZE = DEFAULT_COPY_BUFFER_SIZE;
+ public static final int DEFAULT_SEND_BUFFER_SIZE = DEFAULT_COPY_BUFFER_SIZE;
+
+ /**
+ * The minimum size for sending / receiving files
+ */
+ public static final int MIN_COPY_BUFFER_SIZE = Byte.MAX_VALUE;
+ public static final int MIN_RECEIVE_BUFFER_SIZE = MIN_COPY_BUFFER_SIZE;
+ public static final int MIN_SEND_BUFFER_SIZE = MIN_COPY_BUFFER_SIZE;
+
+ public static final int S_IRUSR = 0000400;
+ public static final int S_IWUSR = 0000200;
+ public static final int S_IXUSR = 0000100;
+ public static final int S_IRGRP = 0000040;
+ public static final int S_IWGRP = 0000020;
+ public static final int S_IXGRP = 0000010;
+ public static final int S_IROTH = 0000004;
+ public static final int S_IWOTH = 0000002;
+ public static final int S_IXOTH = 0000001;
+
+ public static final String DEFAULT_DIR_OCTAL_PERMISSIONS = "0755";
+ public static final String DEFAULT_FILE_OCTAL_PERMISSIONS = "0644";
+
+ protected final InputStream in;
+ protected final OutputStream out;
+ protected final FileSystem fileSystem;
+ protected final ScpFileOpener opener;
+ protected final ScpTransferEventListener listener;
+
+ private final Session sessionInstance;
+
+ public ScpHelper(Session session, InputStream in, OutputStream out,
+ FileSystem fileSystem, ScpFileOpener opener, ScpTransferEventListener eventListener) {
+ this.sessionInstance = Objects.requireNonNull(session, "No session");
+ this.in = Objects.requireNonNull(in, "No input stream");
+ this.out = Objects.requireNonNull(out, "No output stream");
+ this.fileSystem = fileSystem;
+ this.opener = (opener == null) ? DefaultScpFileOpener.INSTANCE : opener;
+ this.listener = (eventListener == null) ? ScpTransferEventListener.EMPTY : eventListener;
+ }
+
+ @Override
+ public Session getSession() {
+ return sessionInstance;
+ }
+
+ public void receiveFileStream(OutputStream local, int bufferSize) throws IOException {
+ receive((line, isDir, timestamp) -> {
+ if (isDir) {
+ throw new StreamCorruptedException("Cannot download a directory into a file stream: " + line);
+ }
+
+ Path path = new MockPath(line);
+ receiveStream(line, new ScpTargetStreamResolver() {
+ @Override
+ @SuppressWarnings("synthetic-access")
+ public OutputStream resolveTargetStream(
+ Session session, String name, long length, Set<PosixFilePermission> perms, OpenOption... options)
+ throws IOException {
+ if (log.isDebugEnabled()) {
+ log.debug("resolveTargetStream({}) name={}, perms={}, len={} - started local stream download",
+ ScpHelper.this, name, perms, length);
+ }
+ return local;
+ }
+
+ @Override
+ public Path getEventListenerFilePath() {
+ return path;
+ }
+
+ @Override
+ @SuppressWarnings("synthetic-access")
+ public void postProcessReceivedData(String name, boolean preserve, Set<PosixFilePermission> perms, ScpTimestamp time) throws IOException {
+ if (log.isDebugEnabled()) {
+ log.debug("postProcessReceivedData({}) name={}, perms={}, preserve={} time={}",
+ ScpHelper.this, name, perms, preserve, time);
+ }
+ }
+
+ @Override
+ public String toString() {
+ return line;
+ }
+ }, timestamp, false, bufferSize);
+ });
+ }
+
+ public void receive(Path local, boolean recursive, boolean shouldBeDir, boolean preserve, int bufferSize) throws IOException {
+ Path localPath = Objects.requireNonNull(local, "No local path").normalize().toAbsolutePath();
+ Path path = opener.resolveIncomingReceiveLocation(localPath, recursive, shouldBeDir, preserve);
+ receive((line, isDir, time) -> {
+ if (recursive && isDir) {
+ receiveDir(line, path, time, preserve, bufferSize);
+ } else {
+ receiveFile(line, path, time, preserve, bufferSize);
+ }
+ });
+ }
+
+ protected void receive(ScpReceiveLineHandler handler) throws IOException {
+ ack();
+ ScpTimestamp time = null;
+ for (;;) {
+ String line;
+ boolean isDir = false;
+ int c = readAck(true);
+ switch (c) {
+ case -1:
+ return;
+ case 'D':
+ isDir = true;
+ line = String.valueOf((char) c) + readLine();
+ if (log.isDebugEnabled()) {
+ log.debug("receive({}) - Received 'D' header: {}", this, line);
+ }
+ break;
+ case 'C':
+ line = String.valueOf((char) c) + readLine();
+ if (log.isDebugEnabled()) {
+ log.debug("receive({}) - Received 'C' header: {}", this, line);
+ }
+ break;
+ case 'T':
+ line = String.valueOf((char) c) + readLine();
+ if (log.isDebugEnabled()) {
+ log.debug("receive({}) - Received 'T' header: {}", this, line);
+ }
+ time = ScpTimestamp.parseTime(line);
+ ack();
+ continue;
+ case 'E':
+ line = String.valueOf((char) c) + readLine();
+ if (log.isDebugEnabled()) {
+ log.debug("receive({}) - Received 'E' header: {}", this, line);
+ }
+ ack();
+ return;
+ default:
+ //a real ack that has been acted upon already
+ continue;
+ }
+
+ try {
+ handler.process(line, isDir, time);
+ } finally {
+ time = null;
+ }
+ }
+ }
+
+ public void receiveDir(String header, Path local, ScpTimestamp time, boolean preserve, int bufferSize) throws IOException {
+ Path path = Objects.requireNonNull(local, "No local path").normalize().toAbsolutePath();
+ if (log.isDebugEnabled()) {
+ log.debug("receiveDir({})[{}] Receiving directory {} - preserve={}, time={}, buffer-size={}",
+ this, header, path, preserve, time, bufferSize);
+ }
+ if (!header.startsWith("D")) {
+ throw new IOException("Expected a 'D; message but got '" + header + "'");
+ }
+
+ Set<PosixFilePermission> perms = parseOctalPermissions(header.substring(1, 5));
+ int length = Integer.parseInt(header.substring(6, header.indexOf(' ', 6)));
+ String name = header.substring(header.indexOf(' ', 6) + 1);
+ if (length != 0) {
+ throw new IOException("Expected 0 length for directory=" + name + " but got " + length);
+ }
+
+ Path file = opener.resolveIncomingFilePath(path, name, preserve, perms, time);
+
+ ack();
+
+ time = null;
+ listener.startFolderEvent(FileOperation.RECEIVE, path, perms);
+ try {
+ for (;;) {
+ header = readLine();
+ if (log.isDebugEnabled()) {
+ log.debug("receiveDir({})[{}] Received header: {}", this, file, header);
+ }
+ if (header.startsWith("C")) {
+ receiveFile(header, file, time, preserve, bufferSize);
+ time = null;
+ } else if (header.startsWith("D")) {
+ receiveDir(header, file, time, preserve, bufferSize);
+ time = null;
+ } else if (header.equals("E")) {
+ ack();
+ break;
+ } else if (header.startsWith("T")) {
+ time = ScpTimestamp.parseTime(header);
+ ack();
+ } else {
+ throw new IOException("Unexpected message: '" + header + "'");
+ }
+ }
+ } catch (IOException | RuntimeException e) {
+ listener.endFolderEvent(FileOperation.RECEIVE, path, perms, e);
+ throw e;
+ }
+ listener.endFolderEvent(FileOperation.RECEIVE, path, perms, null);
+ }
+
+ public void receiveFile(String header, Path local, ScpTimestamp time, boolean preserve, int bufferSize) throws IOException {
+ Path path = Objects.requireNonNull(local, "No local path").normalize().toAbsolutePath();
+ if (log.isDebugEnabled()) {
+ log.debug("receiveFile({})[{}] Receiving file {} - preserve={}, time={}, buffer-size={}",
+ this, header, path, preserve, time, bufferSize);
+ }
+
+ receiveStream(header, opener.createScpTargetStreamResolver(path), time, preserve, bufferSize);
+ }
+
+ public void receiveStream(String header, ScpTargetStreamResolver resolver, ScpTimestamp time, boolean preserve, int bufferSize) throws IOException {
+ if (!header.startsWith("C")) {
+ throw new IOException("receiveStream(" + resolver + ") Expected a C message but got '" + header + "'");
+ }
+
+ if (bufferSize < MIN_RECEIVE_BUFFER_SIZE) {
+ throw new IOException("receiveStream(" + resolver + ") buffer size (" + bufferSize + ") below minimum (" + MIN_RECEIVE_BUFFER_SIZE + ")");
+ }
+
+ Set<PosixFilePermission> perms = parseOctalPermissions(header.substring(1, 5));
+ long length = Long.parseLong(header.substring(6, header.indexOf(' ', 6)));
+ String name = header.substring(header.indexOf(' ', 6) + 1);
+ if (length < 0L) { // TODO consider throwing an exception...
+ log.warn("receiveStream({})[{}] bad length in header: {}", this, resolver, header);
+ }
+
+ // if file size is less than buffer size allocate only expected file size
+ int bufSize;
+ boolean debugEnabled = log.isDebugEnabled();
+ if (length == 0L) {
+ if (debugEnabled) {
+ log.debug("receiveStream({})[{}] zero file size (perhaps special file) using copy buffer size={}",
+ this, resolver, MIN_RECEIVE_BUFFER_SIZE);
+ }
+ bufSize = MIN_RECEIVE_BUFFER_SIZE;
+ } else {
+ bufSize = (int) Math.min(length, bufferSize);
+ }
+
+ if (bufSize < 0) { // TODO consider throwing an exception
+ log.warn("receiveStream({})[{}] bad buffer size ({}) using default ({})",
+ this, resolver, bufSize, MIN_RECEIVE_BUFFER_SIZE);
+ bufSize = MIN_RECEIVE_BUFFER_SIZE;
+ }
+
+ try (
+ InputStream is = new LimitInputStream(this.in, length);
+ OutputStream os = resolver.resolveTargetStream(getSession(), name, length, perms)
+ ) {
+ ack();
+
+ Path file = resolver.getEventListenerFilePath();
+ listener.startFileEvent(FileOperation.RECEIVE, file, length, perms);
+ try {
+ IoUtils.copy(is, os, bufSize);
+ } catch (IOException | RuntimeException e) {
+ listener.endFileEvent(FileOperation.RECEIVE, file, length, perms, e);
+ throw e;
+ }
+ listener.endFileEvent(FileOperation.RECEIVE, file, length, perms, null);
+ }
+
+ resolver.postProcessReceivedData(name, preserve, perms, time);
+
+ ack();
+
+ int replyCode = readAck(false);
+ if (debugEnabled) {
+ log.debug("receiveStream({})[{}] ack reply code={}", this, resolver, replyCode);
+ }
+ validateAckReplyCode("receiveStream", resolver, replyCode, false);
+ }
+
+ public String readLine() throws IOException {
+ return readLine(false);
+ }
+
+ public String readLine(boolean canEof) throws IOException {
+ try (ByteArrayOutputStream baos = new ByteArrayOutputStream(Byte.MAX_VALUE)) {
+ for (;;) {
+ int c = in.read();
+ if (c == '\n') {
+ return baos.toString(StandardCharsets.UTF_8.name());
+ } else if (c == -1) {
+ if (!canEof) {
+ throw new EOFException("EOF while await end of line");
+ }
+ return null;
+ } else {
+ baos.write(c);
+ }
+ }
+ }
+ }
+
+ public void send(Collection<String> paths, boolean recursive, boolean preserve, int bufferSize) throws IOException {
+ int readyCode = readAck(false);
+ boolean debugEnabled = log.isDebugEnabled();
+ if (debugEnabled) {
+ log.debug("send({}) ready code={}", paths, readyCode);
+ }
+ validateOperationReadyCode("send", "Paths", readyCode, false);
+
+ LinkOption[] options = IoUtils.getLinkOptions(true);
+ for (String pattern : paths) {
+ pattern = pattern.replace('/', File.separatorChar);
+
+ int idx = pattern.indexOf('*'); // check if wildcard used
+ if (idx >= 0) {
+ String basedir = "";
+ String fixedPart = pattern.substring(0, idx);
+ int lastSep = fixedPart.lastIndexOf(File.separatorChar);
+ if (lastSep >= 0) {
+ basedir = pattern.substring(0, lastSep);
+ pattern = pattern.substring(lastSep + 1);
+ }
+
+ Iterable<String> included = opener.getMatchingFilesToSend(basedir, pattern);
+ for (String path : included) {
+ Path file = resolveLocalPath(basedir, path);
+ if (opener.sendAsRegularFile(file, options)) {
+ sendFile(file, preserve, bufferSize);
+ } else if (opener.sendAsDirectory(file, options)) {
+ if (!recursive) {
+ if (debugEnabled) {
+ log.debug("send({}) {}: not a regular file", this, path);
+ }
+ sendWarning(path.replace(File.separatorChar, '/') + " not a regular file");
+ } else {
+ sendDir(file, preserve, bufferSize);
+ }
+ } else {
+ if (debugEnabled) {
+ log.debug("send({}) {}: unknown file type", this, path);
+ }
+ sendWarning(path.replace(File.separatorChar, '/') + " unknown file type");
+ }
+ }
+ } else {
+ send(resolveLocalPath(pattern), recursive, preserve, bufferSize, options);
+ }
+ }
+ }
+
+ public void sendPaths(Collection<? extends Path> paths, boolean recursive, boolean preserve, int bufferSize) throws IOException {
+ int readyCode = readAck(false);
+ if (log.isDebugEnabled()) {
+ log.debug("sendPaths({}) ready code={}", paths, readyCode);
+ }
+ validateOperationReadyCode("sendPaths", "Paths", readyCode, false);
+
+ LinkOption[] options = IoUtils.getLinkOptions(true);
+ for (Path file : paths) {
+ send(file, recursive, preserve, bufferSize, options);
+ }
+ }
+
+ protected void send(Path local, boolean recursive, boolean preserve, int bufferSize, LinkOption... options) throws IOException {
+ Path localPath = Objects.requireNonNull(local, "No local path").normalize().toAbsolutePath();
+ Path file = opener.resolveOutgoingFilePath(localPath, options);
+ if (opener.sendAsRegularFile(file, options)) {
+ sendFile(file, preserve, bufferSize);
+ } else if (opener.sendAsDirectory(file, options)) {
+ if (!recursive) {
+ throw new IOException(file + " not a regular file");
+ } else {
+ sendDir(file, preserve, bufferSize);
+ }
+ } else {
+ throw new IOException(file + ": unknown file type");
+ }
+ }
+
+ public Path resolveLocalPath(String basedir, String subpath) throws IOException {
+ if (GenericUtils.isEmpty(basedir)) {
+ return resolveLocalPath(subpath);
+ } else {
+ return resolveLocalPath(basedir + File.separator + subpath);
+ }
+ }
+
+ /**
+ * @param commandPath The command path using the <U>local</U> file separator
+ * @return The resolved absolute and normalized local {@link Path}
+ * @throws IOException If failed to resolve the path
+ * @throws InvalidPathException If invalid local path value
+ */
+ public Path resolveLocalPath(String commandPath) throws IOException, InvalidPathException {
+ Path p = opener.resolveLocalPath(fileSystem, commandPath);
+ if (log.isTraceEnabled()) {
+ log.trace("resolveLocalPath({}) {}: {}", this, commandPath, p);
+ }
+
+ return p;
+ }
+
+ public void sendFile(Path local, boolean preserve, int bufferSize) throws IOException {
+ Path path = Objects.requireNonNull(local, "No local path").normalize().toAbsolutePath();
+ if (log.isDebugEnabled()) {
+ log.debug("sendFile({})[preserve={},buffer-size={}] Sending file {}", this, preserve, bufferSize, path);
+ }
+
+ sendStream(opener.createScpSourceStreamResolver(path), preserve, bufferSize);
+ }
+
+ public void sendStream(ScpSourceStreamResolver resolver, boolean preserve, int bufferSize) throws IOException {
+ if (bufferSize < MIN_SEND_BUFFER_SIZE) {
+ throw new IOException("sendStream(" + resolver + ") buffer size (" + bufferSize + ") below minimum (" + MIN_SEND_BUFFER_SIZE + ")");
+ }
+
+ long fileSize = resolver.getSize();
+ // if file size is less than buffer size allocate only expected file size
+ int bufSize;
+ boolean debugEnabled = log.isDebugEnabled();
+ if (fileSize <= 0L) {
+ if (debugEnabled) {
+ log.debug("sendStream({})[{}] unknown file size ({}) perhaps special file - using copy buffer size={}",
+ this, resolver, fileSize, MIN_SEND_BUFFER_SIZE);
+ }
+ bufSize = MIN_SEND_BUFFER_SIZE;
+ } else {
+ bufSize = (int) Math.min(fileSize, bufferSize);
+ }
+
+ if (bufSize < 0) { // TODO consider throwing an exception
+ log.warn("sendStream({})[{}] bad buffer size ({}) using default ({})",
+ this, resolver, bufSize, MIN_SEND_BUFFER_SIZE);
+ bufSize = MIN_SEND_BUFFER_SIZE;
+ }
+
+ ScpTimestamp time = resolver.getTimestamp();
+ if (preserve && (time != null)) {
+ String cmd = "T" + TimeUnit.MILLISECONDS.toSeconds(time.getLastModifiedTime())
+ + " " + "0" + " " + TimeUnit.MILLISECONDS.toSeconds(time.getLastAccessTime())
+ + " " + "0";
+ if (debugEnabled) {
+ log.debug("sendStream({})[{}] send timestamp={} command: {}", this, resolver, time, cmd);
+ }
+ out.write(cmd.getBytes(StandardCharsets.UTF_8));
+ out.write('\n');
+ out.flush();
+
+ int readyCode = readAck(false);
+ if (debugEnabled) {
+ log.debug("sendStream({})[{}] command='{}' ready code={}", this, resolver, cmd, readyCode);
+ }
+ validateAckReplyCode(cmd, resolver, readyCode, false);
+ }
+
+ Set<PosixFilePermission> perms = EnumSet.copyOf(resolver.getPermissions());
+ String octalPerms = ((!preserve) || GenericUtils.isEmpty(perms)) ? DEFAULT_FILE_OCTAL_PERMISSIONS : getOctalPermissions(perms);
+ String fileName = resolver.getFileName();
+ String cmd = "C" + octalPerms + " " + fileSize + " " + fileName;
+ if (debugEnabled) {
+ log.debug("sendStream({})[{}] send 'C' command: {}", this, resolver, cmd);
+ }
+ out.write(cmd.getBytes(StandardCharsets.UTF_8));
+ out.write('\n');
+ out.flush();
+
+ int readyCode = readAck(false);
+ if (debugEnabled) {
+ log.debug("sendStream({})[{}] command='{}' ready code={}",
+ this, resolver, cmd.substring(0, cmd.length() - 1), readyCode);
+ }
+ validateAckReplyCode(cmd, resolver, readyCode, false);
+
+ try (InputStream in = resolver.resolveSourceStream(getSession())) {
+ Path path = resolver.getEventListenerFilePath();
+ listener.startFileEvent(FileOperation.SEND, path, fileSize, perms);
+ try {
+ IoUtils.copy(in, out, bufSize);
+ } catch (IOException | RuntimeException e) {
+ listener.endFileEvent(FileOperation.SEND, path, fileSize, perms, e);
+ throw e;
+ }
+ listener.endFileEvent(FileOperation.SEND, path, fileSize, perms, null);
+ }
+ ack();
+
+ readyCode = readAck(false);
+ if (debugEnabled) {
+ log.debug("sendStream({})[{}] command='{}' reply code={}", this, resolver, cmd, readyCode);
+ }
+ validateAckReplyCode("sendStream", resolver, readyCode, false);
+ }
+
+ protected void validateOperationReadyCode(String command, Object location, int readyCode, boolean eofAllowed) throws IOException {
+ validateCommandStatusCode(command, location, readyCode, eofAllowed);
+ }
+
+ protected void validateAckReplyCode(String command, Object location, int replyCode, boolean eofAllowed) throws IOException {
+ validateCommandStatusCode(command, location, replyCode, eofAllowed);
+ }
+
+ protected void validateCommandStatusCode(String command, Object location, int statusCode, boolean eofAllowed) throws IOException {
+ switch (statusCode) {
+ case -1:
+ if (!eofAllowed) {
+ throw new EOFException("Unexpected EOF for command='" + command + "' on " + location);
+ }
+ break;
+ case OK:
+ break;
+ case WARNING:
+ break;
+ default:
+ throw new ScpException("Bad reply code (" + statusCode + ") for command='" + command + "' on " + location, statusCode);
+ }
+ }
+
+ public void sendDir(Path local, boolean preserve, int bufferSize) throws IOException {
+ Path path = Objects.requireNonNull(local, "No local path").normalize().toAbsolutePath();
+ boolean debugEnabled = log.isDebugEnabled();
+ if (debugEnabled) {
+ log.debug("sendDir({}) Sending directory {} - preserve={}, buffer-size={}",
+ this, path, preserve, bufferSize);
+ }
+
+ LinkOption[] options = IoUtils.getLinkOptions(true);
+ if (preserve) {
+ BasicFileAttributes basic = opener.getLocalBasicFileAttributes(path, options);
+ FileTime lastModified = basic.lastModifiedTime();
+ FileTime lastAccess = basic.lastAccessTime();
+ String cmd = "T" + lastModified.to(TimeUnit.SECONDS) + " "
+ + "0" + " " + lastAccess.to(TimeUnit.SECONDS) + " "
+ + "0";
+ if (debugEnabled) {
+ log.debug("sendDir({})[{}] send last-modified={}, last-access={} command: {}",
+ this, path, lastModified, lastAccess, cmd);
+ }
+
+ out.write(cmd.getBytes(StandardCharsets.UTF_8));
+ out.write('\n');
+ out.flush();
+
+ int readyCode = readAck(false);
+ if (debugEnabled) {
+ if (debugEnabled) {
+ log.debug("sendDir({})[{}] command='{}' ready code={}", this, path, cmd, readyCode);
+ }
+ }
+ validateAckReplyCode(cmd, path, readyCode, false);
+ }
+
+ Set<PosixFilePermission> perms = opener.getLocalFilePermissions(path, options);
+ String octalPerms = ((!preserve) || GenericUtils.isEmpty(perms)) ? DEFAULT_DIR_OCTAL_PERMISSIONS : getOctalPermissions(perms);
+ String cmd = "D" + octalPerms + " " + "0" + " " + Objects.toString(path.getFileName(), null);
+ if (debugEnabled) {
+ log.debug("sendDir({})[{}] send 'D' command: {}", this, path, cmd);
+ }
+ out.write(cmd.getBytes(StandardCharsets.UTF_8));
+ out.write('\n');
+ out.flush();
+
+ int readyCode = readAck(false);
+ if (debugEnabled) {
+ log.debug("sendDir({})[{}] command='{}' ready code={}",
+ this, path, cmd.substring(0, cmd.length() - 1), readyCode);
+ }
+ validateAckReplyCode(cmd, path, readyCode, false);
+
+ try (DirectoryStream<Path> children = opener.getLocalFolderChildren(path)) {
+ listener.startFolderEvent(FileOperation.SEND, path, perms);
+
+ try {
+ for (Path child : children) {
+ if (opener.sendAsRegularFile(child, options)) {
+ sendFile(child, preserve, bufferSize);
+ } else if (opener.sendAsDirectory(child, options)) {
+ sendDir(child, preserve, bufferSize);
+ }
+ }
+ } catch (IOException | RuntimeException e) {
+ listener.endFolderEvent(FileOperation.SEND, path, perms, e);
+ throw e;
+ }
+
+ listener.endFolderEvent(FileOperation.SEND, path, perms, null);
+ }
+
+ if (debugEnabled) {
+ log.debug("sendDir({})[{}] send 'E' command", this, path);
+ }
+ out.write("E\n".getBytes(StandardCharsets.UTF_8));
+ out.flush();
+
+ readyCode = readAck(false);
+ if (debugEnabled) {
+ log.debug("sendDir({})[{}] 'E' command reply code=", this, path, readyCode);
+ }
+ validateAckReplyCode("E", path, readyCode, false);
+ }
+
+ public static String getOctalPermissions(Collection<PosixFilePermission> perms) {
+ int pf = 0;
+
+ for (PosixFilePermission p : perms) {
+ switch (p) {
+ case OWNER_READ:
+ pf |= S_IRUSR;
+ break;
+ case OWNER_WRITE:
+ pf |= S_IWUSR;
+ break;
+ case OWNER_EXECUTE:
+ pf |= S_IXUSR;
+ break;
+ case GROUP_READ:
+ pf |= S_IRGRP;
+ break;
+ case GROUP_WRITE:
+ pf |= S_IWGRP;
+ break;
+ case GROUP_EXECUTE:
+ pf |= S_IXGRP;
+ break;
+ case OTHERS_READ:
+ pf |= S_IROTH;
+ break;
+ case OTHERS_WRITE:
+ pf |= S_IWOTH;
+ break;
+ case OTHERS_EXECUTE:
+ pf |= S_IXOTH;
+ break;
+ default: // ignored
+ }
+ }
+
+ return String.format("%04o", pf);
+ }
+
+ public static Set<PosixFilePermission> parseOctalPermissions(String str) {
+ int perms = Integer.parseInt(str, 8);
+ Set<PosixFilePermission> p = EnumSet.noneOf(PosixFilePermission.class);
+ if ((perms & S_IRUSR) != 0) {
+ p.add(PosixFilePermission.OWNER_READ);
+ }
+ if ((perms & S_IWUSR) != 0) {
+ p.add(PosixFilePermission.OWNER_WRITE);
+ }
+ if ((perms & S_IXUSR) != 0) {
+ p.add(PosixFilePermission.OWNER_EXECUTE);
+ }
+ if ((perms & S_IRGRP) != 0) {
+ p.add(PosixFilePermission.GROUP_READ);
+ }
+ if ((perms & S_IWGRP) != 0) {
+ p.add(PosixFilePermission.GROUP_WRITE);
+ }
+ if ((perms & S_IXGRP) != 0) {
+ p.add(PosixFilePermission.GROUP_EXECUTE);
+ }
+ if ((perms & S_IROTH) != 0) {
+ p.add(PosixFilePermission.OTHERS_READ);
+ }
+ if ((perms & S_IWOTH) != 0) {
+ p.add(PosixFilePermission.OTHERS_WRITE);
+ }
+ if ((perms & S_IXOTH) != 0) {
+ p.add(PosixFilePermission.OTHERS_EXECUTE);
+ }
+
+ return p;
+ }
+
+ protected void sendWarning(String message) throws IOException {
+ sendResponseMessage(WARNING, message);
+ }
+
+ protected void sendError(String message) throws IOException {
+ sendResponseMessage(ERROR, message);
+ }
+
+ protected void sendResponseMessage(int level, String message) throws IOException {
+ sendResponseMessage(out, level, message);
+ }
+
+ public static <O extends OutputStream> O sendWarning(O out, String message) throws IOException {
+ return sendResponseMessage(out, WARNING, message);
+ }
+
+ public static <O extends OutputStream> O sendError(O out, String message) throws IOException {
+ return sendResponseMessage(out, ERROR, message);
+ }
+
+ public static <O extends OutputStream> O sendResponseMessage(O out, int level, String message) throws IOException {
+ out.write(level);
+ out.write(message.getBytes(StandardCharsets.UTF_8));
+ out.write('\n');
+ out.flush();
+ return out;
+ }
+
+ public static String getExitStatusName(Integer exitStatus) {
+ if (exitStatus == null) {
+ return "null";
+ }
+
+ switch (exitStatus) {
+ case OK:
+ return "OK";
+ case WARNING:
+ return "WARNING";
+ case ERROR:
+ return "ERROR";
+ default:
+ return exitStatus.toString();
+ }
+ }
+
+ public void ack() throws IOException {
+ out.write(0);
+ out.flush();
+ }
+
+ public int readAck(boolean canEof) throws IOException {
+ int c = in.read();
+ switch (c) {
+ case -1:
+ if (log.isDebugEnabled()) {
+ log.debug("readAck({})[EOF={}] received EOF", this, canEof);
+ }
+ if (!canEof) {
+ throw new EOFException("readAck - EOF before ACK");
+ }
+ break;
+ case OK:
+ if (log.isDebugEnabled()) {
+ log.debug("readAck({})[EOF={}] read OK", this, canEof);
+ }
+ break;
+ case WARNING: {
+ if (log.isDebugEnabled()) {
+ log.debug("readAck({})[EOF={}] read warning message", this, canEof);
+ }
+
+ String line = readLine();
+ log.warn("readAck({})[EOF={}] - Received warning: {}", this, canEof, line);
+ break;
+ }
+ case ERROR: {
+ if (log.isDebugEnabled()) {
+ log.debug("readAck({})[EOF={}] read error message", this, canEof);
+ }
+ String line = readLine();
+ if (log.isDebugEnabled()) {
+ log.debug("readAck({})[EOF={}] received error: {}", this, canEof, line);
+ }
+ throw new ScpException("Received nack: " + line, c);
+ }
+ default:
+ break;
+ }
+ return c;
+ }
+
+ @Override
+ public String toString() {
+ return getClass().getSimpleName() + "[" + getSession() + "]";
+ }
+}
http://git-wip-us.apache.org/repos/asf/mina-sshd/blob/af415e5f/sshd-scp/src/main/java/org/apache/sshd/common/scp/ScpLocation.java
----------------------------------------------------------------------
diff --git a/sshd-scp/src/main/java/org/apache/sshd/common/scp/ScpLocation.java b/sshd-scp/src/main/java/org/apache/sshd/common/scp/ScpLocation.java
new file mode 100644
index 0000000..d2a9afc
--- /dev/null
+++ b/sshd-scp/src/main/java/org/apache/sshd/common/scp/ScpLocation.java
@@ -0,0 +1,227 @@
+/*
+ * 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.scp;
+
+import java.io.Serializable;
+import java.util.Objects;
+
+import org.apache.sshd.common.auth.MutableUserHolder;
+import org.apache.sshd.common.util.GenericUtils;
+import org.apache.sshd.common.util.OsUtils;
+import org.apache.sshd.common.util.ValidateUtils;
+
+/**
+ * Represents a local or remote SCP location in the format {@code user@host:path}
+ * for a remote path and a simple path for a local one. If user is omitted for a
+ * remote path then current user is used.
+ * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
+ */
+public class ScpLocation implements MutableUserHolder, Serializable, Cloneable {
+ public static final char HOST_PART_SEPARATOR = ':';
+ public static final char USERNAME_PART_SEPARATOR = '@';
+
+ private static final long serialVersionUID = 5450230457030600136L;
+
+ private String host;
+ private String username;
+ private String path;
+
+ public ScpLocation() {
+ this(null);
+ }
+
+ /**
+ * @param locSpec The location specification - ignored if {@code null}/empty
+ * @see #update(String, ScpLocation)
+ * @throws IllegalArgumentException if invalid specification
+ */
+ public ScpLocation(String locSpec) {
+ update(locSpec, this);
+ }
+
+ public String getHost() {
+ return host;
+ }
+
+ public void setHost(String host) {
+ this.host = host;
+ }
+
+ public boolean isLocal() {
+ return GenericUtils.isEmpty(getHost());
+ }
+
+ @Override
+ public String getUsername() {
+ return username;
+ }
+
+ @Override
+ public void setUsername(String username) {
+ this.username = username;
+ }
+
+ /**
+ * Resolves the effective username to use for a remote location.
+ * If username not set then uses the current username
+ *
+ * @return The resolved username
+ * @see #getUsername()
+ * @see OsUtils#getCurrentUser()
+ */
+ public String resolveUsername() {
+ String user = getUsername();
+ if (GenericUtils.isEmpty(user)) {
+ return OsUtils.getCurrentUser();
+ } else {
+ return user;
+ }
+ }
+
+ public String getPath() {
+ return path;
+ }
+
+ public void setPath(String path) {
+ this.path = path;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(getHost(), resolveUsername(), OsUtils.getComparablePath(getPath()));
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj == null) {
+ return false;
+ }
+ if (this == obj) {
+ return true;
+ }
+ if (getClass() != obj.getClass()) {
+ return false;
+ }
+
+ ScpLocation other = (ScpLocation) obj;
+ if (this.isLocal() != other.isLocal()) {
+ return false;
+ }
+
+ String thisPath = OsUtils.getComparablePath(getPath());
+ String otherPath = OsUtils.getComparablePath(other.getPath());
+ if (!Objects.equals(thisPath, otherPath)) {
+ return false;
+ }
+
+ if (isLocal()) {
+ return true;
+ }
+
+ // we know other is also remote or we would not have reached this point
+ return Objects.equals(resolveUsername(), other.resolveUsername())
+ && Objects.equals(getHost(), other.getHost());
+ }
+
+ @Override
+ public ScpLocation clone() {
+ try {
+ return getClass().cast(super.clone());
+ } catch (CloneNotSupportedException e) { // unexpected
+ throw new RuntimeException("Failed to clone " + toString(), e);
+ }
+ }
+
+ @Override
+ public String toString() {
+ String p = getPath();
+ if (isLocal()) {
+ return p;
+ }
+
+ return resolveUsername() + String.valueOf(USERNAME_PART_SEPARATOR)
+ + getHost() + String.valueOf(HOST_PART_SEPARATOR) + p;
+ }
+
+ /**
+ * Parses a local or remote SCP location in the format {@code user@host:path}
+ *
+ * @param locSpec The location specification - ignored if {@code null}/empty
+ * @return The {@link ScpLocation} or {@code null} if no specification provider
+ * @throws IllegalArgumentException if invalid specification
+ * @see #update(String, ScpLocation)
+ */
+ public static ScpLocation parse(String locSpec) {
+ return GenericUtils.isEmpty(locSpec) ? null : update(locSpec, new ScpLocation());
+ }
+
+ /**
+ * Parses a local or remote SCP location in the format {@code user@host:path}
+ *
+ * @param <L> Type of {@link ScpLocation} being updated
+ * @param locSpec The location specification - ignored if {@code null}/empty
+ * @param location The {@link ScpLocation} to update - never {@code null}
+ * @return The updated location (unless no specification)
+ * @throws IllegalArgumentException if invalid specification
+ */
+ public static <L extends ScpLocation> L update(String locSpec, L location) {
+ Objects.requireNonNull(location, "No location to update");
+ if (GenericUtils.isEmpty(locSpec)) {
+ return location;
+ }
+
+ location.setHost(null);
+ location.setUsername(null);
+
+ int pos = locSpec.indexOf(HOST_PART_SEPARATOR);
+ if (pos < 0) { // assume a local path
+ location.setPath(locSpec);
+ return location;
+ }
+
+ /*
+ * NOTE !!! in such a case there may be confusion with a host named 'a',
+ * but there is a limit to how smart we can be...
+ */
+ if ((pos == 1) && OsUtils.isWin32()) {
+ char drive = locSpec.charAt(0);
+ if (((drive >= 'a') && (drive <= 'z')) || ((drive >= 'A') && (drive <= 'Z'))) {
+ location.setPath(locSpec);
+ return location;
+ }
+ }
+
+ String login = locSpec.substring(0, pos);
+ ValidateUtils.checkTrue(pos < (locSpec.length() - 1), "Invalid remote specification (missing path): %s", locSpec);
+ location.setPath(locSpec.substring(pos + 1));
+
+ pos = login.indexOf(USERNAME_PART_SEPARATOR);
+ ValidateUtils.checkTrue(pos != 0, "Invalid remote specification (missing username): %s", locSpec);
+ if (pos < 0) {
+ location.setHost(login);
+ } else {
+ location.setUsername(login.substring(0, pos));
+ ValidateUtils.checkTrue(pos < (login.length() - 1), "Invalid remote specification (missing host): %s", locSpec);
+ location.setHost(login.substring(pos + 1));
+ }
+
+ return location;
+ }
+}
http://git-wip-us.apache.org/repos/asf/mina-sshd/blob/af415e5f/sshd-scp/src/main/java/org/apache/sshd/common/scp/ScpReceiveLineHandler.java
----------------------------------------------------------------------
diff --git a/sshd-scp/src/main/java/org/apache/sshd/common/scp/ScpReceiveLineHandler.java b/sshd-scp/src/main/java/org/apache/sshd/common/scp/ScpReceiveLineHandler.java
new file mode 100644
index 0000000..d0e611c
--- /dev/null
+++ b/sshd-scp/src/main/java/org/apache/sshd/common/scp/ScpReceiveLineHandler.java
@@ -0,0 +1,36 @@
+/*
+ * 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.scp;
+
+import java.io.IOException;
+
+/**
+ * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
+ */
+@FunctionalInterface
+public interface ScpReceiveLineHandler {
+ /**
+ * @param line Received SCP input line
+ * @param isDir Does the input line refer to a directory
+ * @param time The received {@link ScpTimestamp} - may be {@code null}
+ * @throws IOException If failed to process the line
+ */
+ void process(String line, boolean isDir, ScpTimestamp time) throws IOException;
+}
http://git-wip-us.apache.org/repos/asf/mina-sshd/blob/af415e5f/sshd-scp/src/main/java/org/apache/sshd/common/scp/ScpSourceStreamResolver.java
----------------------------------------------------------------------
diff --git a/sshd-scp/src/main/java/org/apache/sshd/common/scp/ScpSourceStreamResolver.java b/sshd-scp/src/main/java/org/apache/sshd/common/scp/ScpSourceStreamResolver.java
new file mode 100644
index 0000000..feeecbc
--- /dev/null
+++ b/sshd-scp/src/main/java/org/apache/sshd/common/scp/ScpSourceStreamResolver.java
@@ -0,0 +1,73 @@
+/*
+ * 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.scp;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.OpenOption;
+import java.nio.file.Path;
+import java.nio.file.attribute.PosixFilePermission;
+import java.util.Collection;
+
+import org.apache.sshd.common.session.Session;
+
+/**
+ * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
+ */
+public interface ScpSourceStreamResolver {
+ /**
+ * @return The uploaded file name
+ * @throws IOException If failed to resolve the name
+ */
+ String getFileName() throws IOException;
+
+ /**
+ * @return The {@link Path} to use when invoking the {@link ScpTransferEventListener}
+ */
+ Path getEventListenerFilePath();
+
+ /**
+ * @return The permissions to be used for uploading a file
+ * @throws IOException If failed to generate the required permissions
+ */
+ Collection<PosixFilePermission> getPermissions() throws IOException;
+
+ /**
+ * @return The {@link ScpTimestamp} to use for uploading the file
+ * if {@code null} then no need to send this information
+ * @throws IOException If failed to generate the required data
+ */
+ ScpTimestamp getTimestamp() throws IOException;
+
+ /**
+ * @return An estimated size of the expected number of bytes to be uploaded.
+ * If non-positive then assumed to be unknown.
+ * @throws IOException If failed to generate an estimate
+ */
+ long getSize() throws IOException;
+
+ /**
+ * @param session The {@link Session} through which file is transmitted
+ * @param options The {@link OpenOption}s may be {@code null}/empty
+ * @return The {@link InputStream} containing the data to be uploaded
+ * @throws IOException If failed to create the stream
+ */
+ InputStream resolveSourceStream(Session session, OpenOption... options) throws IOException;
+}
http://git-wip-us.apache.org/repos/asf/mina-sshd/blob/af415e5f/sshd-scp/src/main/java/org/apache/sshd/common/scp/ScpTargetStreamResolver.java
----------------------------------------------------------------------
diff --git a/sshd-scp/src/main/java/org/apache/sshd/common/scp/ScpTargetStreamResolver.java b/sshd-scp/src/main/java/org/apache/sshd/common/scp/ScpTargetStreamResolver.java
new file mode 100644
index 0000000..9a70302
--- /dev/null
+++ b/sshd-scp/src/main/java/org/apache/sshd/common/scp/ScpTargetStreamResolver.java
@@ -0,0 +1,67 @@
+/*
+ * 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.scp;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.file.OpenOption;
+import java.nio.file.Path;
+import java.nio.file.attribute.PosixFilePermission;
+import java.util.Set;
+
+import org.apache.sshd.common.session.Session;
+
+/**
+ * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
+ */
+public interface ScpTargetStreamResolver {
+ /**
+ * Called when receiving a file in order to obtain an output stream
+ * for the incoming data
+ *
+ * @param session The associated {@link Session}
+ * @param name File name as received from remote site
+ * @param length Number of bytes expected to receive
+ * @param perms The {@link Set} of {@link PosixFilePermission} expected
+ * @param options The {@link OpenOption}s to use - may be {@code null}/empty
+ * @return The {@link OutputStream} to write the incoming data
+ * @throws IOException If failed to create the stream
+ */
+ OutputStream resolveTargetStream(Session session, String name, long length,
+ Set<PosixFilePermission> perms, OpenOption... options) throws IOException;
+
+ /**
+ * @return The {@link Path} to use when invoking the {@link ScpTransferEventListener}
+ */
+ Path getEventListenerFilePath();
+
+ /**
+ * Called after successful reception of the data (and after closing the stream)
+ *
+ * @param name File name as received from remote site
+ * @param preserve If {@code true} then the resolver should attempt to preserve
+ * the specified permissions and timestamp
+ * @param perms The {@link Set} of {@link PosixFilePermission} expected
+ * @param time If not {@code null} then the required timestamp(s) on the
+ * incoming data
+ * @throws IOException If failed to post-process the incoming data
+ */
+ void postProcessReceivedData(String name, boolean preserve, Set<PosixFilePermission> perms, ScpTimestamp time) throws IOException;
+}
http://git-wip-us.apache.org/repos/asf/mina-sshd/blob/af415e5f/sshd-scp/src/main/java/org/apache/sshd/common/scp/ScpTimestamp.java
----------------------------------------------------------------------
diff --git a/sshd-scp/src/main/java/org/apache/sshd/common/scp/ScpTimestamp.java b/sshd-scp/src/main/java/org/apache/sshd/common/scp/ScpTimestamp.java
new file mode 100644
index 0000000..e804de9
--- /dev/null
+++ b/sshd-scp/src/main/java/org/apache/sshd/common/scp/ScpTimestamp.java
@@ -0,0 +1,69 @@
+/*
+ * 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.scp;
+
+import java.util.Date;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.sshd.common.util.GenericUtils;
+
+/**
+ * Represents an SCP timestamp definition
+ * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
+ */
+public class ScpTimestamp {
+ private final long lastModifiedTime;
+ private final long lastAccessTime;
+
+ public ScpTimestamp(long modTime, long accTime) {
+ lastModifiedTime = modTime;
+ lastAccessTime = accTime;
+ }
+
+ public long getLastModifiedTime() {
+ return lastModifiedTime;
+ }
+
+ public long getLastAccessTime() {
+ return lastAccessTime;
+ }
+
+ @Override
+ public String toString() {
+ return "modified=" + new Date(lastModifiedTime)
+ + ";accessed=" + new Date(lastAccessTime);
+ }
+
+ /**
+ * @param line The time specification - format:
+ * {@code T<mtime-sec> <mtime-micros> <atime-sec> <atime-micros>}
+ * where specified times are in seconds since UTC
+ * @return The {@link ScpTimestamp} value with the timestamps converted to
+ * <U>milliseconds</U>
+ * @throws NumberFormatException if bad numerical values - <B>Note:</B>
+ * does not check if 1st character is 'T'.
+ * @see <A HREF="https://blogs.oracle.com/janp/entry/how_the_scp_protocol_works">How the SCP protocol works</A>
+ */
+ public static ScpTimestamp parseTime(String line) throws NumberFormatException {
+ String[] numbers = GenericUtils.split(line.substring(1), ' ');
+ return new ScpTimestamp(TimeUnit.SECONDS.toMillis(Long.parseLong(numbers[0])),
+ TimeUnit.SECONDS.toMillis(Long.parseLong(numbers[2])));
+ }
+}
http://git-wip-us.apache.org/repos/asf/mina-sshd/blob/af415e5f/sshd-scp/src/main/java/org/apache/sshd/common/scp/ScpTransferEventListener.java
----------------------------------------------------------------------
diff --git a/sshd-scp/src/main/java/org/apache/sshd/common/scp/ScpTransferEventListener.java b/sshd-scp/src/main/java/org/apache/sshd/common/scp/ScpTransferEventListener.java
new file mode 100644
index 0000000..d7954e0
--- /dev/null
+++ b/sshd-scp/src/main/java/org/apache/sshd/common/scp/ScpTransferEventListener.java
@@ -0,0 +1,105 @@
+/*
+ * 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.scp;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.nio.file.attribute.PosixFilePermission;
+import java.util.Set;
+
+import org.apache.sshd.common.util.SshdEventListener;
+
+/**
+ * Can be registered in order to receive events about SCP transfers
+ *
+ * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
+ */
+public interface ScpTransferEventListener extends SshdEventListener {
+ enum FileOperation {
+ SEND,
+ RECEIVE
+ }
+
+ /**
+ * An "empty" implementation to be used instead of {@code null}s
+ */
+ ScpTransferEventListener EMPTY = new ScpTransferEventListener() {
+ @Override
+ public String toString() {
+ return "EMPTY";
+ }
+ };
+
+ /**
+ * @param op The {@link FileOperation}
+ * @param file The <U>local</U> referenced file {@link Path}
+ * @param length Size (in bytes) of transferred data
+ * @param perms A {@link Set} of {@link PosixFilePermission}s to be applied
+ * once transfer is complete
+ * @throws IOException If failed to handle the event
+ */
+ default void startFileEvent(FileOperation op, Path file, long length, Set<PosixFilePermission> perms) throws IOException {
+ // ignored
+ }
+
+ /**
+ * @param op The {@link FileOperation}
+ * @param file The <U>local</U> referenced file {@link Path}
+ * @param length Size (in bytes) of transferred data
+ * @param perms A {@link Set} of {@link PosixFilePermission}s to be applied
+ * once transfer is complete
+ * @param thrown The result of the operation attempt - if {@code null} then
+ * reception was successful
+ * @throws IOException If failed to handle the event
+ */
+ default void endFileEvent(FileOperation op, Path file, long length, Set<PosixFilePermission> perms, Throwable thrown)
+ throws IOException {
+ // ignored
+ }
+
+ /**
+ * @param op The {@link FileOperation}
+ * @param file The <U>local</U> referenced folder {@link Path}
+ * @param perms A {@link Set} of {@link PosixFilePermission}s to be applied
+ * once transfer is complete
+ * @throws IOException If failed to handle the event
+ */
+ default void startFolderEvent(FileOperation op, Path file, Set<PosixFilePermission> perms) throws IOException {
+ // ignored
+ }
+
+ /**
+ * @param op The {@link FileOperation}
+ * @param file The <U>local</U> referenced file {@link Path}
+ * @param perms A {@link Set} of {@link PosixFilePermission}s to be applied
+ * once transfer is complete
+ * @param thrown The result of the operation attempt - if {@code null} then
+ * reception was successful
+ * @throws IOException If failed to handle the event
+ */
+ default void endFolderEvent(FileOperation op, Path file, Set<PosixFilePermission> perms, Throwable thrown)
+ throws IOException {
+ // ignored
+ }
+
+ static <L extends ScpTransferEventListener> L validateListener(L listener) {
+ return SshdEventListener.validateListener(listener, ScpTransferEventListener.class.getSimpleName());
+ }
+}
http://git-wip-us.apache.org/repos/asf/mina-sshd/blob/af415e5f/sshd-scp/src/main/java/org/apache/sshd/common/scp/helpers/DefaultScpFileOpener.java
----------------------------------------------------------------------
diff --git a/sshd-scp/src/main/java/org/apache/sshd/common/scp/helpers/DefaultScpFileOpener.java b/sshd-scp/src/main/java/org/apache/sshd/common/scp/helpers/DefaultScpFileOpener.java
new file mode 100644
index 0000000..bb6ae3b
--- /dev/null
+++ b/sshd-scp/src/main/java/org/apache/sshd/common/scp/helpers/DefaultScpFileOpener.java
@@ -0,0 +1,75 @@
+/*
+ * 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.scp.helpers;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.file.Files;
+import java.nio.file.OpenOption;
+import java.nio.file.Path;
+import java.util.Arrays;
+
+import org.apache.sshd.common.scp.ScpFileOpener;
+import org.apache.sshd.common.scp.ScpSourceStreamResolver;
+import org.apache.sshd.common.scp.ScpTargetStreamResolver;
+import org.apache.sshd.common.session.Session;
+import org.apache.sshd.common.util.logging.AbstractLoggingBean;
+
+/**
+ * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
+ */
+public class DefaultScpFileOpener extends AbstractLoggingBean implements ScpFileOpener {
+ public static final DefaultScpFileOpener INSTANCE = new DefaultScpFileOpener();
+
+ public DefaultScpFileOpener() {
+ super();
+ }
+
+ @Override
+ public InputStream openRead(Session session, Path file, OpenOption... options) throws IOException {
+ if (log.isDebugEnabled()) {
+ log.debug("openRead({}) file={}, options={}",
+ session, file, Arrays.toString(options));
+ }
+
+ return Files.newInputStream(file, options);
+ }
+
+ @Override
+ public OutputStream openWrite(Session session, Path file, OpenOption... options) throws IOException {
+ if (log.isDebugEnabled()) {
+ log.debug("openWrite({}) file={}, options={}",
+ session, file, Arrays.toString(options));
+ }
+
+ return Files.newOutputStream(file, options);
+ }
+
+ @Override
+ public ScpSourceStreamResolver createScpSourceStreamResolver(Path path) throws IOException {
+ return new LocalFileScpSourceStreamResolver(path, this);
+ }
+
+ @Override
+ public ScpTargetStreamResolver createScpTargetStreamResolver(Path path) throws IOException {
+ return new LocalFileScpTargetStreamResolver(path, this);
+ }
+}
http://git-wip-us.apache.org/repos/asf/mina-sshd/blob/af415e5f/sshd-scp/src/main/java/org/apache/sshd/common/scp/helpers/LocalFileScpSourceStreamResolver.java
----------------------------------------------------------------------
diff --git a/sshd-scp/src/main/java/org/apache/sshd/common/scp/helpers/LocalFileScpSourceStreamResolver.java b/sshd-scp/src/main/java/org/apache/sshd/common/scp/helpers/LocalFileScpSourceStreamResolver.java
new file mode 100644
index 0000000..8ce9b61
--- /dev/null
+++ b/sshd-scp/src/main/java/org/apache/sshd/common/scp/helpers/LocalFileScpSourceStreamResolver.java
@@ -0,0 +1,97 @@
+/*
+ * 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.scp.helpers;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.OpenOption;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributeView;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.nio.file.attribute.PosixFilePermission;
+import java.util.Collection;
+import java.util.Objects;
+import java.util.Set;
+
+import org.apache.sshd.common.scp.ScpFileOpener;
+import org.apache.sshd.common.scp.ScpSourceStreamResolver;
+import org.apache.sshd.common.scp.ScpTimestamp;
+import org.apache.sshd.common.session.Session;
+import org.apache.sshd.common.util.io.IoUtils;
+import org.apache.sshd.common.util.logging.AbstractLoggingBean;
+
+/**
+ * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
+ */
+public class LocalFileScpSourceStreamResolver extends AbstractLoggingBean implements ScpSourceStreamResolver {
+ protected final Path path;
+ protected final ScpFileOpener opener;
+ protected final Path name;
+ protected final Set<PosixFilePermission> perms;
+ protected final long size;
+ protected final ScpTimestamp time;
+
+ public LocalFileScpSourceStreamResolver(Path path, ScpFileOpener opener) throws IOException {
+ this.path = Objects.requireNonNull(path, "No path specified");
+ this.opener = (opener == null) ? DefaultScpFileOpener.INSTANCE : opener;
+ this.name = path.getFileName();
+ this.perms = IoUtils.getPermissions(path);
+
+ BasicFileAttributes basic = Files.getFileAttributeView(path, BasicFileAttributeView.class).readAttributes();
+ this.size = basic.size();
+ this.time = new ScpTimestamp(basic.lastModifiedTime().toMillis(), basic.lastAccessTime().toMillis());
+ }
+
+ @Override
+ public String getFileName() throws IOException {
+ return name.toString();
+ }
+
+ @Override
+ public Collection<PosixFilePermission> getPermissions() throws IOException {
+ return perms;
+ }
+
+ @Override
+ public ScpTimestamp getTimestamp() throws IOException {
+ return time;
+ }
+
+ @Override
+ public long getSize() throws IOException {
+ return size;
+ }
+
+ @Override
+ public Path getEventListenerFilePath() {
+ return path;
+ }
+
+ @Override
+ public InputStream resolveSourceStream(Session session, OpenOption... options) throws IOException {
+ return opener.openRead(session, getEventListenerFilePath(), options);
+ }
+
+ @Override
+ public String toString() {
+ return String.valueOf(getEventListenerFilePath());
+ }
+}
http://git-wip-us.apache.org/repos/asf/mina-sshd/blob/af415e5f/sshd-scp/src/main/java/org/apache/sshd/common/scp/helpers/LocalFileScpTargetStreamResolver.java
----------------------------------------------------------------------
diff --git a/sshd-scp/src/main/java/org/apache/sshd/common/scp/helpers/LocalFileScpTargetStreamResolver.java b/sshd-scp/src/main/java/org/apache/sshd/common/scp/helpers/LocalFileScpTargetStreamResolver.java
new file mode 100644
index 0000000..6b57443
--- /dev/null
+++ b/sshd-scp/src/main/java/org/apache/sshd/common/scp/helpers/LocalFileScpTargetStreamResolver.java
@@ -0,0 +1,159 @@
+/*
+ * 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.scp.helpers;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.StreamCorruptedException;
+import java.nio.file.AccessDeniedException;
+import java.nio.file.Files;
+import java.nio.file.LinkOption;
+import java.nio.file.OpenOption;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributeView;
+import java.nio.file.attribute.FileTime;
+import java.nio.file.attribute.PosixFilePermission;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+import org.apache.sshd.common.scp.ScpFileOpener;
+import org.apache.sshd.common.scp.ScpTargetStreamResolver;
+import org.apache.sshd.common.scp.ScpTimestamp;
+import org.apache.sshd.common.session.Session;
+import org.apache.sshd.common.util.io.IoUtils;
+import org.apache.sshd.common.util.logging.AbstractLoggingBean;
+
+/**
+ * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
+ */
+public class LocalFileScpTargetStreamResolver extends AbstractLoggingBean implements ScpTargetStreamResolver {
+ protected final Path path;
+ protected final ScpFileOpener opener;
+ protected final Boolean status;
+ private Path file;
+
+ public LocalFileScpTargetStreamResolver(Path path, ScpFileOpener opener) throws IOException {
+ LinkOption[] linkOptions = IoUtils.getLinkOptions(true);
+ this.status = IoUtils.checkFileExists(path, linkOptions);
+ if (status == null) {
+ throw new AccessDeniedException("Receive target file path existence status cannot be determined: " + path);
+ }
+
+ this.path = path;
+ this.opener = (opener == null) ? DefaultScpFileOpener.INSTANCE : opener;
+ }
+
+ @Override
+ public OutputStream resolveTargetStream(Session session, String name, long length,
+ Set<PosixFilePermission> perms, OpenOption... options) throws IOException {
+ if (file != null) {
+ throw new StreamCorruptedException("resolveTargetStream(" + name + ")[" + perms + "] already resolved: " + file);
+ }
+
+ LinkOption[] linkOptions = IoUtils.getLinkOptions(true);
+ if (status && Files.isDirectory(path, linkOptions)) {
+ String localName = name.replace('/', File.separatorChar); // in case we are running on Windows
+ file = path.resolve(localName);
+ } else if (status && Files.isRegularFile(path, linkOptions)) {
+ file = path;
+ } else if (!status) {
+ Path parent = path.getParent();
+
+ Boolean parentStatus = IoUtils.checkFileExists(parent, linkOptions);
+ if (parentStatus == null) {
+ throw new AccessDeniedException("Receive file parent (" + parent + ") existence status cannot be determined for " + path);
+ }
+
+ if (parentStatus && Files.isDirectory(parent, linkOptions)) {
+ file = path;
+ }
+ }
+
+ if (file == null) {
+ throw new IOException("Can not write to " + path);
+ }
+
+ Boolean fileStatus = IoUtils.checkFileExists(file, linkOptions);
+ if (fileStatus == null) {
+ throw new AccessDeniedException("Receive file existence status cannot be determined: " + file);
+ }
+
+ if (fileStatus) {
+ if (Files.isDirectory(file, linkOptions)) {
+ throw new IOException("File is a directory: " + file);
+ }
+
+ if (!Files.isWritable(file)) {
+ throw new IOException("Can not write to file: " + file);
+ }
+ }
+
+ if (log.isTraceEnabled()) {
+ log.trace("resolveTargetStream(" + name + "): " + file);
+ }
+
+ return opener.openWrite(session, file, options);
+ }
+
+ @Override
+ public Path getEventListenerFilePath() {
+ if (file == null) {
+ return path;
+ } else {
+ return file;
+ }
+ }
+
+ @Override
+ public void postProcessReceivedData(String name, boolean preserve, Set<PosixFilePermission> perms, ScpTimestamp time) throws IOException {
+ if (file == null) {
+ throw new StreamCorruptedException("postProcessReceivedData(" + name + ")[" + perms + "] No currently resolved data");
+ }
+
+ if (preserve) {
+ updateFileProperties(name, file, perms, time);
+ }
+ }
+
+ protected void updateFileProperties(String name, Path path, Set<PosixFilePermission> perms, ScpTimestamp time) throws IOException {
+ boolean traceEnabled = log.isTraceEnabled();
+ if (traceEnabled) {
+ log.trace("updateFileProperties(" + name + ")[" + path + "] permissions: " + perms);
+ }
+ IoUtils.setPermissions(path, perms);
+
+ if (time != null) {
+ BasicFileAttributeView view = Files.getFileAttributeView(path, BasicFileAttributeView.class);
+ FileTime lastModified = FileTime.from(time.getLastModifiedTime(), TimeUnit.MILLISECONDS);
+ FileTime lastAccess = FileTime.from(time.getLastAccessTime(), TimeUnit.MILLISECONDS);
+ if (traceEnabled) {
+ log.trace("updateFileProperties(" + name + ")[" + path + "] last-modified=" + lastModified + ", last-access=" + lastAccess);
+ }
+
+ view.setTimes(lastModified, lastAccess, null);
+ }
+ }
+
+ @Override
+ public String toString() {
+ return String.valueOf(getEventListenerFilePath());
+ }
+}
http://git-wip-us.apache.org/repos/asf/mina-sshd/blob/af415e5f/sshd-scp/src/main/java/org/apache/sshd/server/scp/ScpCommand.java
----------------------------------------------------------------------
diff --git a/sshd-scp/src/main/java/org/apache/sshd/server/scp/ScpCommand.java b/sshd-scp/src/main/java/org/apache/sshd/server/scp/ScpCommand.java
new file mode 100644
index 0000000..e80f791
--- /dev/null
+++ b/sshd-scp/src/main/java/org/apache/sshd/server/scp/ScpCommand.java
@@ -0,0 +1,350 @@
+/*
+ * 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.server.scp;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.file.FileSystem;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+
+import org.apache.sshd.common.file.FileSystemAware;
+import org.apache.sshd.common.scp.ScpException;
+import org.apache.sshd.common.scp.ScpFileOpener;
+import org.apache.sshd.common.scp.ScpHelper;
+import org.apache.sshd.common.scp.ScpTransferEventListener;
+import org.apache.sshd.common.scp.helpers.DefaultScpFileOpener;
+import org.apache.sshd.common.session.Session;
+import org.apache.sshd.common.session.SessionHolder;
+import org.apache.sshd.common.util.GenericUtils;
+import org.apache.sshd.common.util.logging.AbstractLoggingBean;
+import org.apache.sshd.common.util.threads.ExecutorServiceCarrier;
+import org.apache.sshd.common.util.threads.ThreadUtils;
+import org.apache.sshd.server.Command;
+import org.apache.sshd.server.Environment;
+import org.apache.sshd.server.ExitCallback;
+import org.apache.sshd.server.SessionAware;
+import org.apache.sshd.server.session.ServerSession;
+import org.apache.sshd.server.session.ServerSessionHolder;
+
+/**
+ * This commands provide SCP support on both server and client side.
+ * Permissions and preservation of access / modification times on files
+ * are not supported.
+ *
+ * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
+ */
+public class ScpCommand
+ extends AbstractLoggingBean
+ implements Command, Runnable, FileSystemAware, SessionAware,
+ SessionHolder<Session>, ServerSessionHolder, ExecutorServiceCarrier {
+
+ protected final String name;
+ protected final int sendBufferSize;
+ protected final int receiveBufferSize;
+ protected final ScpFileOpener opener;
+ protected boolean optR;
+ protected boolean optT;
+ protected boolean optF;
+ protected boolean optD;
+ protected boolean optP; // TODO: handle modification times
+ protected FileSystem fileSystem;
+ protected String path;
+ protected InputStream in;
+ protected OutputStream out;
+ protected OutputStream err;
+ protected ExitCallback callback;
+ protected IOException error;
+ protected Future<?> pendingFuture;
+ protected ScpTransferEventListener listener;
+ protected ServerSession serverSession;
+
+ private ExecutorService executorService;
+ private boolean shutdownOnExit;
+
+ /**
+ * @param command The command to be executed
+ * @param executorService An {@link ExecutorService} to be used when
+ * {@link #start(Environment)}-ing execution. If {@code null} an ad-hoc
+ * single-threaded service is created and used.
+ * @param shutdownOnExit If {@code true} the {@link ExecutorService#shutdownNow()}
+ * will be called when command terminates - unless it is the ad-hoc
+ * service, which will be shutdown regardless
+ * @param sendSize Size (in bytes) of buffer to use when sending files
+ * @param receiveSize Size (in bytes) of buffer to use when receiving files
+ * @param fileOpener The {@link ScpFileOpener} - if {@code null} then {@link DefaultScpFileOpener} is used
+ * @param eventListener An {@link ScpTransferEventListener} - may be {@code null}
+ * @see ThreadUtils#newSingleThreadExecutor(String)
+ * @see ScpHelper#MIN_SEND_BUFFER_SIZE
+ * @see ScpHelper#MIN_RECEIVE_BUFFER_SIZE
+ */
+ public ScpCommand(String command,
+ ExecutorService executorService, boolean shutdownOnExit,
+ int sendSize, int receiveSize,
+ ScpFileOpener fileOpener, ScpTransferEventListener eventListener) {
+ name = command;
+
+ if (executorService == null) {
+ String poolName = command.replace(' ', '_').replace('/', ':');
+ this.executorService = ThreadUtils.newSingleThreadExecutor(poolName);
+ this.shutdownOnExit = true; // we always close the ad-hoc executor service
+ } else {
+ this.executorService = executorService;
+ this.shutdownOnExit = shutdownOnExit;
+ }
+
+ if (sendSize < ScpHelper.MIN_SEND_BUFFER_SIZE) {
+ throw new IllegalArgumentException("<ScpCommmand>(" + command + ") send buffer size "
+ + "(" + sendSize + ") below minimum required "
+ + "(" + ScpHelper.MIN_SEND_BUFFER_SIZE + ")");
+ }
+ sendBufferSize = sendSize;
+
+ if (receiveSize < ScpHelper.MIN_RECEIVE_BUFFER_SIZE) {
+ throw new IllegalArgumentException("<ScpCommmand>(" + command + ") receive buffer size "
+ + "(" + sendSize + ") below minimum required "
+ + "(" + ScpHelper.MIN_RECEIVE_BUFFER_SIZE + ")");
+ }
+ receiveBufferSize = receiveSize;
+
+ opener = (fileOpener == null) ? DefaultScpFileOpener.INSTANCE : fileOpener;
+ listener = (eventListener == null) ? ScpTransferEventListener.EMPTY : eventListener;
+
+ boolean debugEnabled = log.isDebugEnabled();
+ if (debugEnabled) {
+ log.debug("Executing command {}", command);
+ }
+
+ String[] args = GenericUtils.split(command, ' ');
+ int numArgs = GenericUtils.length(args);
+ for (int i = 1; i < numArgs; i++) {
+ String argVal = args[i];
+ if (argVal.charAt(0) == '-') {
+ for (int j = 1; j < argVal.length(); j++) {
+ char option = argVal.charAt(j);
+ switch (option) {
+ case 'f':
+ optF = true;
+ break;
+ case 'p':
+ optP = true;
+ break;
+ case 'r':
+ optR = true;
+ break;
+ case 't':
+ optT = true;
+ break;
+ case 'd':
+ optD = true;
+ break;
+ default: // ignored
+ if (debugEnabled) {
+ log.debug("Unknown flag ('{}') in command={}", option, command);
+ }
+ }
+ }
+ } else {
+ String prevArg = args[i - 1];
+ path = command.substring(command.indexOf(prevArg) + prevArg.length() + 1);
+
+ int pathLen = path.length();
+ char startDelim = path.charAt(0);
+ char endDelim = (pathLen > 2) ? path.charAt(pathLen - 1) : '\0';
+ // remove quotes
+ if ((pathLen > 2) && (startDelim == endDelim) && ((startDelim == '\'') || (startDelim == '"'))) {
+ path = path.substring(1, pathLen - 1);
+ }
+ break;
+ }
+ }
+
+ if ((!optF) && (!optT)) {
+ error = new IOException("Either -f or -t option should be set for " + command);
+ }
+ }
+
+ @Override
+ public ExecutorService getExecutorService() {
+ return executorService;
+ }
+
+ @Override
+ public boolean isShutdownOnExit() {
+ return shutdownOnExit;
+ }
+
+ @Override
+ public Session getSession() {
+ return getServerSession();
+ }
+
+ @Override
+ public ServerSession getServerSession() {
+ return serverSession;
+ }
+
+ @Override
+ public void setSession(ServerSession session) {
+ serverSession = session;
+ }
+
+ @Override
+ public void setInputStream(InputStream in) {
+ this.in = in;
+ }
+
+ @Override
+ public void setOutputStream(OutputStream out) {
+ this.out = out;
+ }
+
+ @Override
+ public void setErrorStream(OutputStream err) {
+ this.err = err;
+ }
+
+ @Override
+ public void setExitCallback(ExitCallback callback) {
+ this.callback = callback;
+ }
+
+ @Override
+ public void setFileSystem(FileSystem fs) {
+ this.fileSystem = fs;
+ }
+
+ @Override
+ public void start(Environment env) throws IOException {
+ if (error != null) {
+ throw error;
+ }
+
+ try {
+ ExecutorService executors = getExecutorService();
+ pendingFuture = executors.submit(this);
+ } catch (RuntimeException e) { // e.g., RejectedExecutionException
+ log.error("Failed (" + e.getClass().getSimpleName() + ") to start command=" + name + ": " + e.getMessage(), e);
+ throw new IOException(e);
+ }
+ }
+
+ @Override
+ public void destroy() {
+ // if thread has not completed, cancel it
+ boolean debugEnabled = log.isDebugEnabled();
+ if ((pendingFuture != null) && (!pendingFuture.isDone())) {
+ boolean result = pendingFuture.cancel(true);
+ // TODO consider waiting some reasonable (?) amount of time for cancellation
+ if (debugEnabled) {
+ log.debug("destroy() - cancel pending future=" + result);
+ }
+ }
+
+ pendingFuture = null;
+
+ ExecutorService executors = getExecutorService();
+ if ((executors != null) && (!executors.isShutdown()) && isShutdownOnExit()) {
+ Collection<Runnable> runners = executors.shutdownNow();
+ if (debugEnabled) {
+ log.debug("destroy() - shutdown executor service - runners count=" + runners.size());
+ }
+ }
+ this.executorService = null;
+
+ try {
+ fileSystem.close();
+ } catch (UnsupportedOperationException e) {
+ // Ignore
+ } catch (IOException e) {
+ log.debug("Error closing FileSystem", e);
+ }
+ }
+
+ @Override
+ public void run() {
+ int exitValue = ScpHelper.OK;
+ String exitMessage = null;
+ ScpHelper helper = new ScpHelper(getServerSession(), in, out, fileSystem, opener, listener);
+ try {
+ if (optT) {
+ helper.receive(helper.resolveLocalPath(path), optR, optD, optP, receiveBufferSize);
+ } else if (optF) {
+ helper.send(Collections.singletonList(path), optR, optP, sendBufferSize);
+ } else {
+ throw new IOException("Unsupported mode");
+ }
+ } catch (IOException e) {
+ ServerSession session = getServerSession();
+ boolean debugEnabled = log.isDebugEnabled();
+ try {
+ Integer statusCode = null;
+ if (e instanceof ScpException) {
+ statusCode = ((ScpException) e).getExitStatus();
+ }
+ exitValue = (statusCode == null) ? ScpHelper.ERROR : statusCode;
+ // this is an exception so status cannot be OK/WARNING
+ if ((exitValue == ScpHelper.OK) || (exitValue == ScpHelper.WARNING)) {
+ if (debugEnabled) {
+ log.debug("run({})[{}] normalize status code={}", session, name, exitValue);
+ }
+ exitValue = ScpHelper.ERROR;
+ }
+ exitMessage = GenericUtils.trimToEmpty(e.getMessage());
+ writeCommandResponseMessage(name, exitValue, exitMessage);
+ } catch (IOException e2) {
+ if (debugEnabled) {
+ log.debug("run({})[{}] Failed ({}) to send error response: {}",
+ session, name, e.getClass().getSimpleName(), e.getMessage());
+ }
+ if (log.isTraceEnabled()) {
+ log.trace("run(" + session + ")[" + name + "] error response failure details", e2);
+ }
+ }
+
+ if (debugEnabled) {
+ log.debug("run({})[{}] Failed ({}) to run command: {}",
+ session, name, e.getClass().getSimpleName(), e.getMessage());
+ }
+ if (log.isTraceEnabled()) {
+ log.trace("run(" + session + ")[" + name + "] command execution failure details", e);
+ }
+ } finally {
+ if (callback != null) {
+ callback.onExit(exitValue, GenericUtils.trimToEmpty(exitMessage));
+ }
+ }
+ }
+
+ protected void writeCommandResponseMessage(String command, int exitValue, String exitMessage) throws IOException {
+ if (log.isDebugEnabled()) {
+ log.debug("writeCommandResponseMessage({}) command='{}', exit-status={}: {}",
+ getServerSession(), command, exitValue, exitMessage);
+ }
+ ScpHelper.sendResponseMessage(out, exitValue, exitMessage);
+ }
+
+ @Override
+ public String toString() {
+ return getClass().getSimpleName() + "(" + getSession() + ") " + name;
+ }
+}
\ No newline at end of file