You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@mina.apache.org by gn...@apache.org on 2018/04/16 11:47:55 UTC

[08/30] mina-sshd git commit: [SSHD-815] Extract SFTP in its own module

http://git-wip-us.apache.org/repos/asf/mina-sshd/blob/251db9b9/sshd-sftp/src/main/java/org/apache/sshd/server/subsystem/sftp/AbstractSftpSubsystemHelper.java
----------------------------------------------------------------------
diff --git a/sshd-sftp/src/main/java/org/apache/sshd/server/subsystem/sftp/AbstractSftpSubsystemHelper.java b/sshd-sftp/src/main/java/org/apache/sshd/server/subsystem/sftp/AbstractSftpSubsystemHelper.java
new file mode 100644
index 0000000..08213ee
--- /dev/null
+++ b/sshd-sftp/src/main/java/org/apache/sshd/server/subsystem/sftp/AbstractSftpSubsystemHelper.java
@@ -0,0 +1,2580 @@
+/*
+ * 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.subsystem.sftp;
+
+import java.io.EOFException;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.StreamCorruptedException;
+import java.nio.ByteBuffer;
+import java.nio.channels.SeekableByteChannel;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.AccessDeniedException;
+import java.nio.file.CopyOption;
+import java.nio.file.FileAlreadyExistsException;
+import java.nio.file.FileStore;
+import java.nio.file.FileSystem;
+import java.nio.file.Files;
+import java.nio.file.InvalidPathException;
+import java.nio.file.LinkOption;
+import java.nio.file.NoSuchFileException;
+import java.nio.file.NotDirectoryException;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.nio.file.StandardOpenOption;
+import java.nio.file.attribute.AclEntry;
+import java.nio.file.attribute.AclFileAttributeView;
+import java.nio.file.attribute.FileOwnerAttributeView;
+import java.nio.file.attribute.FileTime;
+import java.nio.file.attribute.GroupPrincipal;
+import java.nio.file.attribute.PosixFileAttributeView;
+import java.nio.file.attribute.PosixFilePermission;
+import java.nio.file.attribute.UserPrincipal;
+import java.nio.file.attribute.UserPrincipalLookupService;
+import java.nio.file.attribute.UserPrincipalNotFoundException;
+import java.security.Principal;
+import java.util.AbstractMap.SimpleImmutableEntry;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.EnumSet;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.NavigableMap;
+import java.util.Objects;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.TreeSet;
+import java.util.concurrent.CopyOnWriteArraySet;
+import java.util.function.IntUnaryOperator;
+
+import org.apache.sshd.common.FactoryManager;
+import org.apache.sshd.common.NamedFactory;
+import org.apache.sshd.common.NamedResource;
+import org.apache.sshd.common.OptionalFeature;
+import org.apache.sshd.common.PropertyResolver;
+import org.apache.sshd.common.PropertyResolverUtils;
+import org.apache.sshd.common.config.VersionProperties;
+import org.apache.sshd.common.digest.BuiltinDigests;
+import org.apache.sshd.common.digest.Digest;
+import org.apache.sshd.common.subsystem.sftp.SftpConstants;
+import org.apache.sshd.common.subsystem.sftp.SftpException;
+import org.apache.sshd.common.subsystem.sftp.SftpHelper;
+import org.apache.sshd.common.subsystem.sftp.extensions.AclSupportedParser;
+import org.apache.sshd.common.subsystem.sftp.extensions.SpaceAvailableExtensionInfo;
+import org.apache.sshd.common.subsystem.sftp.extensions.openssh.AbstractOpenSSHExtensionParser.OpenSSHExtension;
+import org.apache.sshd.common.subsystem.sftp.extensions.openssh.FsyncExtensionParser;
+import org.apache.sshd.common.subsystem.sftp.extensions.openssh.HardLinkExtensionParser;
+import org.apache.sshd.common.util.EventListenerUtils;
+import org.apache.sshd.common.util.GenericUtils;
+import org.apache.sshd.common.util.NumberUtils;
+import org.apache.sshd.common.util.OsUtils;
+import org.apache.sshd.common.util.SelectorUtils;
+import org.apache.sshd.common.util.ValidateUtils;
+import org.apache.sshd.common.util.buffer.Buffer;
+import org.apache.sshd.common.util.buffer.BufferUtils;
+import org.apache.sshd.common.util.io.FileInfoExtractor;
+import org.apache.sshd.common.util.io.IoUtils;
+import org.apache.sshd.common.util.logging.AbstractLoggingBean;
+import org.apache.sshd.server.session.ServerSession;
+
+/**
+ * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
+ */
+public abstract class AbstractSftpSubsystemHelper
+            extends AbstractLoggingBean
+            implements SftpEventListenerManager, SftpSubsystemEnvironment {
+    /**
+     * Whether to automatically follow symbolic links when resolving paths
+     * @see #DEFAULT_AUTO_FOLLOW_LINKS
+     */
+    public static final String AUTO_FOLLOW_LINKS = "sftp-auto-follow-links";
+
+    /**
+     * Default value of {@value #AUTO_FOLLOW_LINKS}
+     */
+    public static final boolean DEFAULT_AUTO_FOLLOW_LINKS = true;
+
+    /**
+     * Allows controlling reports of which client extensions are supported
+     * (and reported via &quot;support&quot; and &quot;support2&quot; server
+     * extensions) as a comma-separate list of names. <B>Note:</B> requires
+     * overriding the {@link #executeExtendedCommand(Buffer, int, String)}
+     * command accordingly. If empty string is set then no server extensions
+     * are reported
+     *
+     * @see #DEFAULT_SUPPORTED_CLIENT_EXTENSIONS
+     */
+    public static final String CLIENT_EXTENSIONS_PROP = "sftp-client-extensions";
+
+    /**
+     * The default reported supported client extensions
+     */
+    public static final Map<String, OptionalFeature> DEFAULT_SUPPORTED_CLIENT_EXTENSIONS =
+            // TODO text-seek - see http://tools.ietf.org/wg/secsh/draft-ietf-secsh-filexfer/draft-ietf-secsh-filexfer-13.txt
+            // TODO home-directory - see http://tools.ietf.org/wg/secsh/draft-ietf-secsh-filexfer/draft-ietf-secsh-filexfer-09.txt
+            GenericUtils.<String, OptionalFeature>mapBuilder()
+                .put(SftpConstants.EXT_VERSION_SELECT, OptionalFeature.TRUE)
+                .put(SftpConstants.EXT_COPY_FILE, OptionalFeature.TRUE)
+                .put(SftpConstants.EXT_MD5_HASH, BuiltinDigests.md5)
+                .put(SftpConstants.EXT_MD5_HASH_HANDLE, BuiltinDigests.md5)
+                .put(SftpConstants.EXT_CHECK_FILE_HANDLE, OptionalFeature.any(BuiltinDigests.VALUES))
+                .put(SftpConstants.EXT_CHECK_FILE_NAME, OptionalFeature.any(BuiltinDigests.VALUES))
+                .put(SftpConstants.EXT_COPY_DATA, OptionalFeature.TRUE)
+                .put(SftpConstants.EXT_SPACE_AVAILABLE, OptionalFeature.TRUE)
+                .immutable();
+
+    /**
+     * Comma-separated list of which {@code OpenSSH} extensions are reported and
+     * what version is reported for each - format: {@code name=version}. If empty
+     * value set, then no such extensions are reported. Otherwise, the
+     * {@link #DEFAULT_OPEN_SSH_EXTENSIONS} are used
+     */
+    public static final String OPENSSH_EXTENSIONS_PROP = "sftp-openssh-extensions";
+    public static final List<OpenSSHExtension> DEFAULT_OPEN_SSH_EXTENSIONS =
+            Collections.unmodifiableList(
+                    Arrays.asList(
+                            new OpenSSHExtension(FsyncExtensionParser.NAME, "1"),
+                            new OpenSSHExtension(HardLinkExtensionParser.NAME, "1")
+                    ));
+
+    public static final List<String> DEFAULT_OPEN_SSH_EXTENSIONS_NAMES =
+            Collections.unmodifiableList(NamedResource.getNameList(DEFAULT_OPEN_SSH_EXTENSIONS));
+
+    /**
+     * Comma separate list of {@code SSH_ACL_CAP_xxx} names - where name can be without
+     * the prefix. If not defined then {@link #DEFAULT_ACL_SUPPORTED_MASK} is used
+     */
+    public static final String ACL_SUPPORTED_MASK_PROP = "sftp-acl-supported-mask";
+    public static final Set<Integer> DEFAULT_ACL_SUPPORTED_MASK =
+            Collections.unmodifiableSet(
+                    new HashSet<>(Arrays.asList(
+                            SftpConstants.SSH_ACL_CAP_ALLOW,
+                            SftpConstants.SSH_ACL_CAP_DENY,
+                            SftpConstants.SSH_ACL_CAP_AUDIT,
+                            SftpConstants.SSH_ACL_CAP_ALARM)));
+
+    /**
+     * Property that can be used to set the reported NL value.
+     * If not set, then {@link IoUtils#EOL} is used
+     */
+    public static final String NEWLINE_VALUE = "sftp-newline";
+
+    /**
+     * Force the use of a max. packet length for {@link #doRead(Buffer, int)} protection
+     * against malicious packets
+     *
+     * @see #DEFAULT_MAX_READDATA_PACKET_LENGTH
+     */
+    public static final String MAX_READDATA_PACKET_LENGTH_PROP = "sftp-max-readdata-packet-length";
+    public static final int DEFAULT_MAX_READDATA_PACKET_LENGTH = 63 * 1024;
+
+    private final UnsupportedAttributePolicy unsupportedAttributePolicy;
+    private final Collection<SftpEventListener> sftpEventListeners = new CopyOnWriteArraySet<>();
+    private final SftpEventListener sftpEventListenerProxy;
+    private final SftpFileSystemAccessor fileSystemAccessor;
+    private final SftpErrorStatusDataHandler errorStatusDataHandler;
+
+    protected AbstractSftpSubsystemHelper(
+            UnsupportedAttributePolicy policy, SftpFileSystemAccessor accessor, SftpErrorStatusDataHandler handler) {
+        unsupportedAttributePolicy = Objects.requireNonNull(policy, "No unsupported attribute policy provided");
+        fileSystemAccessor = Objects.requireNonNull(accessor, "No file system accessor");
+        sftpEventListenerProxy = EventListenerUtils.proxyWrapper(SftpEventListener.class, getClass().getClassLoader(), sftpEventListeners);
+        errorStatusDataHandler = Objects.requireNonNull(handler, "No error status data handler");
+    }
+
+    @Override
+    public UnsupportedAttributePolicy getUnsupportedAttributePolicy() {
+        return unsupportedAttributePolicy;
+    }
+
+    @Override
+    public SftpFileSystemAccessor getFileSystemAccessor() {
+        return fileSystemAccessor;
+    }
+
+    @Override
+    public SftpEventListener getSftpEventListenerProxy() {
+        return sftpEventListenerProxy;
+    }
+
+    @Override
+    public boolean addSftpEventListener(SftpEventListener listener) {
+        return sftpEventListeners.add(SftpEventListener.validateListener(listener));
+    }
+
+    @Override
+    public boolean removeSftpEventListener(SftpEventListener listener) {
+        if (listener == null) {
+            return false;
+        }
+
+        return sftpEventListeners.remove(SftpEventListener.validateListener(listener));
+    }
+
+    public SftpErrorStatusDataHandler getErrorStatusDataHandler() {
+        return errorStatusDataHandler;
+    }
+
+    protected abstract void process(Buffer buffer) throws IOException;
+
+    /**
+     * @param buffer   The {@link Buffer} holding the request
+     * @param id       The request id
+     * @param proposed The proposed value
+     * @return A {@link Boolean} indicating whether to accept/reject the proposal.
+     * If {@code null} then rejection response has been sent, otherwise and
+     * appropriate response is generated
+     * @throws IOException If failed send an independent rejection response
+     */
+    protected Boolean validateProposedVersion(Buffer buffer, int id, String proposed) throws IOException {
+        if (log.isDebugEnabled()) {
+            log.debug("validateProposedVersion({})[id={}] SSH_FXP_EXTENDED(version-select) (version={})",
+                      getServerSession(), id, proposed);
+        }
+
+        if (GenericUtils.length(proposed) != 1) {
+            return Boolean.FALSE;
+        }
+
+        char digit = proposed.charAt(0);
+        if ((digit < '0') || (digit > '9')) {
+            return Boolean.FALSE;
+        }
+
+        int value = digit - '0';
+        String all = checkVersionCompatibility(buffer, id, value, SftpConstants.SSH_FX_FAILURE);
+        if (GenericUtils.isEmpty(all)) {    // validation failed
+            return null;
+        } else {
+            return Boolean.TRUE;
+        }
+    }
+
+    /**
+     * Checks if a proposed version is within supported range. <B>Note:</B>
+     * if the user forced a specific value via the {@link SftpSubsystemEnvironment#SFTP_VERSION}
+     * property, then it is used to validate the proposed value
+     *
+     * @param buffer        The {@link Buffer} containing the request
+     * @param id            The SSH message ID to be used to send the failure message
+     *                      if required
+     * @param proposed      The proposed version value
+     * @param failureOpcode The failure opcode to send if validation fails
+     * @return A {@link String} of comma separated values representing all
+     * the supported version - {@code null} if validation failed and an
+     * appropriate status message was sent
+     * @throws IOException If failed to send the failure status message
+     */
+    protected String checkVersionCompatibility(Buffer buffer, int id, int proposed, int failureOpcode) throws IOException {
+        int low = SftpSubsystemEnvironment.LOWER_SFTP_IMPL;
+        int hig = SftpSubsystemEnvironment.HIGHER_SFTP_IMPL;
+        String available = SftpSubsystemEnvironment.ALL_SFTP_IMPL;
+        // check if user wants to use a specific version
+        ServerSession session = getServerSession();
+        Integer sftpVersion = session.getInteger(SftpSubsystemEnvironment.SFTP_VERSION);
+        if (sftpVersion != null) {
+            int forcedValue = sftpVersion;
+            if ((forcedValue < SftpSubsystemEnvironment.LOWER_SFTP_IMPL) || (forcedValue > SftpSubsystemEnvironment.HIGHER_SFTP_IMPL)) {
+                throw new IllegalStateException("Forced SFTP version (" + sftpVersion + ") not within supported values: " + available);
+            }
+            hig = sftpVersion;
+            low = hig;
+            available = sftpVersion.toString();
+        }
+
+        if (log.isTraceEnabled()) {
+            log.trace("checkVersionCompatibility({})[id={}] - proposed={}, available={}",
+                      getServerSession(), id, proposed, available);
+        }
+
+        if ((proposed < low) || (proposed > hig)) {
+            sendStatus(BufferUtils.clear(buffer), id, failureOpcode, "Proposed version (" + proposed + ") not in supported range: " + available);
+            return null;
+        }
+
+        return available;
+    }
+
+    protected void doOpen(Buffer buffer, int id) throws IOException {
+        String path = buffer.getString();
+        /*
+         * Be consistent with FileChannel#open - if no mode specified then READ is assumed
+         */
+        int access = 0;
+        int version = getVersion();
+        if (version >= SftpConstants.SFTP_V5) {
+            access = buffer.getInt();
+            if (access == 0) {
+                access = SftpConstants.ACE4_READ_DATA | SftpConstants.ACE4_READ_ATTRIBUTES;
+            }
+        }
+
+        int pflags = buffer.getInt();
+        if (pflags == 0) {
+            pflags = SftpConstants.SSH_FXF_READ;
+        }
+
+        if (version < SftpConstants.SFTP_V5) {
+            int flags = pflags;
+            pflags = 0;
+            switch (flags & (SftpConstants.SSH_FXF_READ | SftpConstants.SSH_FXF_WRITE)) {
+                case SftpConstants.SSH_FXF_READ:
+                    access |= SftpConstants.ACE4_READ_DATA | SftpConstants.ACE4_READ_ATTRIBUTES;
+                    break;
+                case SftpConstants.SSH_FXF_WRITE:
+                    access |= SftpConstants.ACE4_WRITE_DATA | SftpConstants.ACE4_WRITE_ATTRIBUTES;
+                    break;
+                default:
+                    access |= SftpConstants.ACE4_READ_DATA | SftpConstants.ACE4_READ_ATTRIBUTES;
+                    access |= SftpConstants.ACE4_WRITE_DATA | SftpConstants.ACE4_WRITE_ATTRIBUTES;
+                    break;
+            }
+            if ((flags & SftpConstants.SSH_FXF_APPEND) != 0) {
+                access |= SftpConstants.ACE4_APPEND_DATA;
+                pflags |= SftpConstants.SSH_FXF_APPEND_DATA | SftpConstants.SSH_FXF_APPEND_DATA_ATOMIC;
+            }
+            if ((flags & SftpConstants.SSH_FXF_CREAT) != 0) {
+                if ((flags & SftpConstants.SSH_FXF_EXCL) != 0) {
+                    pflags |= SftpConstants.SSH_FXF_CREATE_NEW;
+                } else if ((flags & SftpConstants.SSH_FXF_TRUNC) != 0) {
+                    pflags |= SftpConstants.SSH_FXF_CREATE_TRUNCATE;
+                } else {
+                    pflags |= SftpConstants.SSH_FXF_OPEN_OR_CREATE;
+                }
+            } else {
+                if ((flags & SftpConstants.SSH_FXF_TRUNC) != 0) {
+                    pflags |= SftpConstants.SSH_FXF_TRUNCATE_EXISTING;
+                } else {
+                    pflags |= SftpConstants.SSH_FXF_OPEN_EXISTING;
+                }
+            }
+        }
+
+        Map<String, Object> attrs = readAttrs(buffer);
+        String handle;
+        try {
+            handle = doOpen(id, path, pflags, access, attrs);
+        } catch (IOException | RuntimeException e) {
+            sendStatus(BufferUtils.clear(buffer), id, e, SftpConstants.SSH_FXP_OPEN, path);
+            return;
+        }
+
+        sendHandle(BufferUtils.clear(buffer), id, handle);
+    }
+
+    /**
+     * @param id     Request id
+     * @param path   Path
+     * @param pflags Open mode flags - see {@code SSH_FXF_XXX} flags
+     * @param access Access mode flags - see {@code ACE4_XXX} flags
+     * @param attrs  Requested attributes
+     * @return The assigned (opaque) handle
+     * @throws IOException if failed to execute
+     */
+    protected abstract String doOpen(int id, String path, int pflags, int access, Map<String, Object> attrs) throws IOException;
+
+    protected void doClose(Buffer buffer, int id) throws IOException {
+        String handle = buffer.getString();
+        try {
+            doClose(id, handle);
+        } catch (IOException | RuntimeException e) {
+            sendStatus(BufferUtils.clear(buffer), id, e, SftpConstants.SSH_FXP_CLOSE, handle);
+            return;
+        }
+
+        sendStatus(BufferUtils.clear(buffer), id, SftpConstants.SSH_FX_OK, "", "");
+    }
+
+    protected abstract void doClose(int id, String handle) throws IOException;
+
+    protected void doRead(Buffer buffer, int id) throws IOException {
+        String handle = buffer.getString();
+        long offset = buffer.getLong();
+        int requestedLength = buffer.getInt();
+        ServerSession serverSession = getServerSession();
+        int maxAllowed = serverSession.getIntProperty(MAX_READDATA_PACKET_LENGTH_PROP, DEFAULT_MAX_READDATA_PACKET_LENGTH);
+        int readLen = Math.min(requestedLength, maxAllowed);
+        if (log.isTraceEnabled()) {
+            log.trace("doRead({})[id={}]({})[offset={}] - req={}, max={}, effective={}",
+                    serverSession, id, handle, offset, requestedLength, maxAllowed, readLen);
+        }
+
+        try {
+            ValidateUtils.checkTrue(readLen >= 0, "Illegal requested read length: %d", readLen);
+
+            buffer.clear();
+            buffer.ensureCapacity(readLen + Long.SIZE /* the header */, IntUnaryOperator.identity());
+
+            buffer.putByte((byte) SftpConstants.SSH_FXP_DATA);
+            buffer.putInt(id);
+            int lenPos = buffer.wpos();
+            buffer.putInt(0);
+
+            int startPos = buffer.wpos();
+            int len = doRead(id, handle, offset, readLen, buffer.array(), startPos);
+            if (len < 0) {
+                throw new EOFException("Unable to read " + readLen + " bytes from offset=" + offset + " of " + handle);
+            }
+            buffer.wpos(startPos + len);
+            BufferUtils.updateLengthPlaceholder(buffer, lenPos, len);
+        } catch (IOException | RuntimeException e) {
+            sendStatus(BufferUtils.clear(buffer), id, e, SftpConstants.SSH_FXP_READ, handle, offset, requestedLength);
+            return;
+        }
+
+        send(buffer);
+    }
+
+    protected abstract int doRead(int id, String handle, long offset, int length, byte[] data, int doff) throws IOException;
+
+    protected void doWrite(Buffer buffer, int id) throws IOException {
+        String handle = buffer.getString();
+        long offset = buffer.getLong();
+        int length = buffer.getInt();
+        try {
+            doWrite(id, handle, offset, length, buffer.array(), buffer.rpos(), buffer.available());
+        } catch (IOException | RuntimeException e) {
+            sendStatus(BufferUtils.clear(buffer), id, e, SftpConstants.SSH_FXP_WRITE, handle, offset, length);
+            return;
+        }
+
+        sendStatus(BufferUtils.clear(buffer), id, SftpConstants.SSH_FX_OK, "");
+    }
+
+    protected abstract void doWrite(int id, String handle, long offset, int length, byte[] data, int doff, int remaining) throws IOException;
+
+    protected void doLStat(Buffer buffer, int id) throws IOException {
+        String path = buffer.getString();
+        int flags = SftpConstants.SSH_FILEXFER_ATTR_ALL;
+        int version = getVersion();
+        if (version >= SftpConstants.SFTP_V4) {
+            flags = buffer.getInt();
+        }
+
+        Map<String, ?> attrs;
+        try {
+            attrs = doLStat(id, path, flags);
+        } catch (IOException | RuntimeException e) {
+            sendStatus(BufferUtils.clear(buffer), id, e, SftpConstants.SSH_FXP_LSTAT, path, flags);
+            return;
+        }
+
+        sendAttrs(BufferUtils.clear(buffer), id, attrs);
+    }
+
+    protected Map<String, Object> doLStat(int id, String path, int flags) throws IOException {
+        Path p = resolveFile(path);
+        if (log.isDebugEnabled()) {
+            log.debug("doLStat({})[id={}] SSH_FXP_LSTAT (path={}[{}], flags=0x{})",
+                      getServerSession(), id, path, p, Integer.toHexString(flags));
+        }
+
+        /*
+         * SSH_FXP_STAT and SSH_FXP_LSTAT only differ in that SSH_FXP_STAT
+         * follows symbolic links on the server, whereas SSH_FXP_LSTAT does not.
+         */
+        return resolveFileAttributes(p, flags, IoUtils.getLinkOptions(false));
+    }
+
+    protected void doSetStat(Buffer buffer, int id) throws IOException {
+        String path = buffer.getString();
+        Map<String, Object> attrs = readAttrs(buffer);
+        try {
+            doSetStat(id, path, attrs);
+        } catch (IOException | RuntimeException e) {
+            sendStatus(BufferUtils.clear(buffer), id, e, SftpConstants.SSH_FXP_SETSTAT, path);
+            return;
+        }
+
+        sendStatus(BufferUtils.clear(buffer), id, SftpConstants.SSH_FX_OK, "");
+    }
+
+    protected void doSetStat(int id, String path, Map<String, ?> attrs) throws IOException {
+        if (log.isDebugEnabled()) {
+            log.debug("doSetStat({})[id={}] SSH_FXP_SETSTAT (path={}, attrs={})",
+                      getServerSession(), id, path, attrs);
+        }
+        Path p = resolveFile(path);
+        doSetAttributes(p, attrs);
+    }
+
+    protected void doFStat(Buffer buffer, int id) throws IOException {
+        String handle = buffer.getString();
+        int flags = SftpConstants.SSH_FILEXFER_ATTR_ALL;
+        int version = getVersion();
+        if (version >= SftpConstants.SFTP_V4) {
+            flags = buffer.getInt();
+        }
+
+        Map<String, ?> attrs;
+        try {
+            attrs = doFStat(id, handle, flags);
+        } catch (IOException | RuntimeException e) {
+            sendStatus(BufferUtils.clear(buffer), id, e, SftpConstants.SSH_FXP_FSTAT, handle, flags);
+            return;
+        }
+
+        sendAttrs(BufferUtils.clear(buffer), id, attrs);
+    }
+
+    protected abstract Map<String, Object> doFStat(int id, String handle, int flags) throws IOException;
+
+    protected void doFSetStat(Buffer buffer, int id) throws IOException {
+        String handle = buffer.getString();
+        Map<String, Object> attrs = readAttrs(buffer);
+        try {
+            doFSetStat(id, handle, attrs);
+        } catch (IOException | RuntimeException e) {
+            sendStatus(BufferUtils.clear(buffer), id, e, SftpConstants.SSH_FXP_FSETSTAT, handle, attrs);
+            return;
+        }
+
+        sendStatus(BufferUtils.clear(buffer), id, SftpConstants.SSH_FX_OK, "");
+    }
+
+    protected abstract void doFSetStat(int id, String handle, Map<String, ?> attrs) throws IOException;
+
+    protected void doOpenDir(Buffer buffer, int id) throws IOException {
+        String path = buffer.getString();
+        String handle;
+
+        try {
+            Path p = resolveNormalizedLocation(path);
+            if (log.isDebugEnabled()) {
+                log.debug("doOpenDir({})[id={}] SSH_FXP_OPENDIR (path={})[{}]",
+                          getServerSession(), id, path, p);
+            }
+
+            LinkOption[] options =
+                getPathResolutionLinkOption(SftpConstants.SSH_FXP_OPENDIR, "", p);
+            handle = doOpenDir(id, path, p, options);
+        } catch (IOException | RuntimeException e) {
+            sendStatus(BufferUtils.clear(buffer), id, e, SftpConstants.SSH_FXP_OPENDIR, path);
+            return;
+        }
+
+        sendHandle(BufferUtils.clear(buffer), id, handle);
+    }
+
+    protected abstract String doOpenDir(int id, String path, Path p, LinkOption... options) throws IOException;
+
+    protected abstract void doReadDir(Buffer buffer, int id) throws IOException;
+
+    protected void doLink(Buffer buffer, int id) throws IOException {
+        String targetPath = buffer.getString();
+        String linkPath = buffer.getString();
+        boolean symLink = buffer.getBoolean();
+
+        try {
+            if (log.isDebugEnabled()) {
+                log.debug("doLink({})[id={}] SSH_FXP_LINK linkpath={}, targetpath={}, symlink={}",
+                          getServerSession(), id, linkPath, targetPath, symLink);
+            }
+
+            doLink(id, targetPath, linkPath, symLink);
+        } catch (IOException | RuntimeException e) {
+            sendStatus(BufferUtils.clear(buffer), id, e, SftpConstants.SSH_FXP_LINK, targetPath, linkPath, symLink);
+            return;
+        }
+
+        sendStatus(BufferUtils.clear(buffer), id, SftpConstants.SSH_FX_OK, "");
+    }
+
+    protected void doLink(int id, String targetPath, String linkPath, boolean symLink) throws IOException {
+        createLink(id, targetPath, linkPath, symLink);
+    }
+
+    protected void doSymLink(Buffer buffer, int id) throws IOException {
+        String targetPath = buffer.getString();
+        String linkPath = buffer.getString();
+        try {
+            if (log.isDebugEnabled()) {
+                log.debug("doSymLink({})[id={}] SSH_FXP_SYMLINK linkpath={}, targetpath={}",
+                          getServerSession(), id, targetPath, linkPath);
+            }
+            doSymLink(id, targetPath, linkPath);
+        } catch (IOException | RuntimeException e) {
+            sendStatus(BufferUtils.clear(buffer), id, e, SftpConstants.SSH_FXP_SYMLINK, targetPath, linkPath);
+            return;
+        }
+
+        sendStatus(BufferUtils.clear(buffer), id, SftpConstants.SSH_FX_OK, "");
+    }
+
+    protected void doSymLink(int id, String targetPath, String linkPath) throws IOException {
+        createLink(id, targetPath, linkPath, true);
+    }
+
+    protected abstract void createLink(int id, String existingPath, String linkPath, boolean symLink) throws IOException;
+
+    // see https://github.com/openssh/openssh-portable/blob/master/PROTOCOL section 10
+    protected void doOpenSSHHardLink(Buffer buffer, int id) throws IOException {
+        String srcFile = buffer.getString();
+        String dstFile = buffer.getString();
+
+        try {
+            doOpenSSHHardLink(id, srcFile, dstFile);
+        } catch (IOException | RuntimeException e) {
+            sendStatus(BufferUtils.clear(buffer), id, e, SftpConstants.SSH_FXP_EXTENDED, HardLinkExtensionParser.NAME, srcFile, dstFile);
+            return;
+        }
+
+        sendStatus(BufferUtils.clear(buffer), id, SftpConstants.SSH_FX_OK, "");
+    }
+
+    protected void doOpenSSHHardLink(int id, String srcFile, String dstFile) throws IOException {
+        if (log.isDebugEnabled()) {
+            log.debug("doOpenSSHHardLink({})[id={}] SSH_FXP_EXTENDED[{}] (src={}, dst={})",
+                      getServerSession(), id, HardLinkExtensionParser.NAME, srcFile, dstFile);
+        }
+
+        createLink(id, srcFile, dstFile, false);
+    }
+
+    protected void doSpaceAvailable(Buffer buffer, int id) throws IOException {
+        String path = buffer.getString();
+        SpaceAvailableExtensionInfo info;
+        try {
+            info = doSpaceAvailable(id, path);
+        } catch (IOException | RuntimeException e) {
+            sendStatus(BufferUtils.clear(buffer), id, e, SftpConstants.SSH_FXP_EXTENDED, SftpConstants.EXT_SPACE_AVAILABLE, path);
+            return;
+        }
+
+        buffer.clear();
+        buffer.putByte((byte) SftpConstants.SSH_FXP_EXTENDED_REPLY);
+        buffer.putInt(id);
+        SpaceAvailableExtensionInfo.encode(buffer, info);
+        send(buffer);
+    }
+
+    protected SpaceAvailableExtensionInfo doSpaceAvailable(int id, String path) throws IOException {
+        Path nrm = resolveNormalizedLocation(path);
+        if (log.isDebugEnabled()) {
+            log.debug("doSpaceAvailable({})[id={}] path={}[{}]", getServerSession(), id, path, nrm);
+        }
+
+        FileStore store = Files.getFileStore(nrm);
+        if (log.isTraceEnabled()) {
+            log.trace("doSpaceAvailable({})[id={}] path={}[{}] - {}[{}]",
+                      getServerSession(), id, path, nrm, store.name(), store.type());
+        }
+
+        return new SpaceAvailableExtensionInfo(store);
+    }
+
+    protected void doTextSeek(Buffer buffer, int id) throws IOException {
+        String handle = buffer.getString();
+        long line = buffer.getLong();
+        try {
+            // TODO : implement text-seek - see https://tools.ietf.org/html/draft-ietf-secsh-filexfer-03#section-6.3
+            doTextSeek(id, handle, line);
+        } catch (IOException | RuntimeException e) {
+            sendStatus(BufferUtils.clear(buffer), id, e, SftpConstants.SSH_FXP_EXTENDED, SftpConstants.EXT_TEXT_SEEK, handle, line);
+            return;
+        }
+
+        sendStatus(BufferUtils.clear(buffer), id, SftpConstants.SSH_FX_OK, "");
+    }
+
+    protected abstract void doTextSeek(int id, String handle, long line) throws IOException;
+
+    // see https://github.com/openssh/openssh-portable/blob/master/PROTOCOL section 10
+    protected void doOpenSSHFsync(Buffer buffer, int id) throws IOException {
+        String handle = buffer.getString();
+        try {
+            doOpenSSHFsync(id, handle);
+        } catch (IOException | RuntimeException e) {
+            sendStatus(BufferUtils.clear(buffer), id, e, SftpConstants.SSH_FXP_EXTENDED, FsyncExtensionParser.NAME, handle);
+            return;
+        }
+
+        sendStatus(BufferUtils.clear(buffer), id, SftpConstants.SSH_FX_OK, "");
+    }
+
+    protected abstract void doOpenSSHFsync(int id, String handle) throws IOException;
+
+    protected void doCheckFileHash(Buffer buffer, int id, String targetType) throws IOException {
+        String target = buffer.getString();
+        String algList = buffer.getString();
+        String[] algos = GenericUtils.split(algList, ',');
+        long startOffset = buffer.getLong();
+        long length = buffer.getLong();
+        int blockSize = buffer.getInt();
+        try {
+            buffer.clear();
+            buffer.putByte((byte) SftpConstants.SSH_FXP_EXTENDED_REPLY);
+            buffer.putInt(id);
+            buffer.putString(SftpConstants.EXT_CHECK_FILE);
+            doCheckFileHash(id, targetType, target, Arrays.asList(algos), startOffset, length, blockSize, buffer);
+        } catch (Exception e) {
+            sendStatus(BufferUtils.clear(buffer), id, e,
+                SftpConstants.SSH_FXP_EXTENDED, targetType, target, algList, startOffset, length, blockSize);
+            return;
+        }
+
+        send(buffer);
+    }
+
+    protected void doCheckFileHash(int id, Path file, NamedFactory<? extends Digest> factory,
+            long startOffset, long length, int blockSize, Buffer buffer)
+                    throws Exception {
+        ValidateUtils.checkTrue(startOffset >= 0L, "Invalid start offset: %d", startOffset);
+        ValidateUtils.checkTrue(length >= 0L, "Invalid length: %d", length);
+        ValidateUtils.checkTrue((blockSize == 0) || (blockSize >= SftpConstants.MIN_CHKFILE_BLOCKSIZE), "Invalid block size: %d", blockSize);
+        Objects.requireNonNull(factory, "No digest factory provided");
+        buffer.putString(factory.getName());
+
+        long effectiveLength = length;
+        long totalLength = Files.size(file);
+        if (effectiveLength == 0L) {
+            effectiveLength = totalLength - startOffset;
+        } else {
+            long maxRead = startOffset + length;
+            if (maxRead > totalLength) {
+                effectiveLength = totalLength - startOffset;
+            }
+        }
+        ValidateUtils.checkTrue(effectiveLength > 0L, "Non-positive effective hash data length: %d", effectiveLength);
+
+        byte[] digestBuf = (blockSize == 0)
+                ? new byte[Math.min((int) effectiveLength, IoUtils.DEFAULT_COPY_SIZE)]
+                : new byte[Math.min((int) effectiveLength, blockSize)];
+        ByteBuffer wb = ByteBuffer.wrap(digestBuf);
+        SftpFileSystemAccessor accessor = getFileSystemAccessor();
+        try (SeekableByteChannel channel = accessor.openFile(getServerSession(), this, file, "", Collections.emptySet())) {
+            channel.position(startOffset);
+
+            Digest digest = factory.create();
+            digest.init();
+
+            boolean traceEnabled = log.isTraceEnabled();
+            if (blockSize == 0) {
+                while (effectiveLength > 0L) {
+                    int remainLen = Math.min(digestBuf.length, (int) effectiveLength);
+                    ByteBuffer bb = wb;
+                    if (remainLen < digestBuf.length) {
+                        bb = ByteBuffer.wrap(digestBuf, 0, remainLen);
+                    }
+                    bb.clear(); // prepare for next read
+
+                    int readLen = channel.read(bb);
+                    if (readLen < 0) {
+                        break;
+                    }
+
+                    effectiveLength -= readLen;
+                    digest.update(digestBuf, 0, readLen);
+                }
+
+                byte[] hashValue = digest.digest();
+                if (traceEnabled) {
+                    log.trace("doCheckFileHash({})[{}] offset={}, length={} - algo={}, hash={}",
+                              getServerSession(), file, startOffset, length,
+                              digest.getAlgorithm(), BufferUtils.toHex(':', hashValue));
+                }
+                buffer.putBytes(hashValue);
+            } else {
+                for (int count = 0; effectiveLength > 0L; count++) {
+                    int remainLen = Math.min(digestBuf.length, (int) effectiveLength);
+                    ByteBuffer bb = wb;
+                    if (remainLen < digestBuf.length) {
+                        bb = ByteBuffer.wrap(digestBuf, 0, remainLen);
+                    }
+                    bb.clear(); // prepare for next read
+
+                    int readLen = channel.read(bb);
+                    if (readLen < 0) {
+                        break;
+                    }
+
+                    effectiveLength -= readLen;
+                    digest.update(digestBuf, 0, readLen);
+
+                    byte[] hashValue = digest.digest(); // NOTE: this also resets the hash for the next read
+                    if (traceEnabled) {
+                        log.trace("doCheckFileHash({})({})[{}] offset={}, length={} - algo={}, hash={}",
+                                  getServerSession(), file, count, startOffset, length,
+                                  digest.getAlgorithm(), BufferUtils.toHex(':', hashValue));
+                    }
+                    buffer.putBytes(hashValue);
+                }
+            }
+        }
+    }
+
+    protected void doMD5Hash(Buffer buffer, int id, String targetType) throws IOException {
+        String target = buffer.getString();
+        long startOffset = buffer.getLong();
+        long length = buffer.getLong();
+        byte[] quickCheckHash = buffer.getBytes();
+        byte[] hashValue;
+
+        try {
+            hashValue = doMD5Hash(id, targetType, target, startOffset, length, quickCheckHash);
+            if (log.isTraceEnabled()) {
+                log.trace("doMD5Hash({})({})[{}] offset={}, length={}, quick-hash={} - hash={}",
+                          getServerSession(), targetType, target, startOffset, length,
+                          BufferUtils.toHex(':', quickCheckHash),
+                          BufferUtils.toHex(':', hashValue));
+            }
+
+        } catch (Exception e) {
+            sendStatus(BufferUtils.clear(buffer), id, e,
+                SftpConstants.SSH_FXP_EXTENDED, targetType, target, startOffset, length, quickCheckHash);
+            return;
+        }
+
+        buffer.clear();
+        buffer.putByte((byte) SftpConstants.SSH_FXP_EXTENDED_REPLY);
+        buffer.putInt(id);
+        buffer.putString(targetType);
+        buffer.putBytes(hashValue);
+        send(buffer);
+    }
+
+    protected abstract byte[] doMD5Hash(
+            int id, String targetType, String target, long startOffset, long length, byte[] quickCheckHash)
+                    throws Exception;
+
+    protected byte[] doMD5Hash(int id, Path path, long startOffset, long length, byte[] quickCheckHash) throws Exception {
+        ValidateUtils.checkTrue(startOffset >= 0L, "Invalid start offset: %d", startOffset);
+        ValidateUtils.checkTrue(length > 0L, "Invalid length: %d", length);
+        if (!BuiltinDigests.md5.isSupported()) {
+            throw new UnsupportedOperationException(BuiltinDigests.md5.getAlgorithm() + " hash not supported");
+        }
+
+        Digest digest = BuiltinDigests.md5.create();
+        digest.init();
+
+        long effectiveLength = length;
+        byte[] digestBuf = new byte[(int) Math.min(effectiveLength, SftpConstants.MD5_QUICK_HASH_SIZE)];
+        ByteBuffer wb = ByteBuffer.wrap(digestBuf);
+        boolean hashMatches = false;
+        byte[] hashValue = null;
+        SftpFileSystemAccessor accessor = getFileSystemAccessor();
+        boolean traceEnabled = log.isTraceEnabled();
+        try (SeekableByteChannel channel = accessor.openFile(getServerSession(), this, path, null, EnumSet.of(StandardOpenOption.READ))) {
+            channel.position(startOffset);
+
+            /*
+             * To quote http://tools.ietf.org/wg/secsh/draft-ietf-secsh-filexfer/draft-ietf-secsh-filexfer-09.txt section 9.1.1:
+             *
+             *      If this is a zero length string, the client does not have the
+             *      data, and is requesting the hash for reasons other than comparing
+             *      with a local file.  The server MAY return SSH_FX_OP_UNSUPPORTED in
+             *      this case.
+             */
+            if (NumberUtils.length(quickCheckHash) <= 0) {
+                // TODO consider limiting it - e.g., if the requested effective length is <= than some (configurable) threshold
+                hashMatches = true;
+            } else {
+                int readLen = channel.read(wb);
+                if (readLen < 0) {
+                    throw new EOFException("EOF while read initial buffer from " + path);
+                }
+                effectiveLength -= readLen;
+                digest.update(digestBuf, 0, readLen);
+
+                hashValue = digest.digest();
+                hashMatches = Arrays.equals(quickCheckHash, hashValue);
+                if (hashMatches) {
+                    /*
+                     * Need to re-initialize the digester due to the Javadoc:
+                     *
+                     *      "The digest method can be called once for a given number
+                     *       of updates. After digest has been called, the MessageDigest
+                     *       object is reset to its initialized state."
+                     */
+                    if (effectiveLength > 0L) {
+                        digest = BuiltinDigests.md5.create();
+                        digest.init();
+                        digest.update(digestBuf, 0, readLen);
+                        hashValue = null;   // start again
+                    }
+                } else {
+                    if (traceEnabled) {
+                        log.trace("doMD5Hash({})({}) offset={}, length={} - quick-hash mismatched expected={}, actual={}",
+                                  getServerSession(), path, startOffset, length,
+                                  BufferUtils.toHex(':', quickCheckHash),
+                                  BufferUtils.toHex(':', hashValue));
+                    }
+                }
+            }
+
+            if (hashMatches) {
+                while (effectiveLength > 0L) {
+                    int remainLen = Math.min(digestBuf.length, (int) effectiveLength);
+                    ByteBuffer bb = wb;
+                    if (remainLen < digestBuf.length) {
+                        bb = ByteBuffer.wrap(digestBuf, 0, remainLen);
+                    }
+                    bb.clear(); // prepare for next read
+
+                    int readLen = channel.read(bb);
+                    if (readLen < 0) {
+                        break;  // user may have specified more than we have available
+                    }
+                    effectiveLength -= readLen;
+                    digest.update(digestBuf, 0, readLen);
+                }
+
+                if (hashValue == null) {    // check if did any more iterations after the quick hash
+                    hashValue = digest.digest();
+                }
+            } else {
+                hashValue = GenericUtils.EMPTY_BYTE_ARRAY;
+            }
+        }
+
+        if (traceEnabled) {
+            log.trace("doMD5Hash({})({}) offset={}, length={} - matches={}, quick={} hash={}",
+                      getServerSession(), path, startOffset, length, hashMatches,
+                      BufferUtils.toHex(':', quickCheckHash),
+                      BufferUtils.toHex(':', hashValue));
+        }
+
+        return hashValue;
+    }
+
+    protected abstract void doCheckFileHash(
+            int id, String targetType, String target, Collection<String> algos,
+            long startOffset, long length, int blockSize, Buffer buffer)
+                    throws Exception;
+
+    protected void doReadLink(Buffer buffer, int id) throws IOException {
+        String path = buffer.getString();
+        String l;
+        try {
+            if (log.isDebugEnabled()) {
+                log.debug("doReadLink({})[id={}] SSH_FXP_READLINK path={}",
+                          getServerSession(), id, path);
+            }
+            l = doReadLink(id, path);
+        } catch (IOException | RuntimeException e) {
+            sendStatus(BufferUtils.clear(buffer), id, e, SftpConstants.SSH_FXP_READLINK, path);
+            return;
+        }
+
+        sendLink(BufferUtils.clear(buffer), id, l);
+    }
+
+    protected String doReadLink(int id, String path) throws IOException {
+        Path f = resolveFile(path);
+        Path t = Files.readSymbolicLink(f);
+        if (log.isDebugEnabled()) {
+            log.debug("doReadLink({})[id={}] path={}[{}]: {}",
+                      getServerSession(), id, path, f, t);
+        }
+        return t.toString();
+    }
+
+    protected void doRename(Buffer buffer, int id) throws IOException {
+        String oldPath = buffer.getString();
+        String newPath = buffer.getString();
+        int flags = 0;
+        int version = getVersion();
+        if (version >= SftpConstants.SFTP_V5) {
+            flags = buffer.getInt();
+        }
+        try {
+            doRename(id, oldPath, newPath, flags);
+        } catch (IOException | RuntimeException e) {
+            sendStatus(BufferUtils.clear(buffer), id, e, SftpConstants.SSH_FXP_RENAME, oldPath, newPath, flags);
+            return;
+        }
+
+        sendStatus(BufferUtils.clear(buffer), id, SftpConstants.SSH_FX_OK, "");
+    }
+
+    protected void doRename(int id, String oldPath, String newPath, int flags) throws IOException {
+        if (log.isDebugEnabled()) {
+            log.debug("doRename({})[id={}] SSH_FXP_RENAME (oldPath={}, newPath={}, flags=0x{})",
+                      getServerSession(), id, oldPath, newPath, Integer.toHexString(flags));
+        }
+
+        Collection<CopyOption> opts = Collections.emptyList();
+        if (flags != 0) {
+            opts = new ArrayList<>();
+            if ((flags & SftpConstants.SSH_FXP_RENAME_ATOMIC) == SftpConstants.SSH_FXP_RENAME_ATOMIC) {
+                opts.add(StandardCopyOption.ATOMIC_MOVE);
+            }
+            if ((flags & SftpConstants.SSH_FXP_RENAME_OVERWRITE) == SftpConstants.SSH_FXP_RENAME_OVERWRITE) {
+                opts.add(StandardCopyOption.REPLACE_EXISTING);
+            }
+        }
+
+        doRename(id, oldPath, newPath, opts);
+    }
+
+    protected void doRename(int id, String oldPath, String newPath, Collection<CopyOption> opts) throws IOException {
+        Path o = resolveFile(oldPath);
+        Path n = resolveFile(newPath);
+        SftpEventListener listener = getSftpEventListenerProxy();
+        ServerSession session = getServerSession();
+
+        listener.moving(session, o, n, opts);
+        try {
+            Files.move(o, n, GenericUtils.isEmpty(opts) ? IoUtils.EMPTY_COPY_OPTIONS : opts.toArray(new CopyOption[opts.size()]));
+        } catch (IOException | RuntimeException e) {
+            listener.moved(session, o, n, opts, e);
+            throw e;
+        }
+        listener.moved(session, o, n, opts, null);
+    }
+
+    // see https://tools.ietf.org/html/draft-ietf-secsh-filexfer-extensions-00#section-7
+    protected void doCopyData(Buffer buffer, int id) throws IOException {
+        String readHandle = buffer.getString();
+        long readOffset = buffer.getLong();
+        long readLength = buffer.getLong();
+        String writeHandle = buffer.getString();
+        long writeOffset = buffer.getLong();
+        try {
+            doCopyData(id, readHandle, readOffset, readLength, writeHandle, writeOffset);
+        } catch (IOException | RuntimeException e) {
+            sendStatus(BufferUtils.clear(buffer), id, e,
+                SftpConstants.SSH_FXP_EXTENDED, SftpConstants.EXT_COPY_DATA,
+                readHandle, readOffset, readLength, writeHandle, writeOffset);
+            return;
+        }
+
+        sendStatus(BufferUtils.clear(buffer), id, SftpConstants.SSH_FX_OK, "");
+    }
+
+    protected abstract void doCopyData(int id, String readHandle, long readOffset, long readLength, String writeHandle, long writeOffset) throws IOException;
+
+    // see https://tools.ietf.org/html/draft-ietf-secsh-filexfer-extensions-00#section-6
+    protected void doCopyFile(Buffer buffer, int id) throws IOException {
+        String srcFile = buffer.getString();
+        String dstFile = buffer.getString();
+        boolean overwriteDestination = buffer.getBoolean();
+
+        try {
+            doCopyFile(id, srcFile, dstFile, overwriteDestination);
+        } catch (IOException | RuntimeException e) {
+            sendStatus(BufferUtils.clear(buffer), id, e,
+                SftpConstants.SSH_FXP_EXTENDED, SftpConstants.EXT_COPY_FILE, srcFile, dstFile, overwriteDestination);
+            return;
+        }
+
+        sendStatus(BufferUtils.clear(buffer), id, SftpConstants.SSH_FX_OK, "");
+    }
+
+    protected void doCopyFile(int id, String srcFile, String dstFile, boolean overwriteDestination) throws IOException {
+        if (log.isDebugEnabled()) {
+            log.debug("doCopyFile({})[id={}] SSH_FXP_EXTENDED[{}] (src={}, dst={}, overwrite=0x{})",
+                      getServerSession(), id, SftpConstants.EXT_COPY_FILE,
+                      srcFile, dstFile, overwriteDestination);
+        }
+
+        doCopyFile(id, srcFile, dstFile,
+                overwriteDestination
+                        ? Collections.singletonList(StandardCopyOption.REPLACE_EXISTING)
+                        : Collections.emptyList());
+    }
+
+    protected void doCopyFile(int id, String srcFile, String dstFile, Collection<CopyOption> opts) throws IOException {
+        Path src = resolveFile(srcFile);
+        Path dst = resolveFile(dstFile);
+        Files.copy(src, dst, GenericUtils.isEmpty(opts) ? IoUtils.EMPTY_COPY_OPTIONS : opts.toArray(new CopyOption[opts.size()]));
+    }
+
+    protected void doBlock(Buffer buffer, int id) throws IOException {
+        String handle = buffer.getString();
+        long offset = buffer.getLong();
+        long length = buffer.getLong();
+        int mask = buffer.getInt();
+
+        try {
+            doBlock(id, handle, offset, length, mask);
+        } catch (IOException | RuntimeException e) {
+            sendStatus(BufferUtils.clear(buffer), id, e, SftpConstants.SSH_FXP_BLOCK, handle, offset, length, mask);
+            return;
+        }
+
+        sendStatus(BufferUtils.clear(buffer), id, SftpConstants.SSH_FX_OK, "");
+    }
+
+    protected abstract void doBlock(int id, String handle, long offset, long length, int mask) throws IOException;
+
+    protected void doUnblock(Buffer buffer, int id) throws IOException {
+        String handle = buffer.getString();
+        long offset = buffer.getLong();
+        long length = buffer.getLong();
+        try {
+            doUnblock(id, handle, offset, length);
+        } catch (IOException | RuntimeException e) {
+            sendStatus(BufferUtils.clear(buffer), id, e, SftpConstants.SSH_FXP_UNBLOCK, handle, offset, length);
+            return;
+        }
+
+        sendStatus(BufferUtils.clear(buffer), id, SftpConstants.SSH_FX_OK, "");
+    }
+
+    protected abstract void doUnblock(int id, String handle, long offset, long length) throws IOException;
+
+    protected void doStat(Buffer buffer, int id) throws IOException {
+        String path = buffer.getString();
+        int flags = SftpConstants.SSH_FILEXFER_ATTR_ALL;
+        int version = getVersion();
+        if (version >= SftpConstants.SFTP_V4) {
+            flags = buffer.getInt();
+        }
+
+        Map<String, Object> attrs;
+        try {
+            attrs = doStat(id, path, flags);
+        } catch (IOException | RuntimeException e) {
+            sendStatus(BufferUtils.clear(buffer), id, e, SftpConstants.SSH_FXP_STAT, path, flags);
+            return;
+        }
+
+        sendAttrs(BufferUtils.clear(buffer), id, attrs);
+    }
+
+    protected Map<String, Object> doStat(int id, String path, int flags) throws IOException {
+        if (log.isDebugEnabled()) {
+            log.debug("doStat({})[id={}] SSH_FXP_STAT (path={}, flags=0x{})",
+                      getServerSession(), id, path, Integer.toHexString(flags));
+        }
+
+        /*
+         * SSH_FXP_STAT and SSH_FXP_LSTAT only differ in that SSH_FXP_STAT
+         * follows symbolic links on the server, whereas SSH_FXP_LSTAT does not.
+         */
+        Path p = resolveFile(path);
+        return resolveFileAttributes(p, flags, IoUtils.getLinkOptions(true));
+    }
+
+    protected void doRealPath(Buffer buffer, int id) throws IOException {
+        String path = buffer.getString();
+        boolean debugEnabled = log.isDebugEnabled();
+        if (debugEnabled) {
+            log.debug("doRealPath({})[id={}] SSH_FXP_REALPATH (path={})", getServerSession(), id, path);
+        }
+        path = GenericUtils.trimToEmpty(path);
+        if (GenericUtils.isEmpty(path)) {
+            path = ".";
+        }
+
+        Map<String, ?> attrs = Collections.emptyMap();
+        Map.Entry<Path, Boolean> result;
+        try {
+            int version = getVersion();
+            if (version < SftpConstants.SFTP_V6) {
+                /*
+                 * See http://www.openssh.com/txt/draft-ietf-secsh-filexfer-02.txt:
+                 *
+                 *      The SSH_FXP_REALPATH request can be used to have the server
+                 *      canonicalize any given path name to an absolute path.
+                 *
+                 * See also SSHD-294
+                 */
+                Path p = resolveFile(path);
+                LinkOption[] options =
+                    getPathResolutionLinkOption(SftpConstants.SSH_FXP_REALPATH, "", p);
+                result = doRealPathV345(id, path, p, options);
+            } else {
+                /*
+                 * See https://tools.ietf.org/html/draft-ietf-secsh-filexfer-13#section-8.9
+                 *
+                 *      This field is optional, and if it is not present in the packet, it
+                 *      is assumed to be SSH_FXP_REALPATH_NO_CHECK.
+                 */
+                int control = SftpConstants.SSH_FXP_REALPATH_NO_CHECK;
+                if (buffer.available() > 0) {
+                    control = buffer.getUByte();
+                    if (debugEnabled) {
+                        log.debug("doRealPath({}) - control=0x{} for path={}",
+                              getServerSession(), Integer.toHexString(control), path);
+                    }
+                }
+
+                Collection<String> extraPaths = new LinkedList<>();
+                while (buffer.available() > 0) {
+                    extraPaths.add(buffer.getString());
+                }
+
+                Path p = resolveFile(path);
+                LinkOption[] options =
+                    getPathResolutionLinkOption(SftpConstants.SSH_FXP_REALPATH, "", p);
+                result = doRealPathV6(id, path, extraPaths, p, options);
+
+                p = result.getKey();
+                options = getPathResolutionLinkOption(SftpConstants.SSH_FXP_REALPATH, "", p);
+                Boolean status = result.getValue();
+                switch (control) {
+                    case SftpConstants.SSH_FXP_REALPATH_STAT_IF:
+                        if (status == null) {
+                            attrs = handleUnknownStatusFileAttributes(p, SftpConstants.SSH_FILEXFER_ATTR_ALL, options);
+                        } else if (status) {
+                            try {
+                                attrs = getAttributes(p, options);
+                            } catch (IOException e) {
+                                if (debugEnabled) {
+                                    log.debug("doRealPath({}) - failed ({}) to retrieve attributes of {}: {}",
+                                              getServerSession(), e.getClass().getSimpleName(), p, e.getMessage());
+                                }
+                                if (log.isTraceEnabled()) {
+                                    log.trace("doRealPath(" + getServerSession() + ")[" + p + "] attributes retrieval failure details", e);
+                                }
+                            }
+                        } else {
+                            if (debugEnabled) {
+                                log.debug("doRealPath({}) - dummy attributes for non-existing file: {}", getServerSession(), p);
+                            }
+                        }
+                        break;
+                    case SftpConstants.SSH_FXP_REALPATH_STAT_ALWAYS:
+                        if (status == null) {
+                            attrs = handleUnknownStatusFileAttributes(p, SftpConstants.SSH_FILEXFER_ATTR_ALL, options);
+                        } else if (status) {
+                            attrs = getAttributes(p, options);
+                        } else {
+                            throw new NoSuchFileException(p.toString(), p.toString(), "Real path N/A for target");
+                        }
+                        break;
+                    case SftpConstants.SSH_FXP_REALPATH_NO_CHECK:
+                        break;
+                    default:
+                        log.warn("doRealPath({}) unknown control value 0x{} for path={}",
+                             getServerSession(), Integer.toHexString(control), p);
+                }
+            }
+        } catch (IOException | RuntimeException e) {
+            sendStatus(BufferUtils.clear(buffer), id, e, SftpConstants.SSH_FXP_REALPATH, path);
+            return;
+        }
+
+        sendPath(BufferUtils.clear(buffer), id, result.getKey(), attrs);
+    }
+
+    protected SimpleImmutableEntry<Path, Boolean> doRealPathV6(
+            int id, String path, Collection<String> extraPaths, Path p, LinkOption... options) throws IOException {
+        int numExtra = GenericUtils.size(extraPaths);
+        if (numExtra > 0) {
+            if (log.isDebugEnabled()) {
+                log.debug("doRealPathV6({})[id={}] path={}, extra={}",
+                          getServerSession(), id, path, extraPaths);
+            }
+            StringBuilder sb = new StringBuilder(GenericUtils.length(path) + numExtra * 8);
+            sb.append(path);
+
+            for (String p2 : extraPaths) {
+                p = p.resolve(p2);
+                options = getPathResolutionLinkOption(SftpConstants.SSH_FXP_REALPATH, "", p);
+                sb.append('/').append(p2);
+            }
+
+            path = sb.toString();
+        }
+
+        return validateRealPath(id, path, p, options);
+    }
+
+    protected SimpleImmutableEntry<Path, Boolean> doRealPathV345(int id, String path, Path p, LinkOption... options) throws IOException {
+        return validateRealPath(id, path, p, options);
+    }
+
+    /**
+     * @param id      The request identifier
+     * @param path    The original path
+     * @param f       The resolve {@link Path}
+     * @param options The {@link LinkOption}s to use to verify file existence and access
+     * @return A {@link SimpleImmutableEntry} whose key is the <U>absolute <B>normalized</B></U>
+     * {@link Path} and value is a {@link Boolean} indicating its status
+     * @throws IOException If failed to validate the file
+     * @see IoUtils#checkFileExists(Path, LinkOption...)
+     */
+    protected SimpleImmutableEntry<Path, Boolean> validateRealPath(int id, String path, Path f, LinkOption... options) throws IOException {
+        Path p = normalize(f);
+        Boolean status = IoUtils.checkFileExists(p, options);
+        return new SimpleImmutableEntry<>(p, status);
+    }
+
+    protected void doRemoveDirectory(Buffer buffer, int id) throws IOException {
+        String path = buffer.getString();
+        try {
+            doRemoveDirectory(id, path, IoUtils.getLinkOptions(false));
+        } catch (IOException | RuntimeException e) {
+            sendStatus(BufferUtils.clear(buffer), id, e, SftpConstants.SSH_FXP_RMDIR, path);
+            return;
+        }
+
+        sendStatus(BufferUtils.clear(buffer), id, SftpConstants.SSH_FX_OK, "");
+    }
+
+    protected void doRemoveDirectory(int id, String path, LinkOption... options) throws IOException {
+        Path p = resolveFile(path);
+        if (log.isDebugEnabled()) {
+            log.debug("doRemoveDirectory({})[id={}] SSH_FXP_RMDIR (path={})[{}]",
+                      getServerSession(), id, path, p);
+        }
+        if (Files.isDirectory(p, options)) {
+            doRemove(id, p);
+        } else {
+            throw new NotDirectoryException(p.toString());
+        }
+    }
+
+    /**
+     * Called when need to delete a file / directory - also informs the {@link SftpEventListener}
+     *
+     * @param id Deletion request ID
+     * @param p {@link Path} to delete
+     * @throws IOException If failed to delete
+     */
+    protected void doRemove(int id, Path p) throws IOException {
+        SftpEventListener listener = getSftpEventListenerProxy();
+        ServerSession session = getServerSession();
+        listener.removing(session, p);
+        try {
+            Files.delete(p);
+        } catch (IOException | RuntimeException e) {
+            listener.removed(session, p, e);
+            throw e;
+        }
+        listener.removed(session, p, null);
+    }
+
+    protected void doMakeDirectory(Buffer buffer, int id) throws IOException {
+        String path = buffer.getString();
+        Map<String, ?> attrs = readAttrs(buffer);
+        try {
+            doMakeDirectory(id, path, attrs, IoUtils.getLinkOptions(false));
+        } catch (IOException | RuntimeException e) {
+            sendStatus(BufferUtils.clear(buffer), id, e, SftpConstants.SSH_FXP_MKDIR, path, attrs);
+            return;
+        }
+
+        sendStatus(BufferUtils.clear(buffer), id, SftpConstants.SSH_FX_OK, "");
+    }
+
+    protected void doMakeDirectory(int id, String path, Map<String, ?> attrs, LinkOption... options) throws IOException {
+        Path p = resolveFile(path);
+        if (log.isDebugEnabled()) {
+            log.debug("doMakeDirectory({})[id={}] SSH_FXP_MKDIR (path={}[{}], attrs={})",
+                      getServerSession(), id, path, p, attrs);
+        }
+
+        Boolean status = IoUtils.checkFileExists(p, options);
+        if (status == null) {
+            throw new AccessDeniedException(p.toString(), p.toString(), "Cannot validate make-directory existence");
+        }
+
+        if (status) {
+            if (Files.isDirectory(p, options)) {
+                throw new FileAlreadyExistsException(p.toString(), p.toString(), "Target directory already exists");
+            } else {
+                throw new FileAlreadyExistsException(p.toString(), p.toString(), "Already exists as a file");
+            }
+        } else {
+            SftpEventListener listener = getSftpEventListenerProxy();
+            ServerSession session = getServerSession();
+            listener.creating(session, p, attrs);
+            try {
+                Files.createDirectory(p);
+                doSetAttributes(p, attrs);
+            } catch (IOException | RuntimeException e) {
+                listener.created(session, p, attrs, e);
+                throw e;
+            }
+            listener.created(session, p, attrs, null);
+        }
+    }
+
+    protected void doRemove(Buffer buffer, int id) throws IOException {
+        String path = buffer.getString();
+        try {
+            /*
+             * If 'filename' is a symbolic link, the link is removed,
+             * not the file it points to.
+             */
+            doRemove(id, path, IoUtils.getLinkOptions(false));
+        } catch (IOException | RuntimeException e) {
+            sendStatus(BufferUtils.clear(buffer), id, e, SftpConstants.SSH_FXP_REMOVE, path);
+            return;
+        }
+
+        sendStatus(BufferUtils.clear(buffer), id, SftpConstants.SSH_FX_OK, "");
+    }
+
+    protected void doRemove(int id, String path, LinkOption... options) throws IOException {
+        Path p = resolveFile(path);
+        if (log.isDebugEnabled()) {
+            log.debug("doRemove({})[id={}] SSH_FXP_REMOVE (path={}[{}])",
+                      getServerSession(), id, path, p);
+        }
+
+        Boolean status = IoUtils.checkFileExists(p, options);
+        if (status == null) {
+            throw new AccessDeniedException(p.toString(), p.toString(), "Cannot determine existence of remove candidate");
+        }
+        if (!status) {
+            throw new NoSuchFileException(p.toString(), p.toString(), "Removal candidate not found");
+        } else if (Files.isDirectory(p, options)) {
+            throw new SftpException(SftpConstants.SSH_FX_FILE_IS_A_DIRECTORY, p.toString() + " is a folder");
+        } else {
+            doRemove(id, p);
+        }
+    }
+
+    protected void doExtended(Buffer buffer, int id) throws IOException {
+        executeExtendedCommand(buffer, id, buffer.getString());
+    }
+
+    /**
+     * @param buffer    The command {@link Buffer}
+     * @param id        The request id
+     * @param extension The extension name
+     * @throws IOException If failed to execute the extension
+     */
+    protected abstract void executeExtendedCommand(Buffer buffer, int id, String extension) throws IOException;
+
+    protected void appendExtensions(Buffer buffer, String supportedVersions) {
+        appendVersionsExtension(buffer, supportedVersions);
+        appendNewlineExtension(buffer, resolveNewlineValue(getServerSession()));
+        appendVendorIdExtension(buffer, VersionProperties.getVersionProperties());
+        appendOpenSSHExtensions(buffer);
+        appendAclSupportedExtension(buffer);
+
+        Map<String, OptionalFeature> extensions = getSupportedClientExtensions();
+        int numExtensions = GenericUtils.size(extensions);
+        List<String> extras = (numExtensions <= 0) ? Collections.emptyList() : new ArrayList<>(numExtensions);
+        if (numExtensions > 0) {
+            ServerSession session = getServerSession();
+            boolean debugEnabled = log.isDebugEnabled();
+            extensions.forEach((name, f) -> {
+                if (!f.isSupported()) {
+                    if (debugEnabled) {
+                        log.debug("appendExtensions({}) skip unsupported extension={}", session, name);
+                    }
+                    return;
+                }
+
+                extras.add(name);
+            });
+        }
+        appendSupportedExtension(buffer, extras);
+        appendSupported2Extension(buffer, extras);
+    }
+
+    protected int appendAclSupportedExtension(Buffer buffer) {
+        ServerSession session = getServerSession();
+        Collection<Integer> maskValues = resolveAclSupportedCapabilities(session);
+        int mask = AclSupportedParser.AclCapabilities.constructAclCapabilities(maskValues);
+        if (mask != 0) {
+            if (log.isTraceEnabled()) {
+                log.trace("appendAclSupportedExtension({}) capabilities={}",
+                          session, AclSupportedParser.AclCapabilities.decodeAclCapabilities(mask));
+            }
+
+            buffer.putString(SftpConstants.EXT_ACL_SUPPORTED);
+
+            // placeholder for length
+            int lenPos = buffer.wpos();
+            buffer.putInt(0);
+            buffer.putInt(mask);
+            BufferUtils.updateLengthPlaceholder(buffer, lenPos);
+        }
+
+        return mask;
+    }
+
+    protected Collection<Integer> resolveAclSupportedCapabilities(ServerSession session) {
+        String override = session.getString(ACL_SUPPORTED_MASK_PROP);
+        if (override == null) {
+            return DEFAULT_ACL_SUPPORTED_MASK;
+        }
+
+        // empty means not supported
+        if (log.isDebugEnabled()) {
+            log.debug("resolveAclSupportedCapabilities({}) override='{}'", session, override);
+        }
+
+        if (override.length() == 0) {
+            return Collections.emptySet();
+        }
+
+        String[] names = GenericUtils.split(override, ',');
+        Set<Integer> maskValues = new HashSet<>(names.length);
+        for (String n : names) {
+            Integer v = ValidateUtils.checkNotNull(
+                AclSupportedParser.AclCapabilities.getAclCapabilityValue(n), "Unknown ACL capability: %s", n);
+            maskValues.add(v);
+        }
+
+        return maskValues;
+    }
+
+    protected List<OpenSSHExtension> appendOpenSSHExtensions(Buffer buffer) {
+        List<OpenSSHExtension> extList = resolveOpenSSHExtensions(getServerSession());
+        if (GenericUtils.isEmpty(extList)) {
+            return extList;
+        }
+
+        for (OpenSSHExtension ext : extList) {
+            buffer.putString(ext.getName());
+            buffer.putString(ext.getVersion());
+        }
+
+        return extList;
+    }
+
+    protected List<OpenSSHExtension> resolveOpenSSHExtensions(ServerSession session) {
+        String value = session.getString(OPENSSH_EXTENSIONS_PROP);
+        if (value == null) {    // No override
+            return DEFAULT_OPEN_SSH_EXTENSIONS;
+        }
+
+        if (log.isDebugEnabled()) {
+            log.debug("resolveOpenSSHExtensions({}) override='{}'", session, value);
+        }
+
+        String[] pairs = GenericUtils.split(value, ',');
+        int numExts = GenericUtils.length(pairs);
+        if (numExts <= 0) {     // User does not want to report ANY extensions
+            return Collections.emptyList();
+        }
+
+        List<OpenSSHExtension> extList = new ArrayList<>(numExts);
+        for (String nvp : pairs) {
+            nvp = GenericUtils.trimToEmpty(nvp);
+            if (GenericUtils.isEmpty(nvp)) {
+                continue;
+            }
+
+            int pos = nvp.indexOf('=');
+            ValidateUtils.checkTrue((pos > 0) && (pos < (nvp.length() - 1)), "Malformed OpenSSH extension spec: %s", nvp);
+            String name = GenericUtils.trimToEmpty(nvp.substring(0, pos));
+            String version = GenericUtils.trimToEmpty(nvp.substring(pos + 1));
+            extList.add(new OpenSSHExtension(name, ValidateUtils.checkNotNullAndNotEmpty(version, "No version specified for OpenSSH extension %s", name)));
+        }
+
+        return extList;
+    }
+
+    protected Map<String, OptionalFeature> getSupportedClientExtensions() {
+        ServerSession session = getServerSession();
+        String value = session.getString(CLIENT_EXTENSIONS_PROP);
+        if (value == null) {
+            return DEFAULT_SUPPORTED_CLIENT_EXTENSIONS;
+        }
+
+        if (log.isDebugEnabled()) {
+            log.debug("getSupportedClientExtensions({}) override='{}'", session, value);
+        }
+
+        if (value.length() <= 0) {  // means don't report any extensions
+            return Collections.emptyMap();
+        }
+
+        if (value.indexOf(',') <= 0) {
+            return Collections.singletonMap(value, OptionalFeature.TRUE);
+        }
+
+        String[] comps = GenericUtils.split(value, ',');
+        Map<String, OptionalFeature> result = new LinkedHashMap<>(comps.length);
+        for (String c : comps) {
+            result.put(c, OptionalFeature.TRUE);
+        }
+
+        return result;
+    }
+
+    /**
+     * Appends the &quot;versions&quot; extension to the buffer. <B>Note:</B>
+     * if overriding this method make sure you either do not append anything
+     * or use the correct extension name
+     *
+     * @param buffer The {@link Buffer} to append to
+     * @param value  The recommended value - ignored if {@code null}/empty
+     * @see SftpConstants#EXT_VERSIONS
+     */
+    protected void appendVersionsExtension(Buffer buffer, String value) {
+        if (GenericUtils.isEmpty(value)) {
+            return;
+        }
+
+        if (log.isDebugEnabled()) {
+            log.debug("appendVersionsExtension({}) value={}", getServerSession(), value);
+        }
+
+        buffer.putString(SftpConstants.EXT_VERSIONS);
+        buffer.putString(value);
+    }
+
+    /**
+     * Appends the &quot;newline&quot; extension to the buffer. <B>Note:</B>
+     * if overriding this method make sure you either do not append anything
+     * or use the correct extension name
+     *
+     * @param buffer The {@link Buffer} to append to
+     * @param value  The recommended value - ignored if {@code null}/empty
+     * @see SftpConstants#EXT_NEWLINE
+     */
+    protected void appendNewlineExtension(Buffer buffer, String value) {
+        if (GenericUtils.isEmpty(value)) {
+            return;
+        }
+
+        if (log.isDebugEnabled()) {
+            log.debug("appendNewlineExtension({}) value={}",
+                      getServerSession(), BufferUtils.toHex(':', value.getBytes(StandardCharsets.UTF_8)));
+        }
+
+        buffer.putString(SftpConstants.EXT_NEWLINE);
+        buffer.putString(value);
+    }
+
+    protected String resolveNewlineValue(ServerSession session) {
+        String value = session.getString(NEWLINE_VALUE);
+        if (value == null) {
+            return IoUtils.EOL;
+        } else {
+            return value;   // empty means disabled
+        }
+    }
+
+    /**
+     * Appends the &quot;vendor-id&quot; extension to the buffer. <B>Note:</B>
+     * if overriding this method make sure you either do not append anything
+     * or use the correct extension name
+     *
+     * @param buffer            The {@link Buffer} to append to
+     * @param versionProperties The currently available version properties - ignored
+     * if {@code null}/empty. The code expects the following values:
+     * <UL>
+     *     <LI>{@code groupId} - as the vendor name</LI>
+     *     <LI>{@code artifactId} - as the product name</LI>
+     *     <LI>{@code version} - as the product version</LI>
+     * </UL>
+     * @see SftpConstants#EXT_VENDOR_ID
+     * @see <A HREF="http://tools.ietf.org/wg/secsh/draft-ietf-secsh-filexfer/draft-ietf-secsh-filexfer-09.txt">DRAFT 09 - section 4.4</A>
+     */
+    protected void appendVendorIdExtension(Buffer buffer, Map<String, ?> versionProperties) {
+        if (GenericUtils.isEmpty(versionProperties)) {
+            return;
+        }
+
+        if (log.isDebugEnabled()) {
+            log.debug("appendVendorIdExtension({}): {}", getServerSession(), versionProperties);
+        }
+        buffer.putString(SftpConstants.EXT_VENDOR_ID);
+
+        PropertyResolver resolver = PropertyResolverUtils.toPropertyResolver(Collections.unmodifiableMap(versionProperties));
+        // placeholder for length
+        int lenPos = buffer.wpos();
+        buffer.putInt(0);
+        buffer.putString(resolver.getStringProperty("groupId", getClass().getPackage().getName()));   // vendor-name
+        buffer.putString(resolver.getStringProperty("artifactId", getClass().getSimpleName()));       // product-name
+        buffer.putString(resolver.getStringProperty("version", FactoryManager.DEFAULT_VERSION));      // product-version
+        buffer.putLong(0L); // product-build-number
+        BufferUtils.updateLengthPlaceholder(buffer, lenPos);
+    }
+
+    /**
+     * Appends the &quot;supported&quot; extension to the buffer. <B>Note:</B>
+     * if overriding this method make sure you either do not append anything
+     * or use the correct extension name
+     *
+     * @param buffer The {@link Buffer} to append to
+     * @param extras The extra extensions that are available and can be reported
+     * - may be {@code null}/empty
+     */
+    protected void appendSupportedExtension(Buffer buffer, Collection<String> extras) {
+        buffer.putString(SftpConstants.EXT_SUPPORTED);
+
+        int lenPos = buffer.wpos();
+        buffer.putInt(0); // length placeholder
+        // supported-attribute-mask
+        buffer.putInt(SftpConstants.SSH_FILEXFER_ATTR_SIZE | SftpConstants.SSH_FILEXFER_ATTR_PERMISSIONS
+                | SftpConstants.SSH_FILEXFER_ATTR_ACCESSTIME | SftpConstants.SSH_FILEXFER_ATTR_CREATETIME
+                | SftpConstants.SSH_FILEXFER_ATTR_MODIFYTIME | SftpConstants.SSH_FILEXFER_ATTR_OWNERGROUP
+                | SftpConstants.SSH_FILEXFER_ATTR_BITS);
+        // TODO: supported-attribute-bits
+        buffer.putInt(0);
+        // supported-open-flags
+        buffer.putInt(SftpConstants.SSH_FXF_READ | SftpConstants.SSH_FXF_WRITE | SftpConstants.SSH_FXF_APPEND
+                | SftpConstants.SSH_FXF_CREAT | SftpConstants.SSH_FXF_TRUNC | SftpConstants.SSH_FXF_EXCL);
+        // TODO: supported-access-mask
+        buffer.putInt(0);
+        // max-read-size
+        buffer.putInt(0);
+        // supported extensions
+        buffer.putStringList(extras, false);
+
+        BufferUtils.updateLengthPlaceholder(buffer, lenPos);
+    }
+
+    /**
+     * Appends the &quot;supported2&quot; extension to the buffer. <B>Note:</B>
+     * if overriding this method make sure you either do not append anything
+     * or use the correct extension name
+     *
+     * @param buffer The {@link Buffer} to append to
+     * @param extras The extra extensions that are available and can be reported
+     * - may be {@code null}/empty
+     * @see SftpConstants#EXT_SUPPORTED
+     * @see <A HREF="https://tools.ietf.org/html/draft-ietf-secsh-filexfer-13#page-10">DRAFT 13 section 5.4</A>
+     */
+    protected void appendSupported2Extension(Buffer buffer, Collection<String> extras) {
+        buffer.putString(SftpConstants.EXT_SUPPORTED2);
+
+        int lenPos = buffer.wpos();
+        buffer.putInt(0); // length placeholder
+        // supported-attribute-mask
+        buffer.putInt(SftpConstants.SSH_FILEXFER_ATTR_SIZE | SftpConstants.SSH_FILEXFER_ATTR_PERMISSIONS
+                | SftpConstants.SSH_FILEXFER_ATTR_ACCESSTIME | SftpConstants.SSH_FILEXFER_ATTR_CREATETIME
+                | SftpConstants.SSH_FILEXFER_ATTR_MODIFYTIME | SftpConstants.SSH_FILEXFER_ATTR_OWNERGROUP
+                | SftpConstants.SSH_FILEXFER_ATTR_BITS);
+        // TODO: supported-attribute-bits
+        buffer.putInt(0);
+        // supported-open-flags
+        buffer.putInt(SftpConstants.SSH_FXF_ACCESS_DISPOSITION | SftpConstants.SSH_FXF_APPEND_DATA);
+        // TODO: supported-access-mask
+        buffer.putInt(0);
+        // max-read-size
+        buffer.putInt(0);
+        // supported-open-block-vector
+        buffer.putShort(0);
+        // supported-block-vector
+        buffer.putShort(0);
+        // attrib-extension-count + attributes name
+        buffer.putStringList(Collections.<String>emptyList(), true);
+        // extension-count + supported extensions
+        buffer.putStringList(extras, true);
+
+        BufferUtils.updateLengthPlaceholder(buffer, lenPos);
+    }
+
+    protected void sendHandle(Buffer buffer, int id, String handle) throws IOException {
+        buffer.putByte((byte) SftpConstants.SSH_FXP_HANDLE);
+        buffer.putInt(id);
+        buffer.putString(handle);
+        send(buffer);
+    }
+
+    protected void sendAttrs(Buffer buffer, int id, Map<String, ?> attributes) throws IOException {
+        buffer.putByte((byte) SftpConstants.SSH_FXP_ATTRS);
+        buffer.putInt(id);
+        writeAttrs(buffer, attributes);
+        send(buffer);
+    }
+
+    protected void sendLink(Buffer buffer, int id, String link) throws IOException {
+        //in case we are running on Windows
+        String unixPath = link.replace(File.separatorChar, '/');
+
+        buffer.putByte((byte) SftpConstants.SSH_FXP_NAME);
+        buffer.putInt(id);
+        buffer.putInt(1);   // one response
+        buffer.putString(unixPath);
+
+        /*
+         * As per the spec (https://tools.ietf.org/html/draft-ietf-secsh-filexfer-02#section-6.10):
+         *
+         *      The server will respond with a SSH_FXP_NAME packet containing only
+         *      one name and a dummy attributes value.
+         */
+        Map<String, Object> attrs = Collections.emptyMap();
+        int version = getVersion();
+        if (version == SftpConstants.SFTP_V3) {
+            buffer.putString(SftpHelper.getLongName(unixPath, attrs));
+        }
+
+        writeAttrs(buffer, attrs);
+        SftpHelper.indicateEndOfNamesList(buffer, getVersion(), getServerSession());
+        send(buffer);
+    }
+
+    protected void sendPath(Buffer buffer, int id, Path f, Map<String, ?> attrs) throws IOException {
+        buffer.putByte((byte) SftpConstants.SSH_FXP_NAME);
+        buffer.putInt(id);
+        buffer.putInt(1);   // one reply
+
+        String originalPath = f.toString();
+        //in case we are running on Windows
+        String unixPath = originalPath.replace(File.separatorChar, '/');
+        buffer.putString(unixPath);
+
+        int version = getVersion();
+        if (version == SftpConstants.SFTP_V3) {
+            buffer.putString(getLongName(f, getShortName(f), attrs));
+        }
+
+        writeAttrs(buffer, attrs);
+        SftpHelper.indicateEndOfNamesList(buffer, getVersion(), getServerSession());
+        send(buffer);
+    }
+
+    /**
+     * @param id      Request id
+     * @param handle  The (opaque) handle assigned to this directory
+     * @param dir     The {@link DirectoryHandle}
+     * @param buffer  The {@link Buffer} to write the results
+     * @param maxSize Max. buffer size
+     * @param options The {@link LinkOption}-s to use when querying the directory contents
+     * @return Number of written entries
+     * @throws IOException If failed to generate an entry
+     */
+    protected int doReadDir(
+            int id, String handle, DirectoryHandle dir, Buffer buffer, int maxSize, LinkOption... options) throws IOException {
+        int nb = 0;
+        Map<String, Path> entries = new TreeMap<>(Comparator.naturalOrder());
+        while ((dir.isSendDot() || dir.isSendDotDot() || dir.hasNext()) && (buffer.wpos() < maxSize)) {
+            if (dir.isSendDot()) {
+                writeDirEntry(id, dir, entries, buffer, nb, dir.getFile(), ".", options);
+                dir.markDotSent();    // do not send it again
+            } else if (dir.isSendDotDot()) {
+                Path dirPath = dir.getFile();
+                writeDirEntry(id, dir, entries, buffer, nb, dirPath.getParent(), "..", options);
+                dir.markDotDotSent(); // do not send it again
+            } else {
+                Path f = dir.next();
+                writeDirEntry(id, dir, entries, buffer, nb, f, getShortName(f), options);
+            }
+
+            nb++;
+        }
+
+        SftpEventListener listener = getSftpEventListenerProxy();
+        listener.read(getServerSession(), handle, dir, entries);
+        return nb;
+    }
+
+    /**
+     * @param id        Request id
+     * @param dir       The {@link DirectoryHandle}
+     * @param entries   An in / out {@link Map} for updating the written entry -
+     *                  key = short name, value = entry {@link Path}
+     * @param buffer    The {@link Buffer} to write the results
+     * @param index     Zero-based index of the entry to be written
+     * @param f         The entry {@link Path}
+     * @param shortName The entry short name
+     * @param options   The {@link LinkOption}s to use for querying the entry-s attributes
+     * @throws IOException If failed to generate the entry data
+     */
+    protected void writeDirEntry(
+            int id, DirectoryHandle dir, Map<String, Path> entries, Buffer buffer, int index, Path f, String shortName, LinkOption... options)
+                    throws IOException {
+        Map<String, ?> attrs = resolveFileAttributes(f, SftpConstants.SSH_FILEXFER_ATTR_ALL, options);
+        entries.put(shortName, f);
+
+        buffer.putString(shortName);
+        int version = getVersion();
+        if (version == SftpConstants.SFTP_V3) {
+            String longName = getLongName(f, shortName, options);
+            buffer.putString(longName);
+            if (log.isTraceEnabled()) {
+                log.trace("writeDirEntry(" + getServerSession() + ") id=" + id + ")[" + index + "] - "
+                        + shortName + " [" + longName + "]: " + attrs);
+            }
+        } else {
+            if (log.isTraceEnabled()) {
+                log.trace("writeDirEntry(" + getServerSession() + "(id=" + id + ")[" + index + "] - "
+                        + shortName + ": " + attrs);
+            }
+        }
+
+        writeAttrs(buffer, attrs);
+    }
+
+    protected String getLongName(Path f, String shortName, LinkOption... options) throws IOException {
+        return getLongName(f, shortName, true, options);
+    }
+
+    protected String getLongName(Path f, String shortName, boolean sendAttrs, LinkOption... options) throws IOException {
+        Map<String, Object> attributes;
+        if (sendAttrs) {
+            attributes = getAttributes(f, options);
+        } else {
+            attributes = Collections.emptyMap();
+        }
+        return getLongName(f, shortName, attributes);
+    }
+
+    protected String getLongName(Path f, String shortName, Map<String, ?> attributes) throws IOException {
+        return SftpHelper.getLongName(shortName, attributes);
+    }
+
+    protected String getShortName(Path f) throws IOException {
+        Path nrm = normalize(f);
+        int  count = nrm.getNameCount();
+        /*
+         * According to the javadoc:
+         *
+         *      The number of elements in the path, or 0 if this path only
+         *      represents a root component
+         */
+        if (OsUtils.isUNIX()) {
+            Path name = f.getFileName();
+            if (name == null) {
+                Path p = resolveFile(".");
+                name = p.getFileName();
+            }
+
+            if (name == null) {
+                if (count > 0) {
+                    name = nrm.getFileName();
+                }
+            }
+
+            if (name != null) {
+                return name.toString();
+            } else {
+                return nrm.toString();
+            }
+        } else {    // need special handling for Windows root drives
+            if (count > 0) {
+                Path name = nrm.getFileName();
+                return name.toString();
+            } else {
+                return nrm.toString().replace(File.separatorChar, '/');
+            }
+        }
+    }
+
+    protected NavigableMap<String, Object> resolveFileAttributes(Path file, int flags, LinkOption... options) throws IOException {
+        Boolean status = IoUtils.checkFileExists(file, options);
+        if (status == null) {
+            return handleUnknownStatusFileAttributes(file, flags, options);
+        } else if (!status) {
+            throw new NoSuchFileException(file.toString(), file.toString(), "Attributes N/A for target");
+        } else {
+            return getAttributes(file, flags, options);
+        }
+    }
+
+    protected void writeAttrs(Buffer buffer, Map<String, ?> attributes) throws IOException {
+        SftpHelper.writeAttrs(buffer, getVersion(), attributes);
+    }
+
+    protected NavigableMap<String, Object> getAttributes(Path file, LinkOption... options) throws IOException {
+        return getAttributes(file, SftpConstants.SSH_FILEXFER_ATTR_ALL, options);
+    }
+
+    protected NavigableMap<String, Object> handleUnknownStatusFileAttributes(Path file, int flags, LinkOption... options) throws IOException {
+        UnsupportedAttributePolicy unsupportedAttributePolicy = getUnsupportedAttributePolicy();
+        switch (unsupportedAttributePolicy) {
+            case Ignore:
+                break;
+            case ThrowException:
+                throw new AccessDeniedException(file.toString(), file.toString(), "Cannot determine existence for attributes of target");
+            case Warn:
+                log.warn("handleUnknownStatusFileAttributes(" + getServerSession() + ")[" + file + "] cannot determine existence");
+                break;
+            default:
+                log.warn("handleUnknownStatusFileAttributes(" + getServerSession() + ")[" + file + "] unknown policy: " + unsupportedAttributePolicy);
+        }
+
+        return getAttributes(file, flags, options);
+    }
+
+    /**
+     * @param file The {@link Path} location for the required attributes
+     * @param flags A mask of the original required attributes - ignored by the
+     * default implementation
+     * @param options The {@link LinkOption}s to use in order to access the file
+     * if necessary
+     * @return A {@link Map} of the retrieved attributes
+     * @throws IOException If failed to access the file
+     * @see #resolveMissingFileAttributes(Path, int, Map, LinkOption...)
+     */
+    protected NavigableMap<String, Object> getAttributes(Path file, int flags, LinkOption... options) throws IOException {
+        FileSystem fs = file.getFileSystem();
+        Collection<String> supportedViews = fs.supportedFileAttributeViews();
+        NavigableMap<String, Object> attrs = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
+        Collection<String> views;
+
+        if (GenericUtils.isEmpty(supportedViews)) {
+            views = Collections.emptyList();
+        } else if (supportedViews.contains("unix")) {
+            view

<TRUNCATED>