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 &quot;empty&quot; 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