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 2015/10/22 11:06:12 UTC

mina-sshd git commit: [SSHD-108] Add upload monitoring to sftp

Repository: mina-sshd
Updated Branches:
  refs/heads/master e1b8bc40c -> 0c443af5c


[SSHD-108] Add upload monitoring to sftp


Project: http://git-wip-us.apache.org/repos/asf/mina-sshd/repo
Commit: http://git-wip-us.apache.org/repos/asf/mina-sshd/commit/0c443af5
Tree: http://git-wip-us.apache.org/repos/asf/mina-sshd/tree/0c443af5
Diff: http://git-wip-us.apache.org/repos/asf/mina-sshd/diff/0c443af5

Branch: refs/heads/master
Commit: 0c443af5c603cbbe2fd4e65a78c0d59814fd568f
Parents: e1b8bc4
Author: Lyor Goldstein <lg...@vmware.com>
Authored: Thu Oct 22 12:05:54 2015 +0300
Committer: Lyor Goldstein <lg...@vmware.com>
Committed: Thu Oct 22 12:05:54 2015 +0300

----------------------------------------------------------------------
 pom.xml                                         |   2 +-
 .../sftp/AbstractSftpEventListenerManager.java  |  58 +++
 .../server/subsystem/sftp/DirectoryHandle.java  |   1 +
 .../sshd/server/subsystem/sftp/FileHandle.java  |   8 +-
 .../sshd/server/subsystem/sftp/Handle.java      |  13 +-
 .../subsystem/sftp/SftpEventListener.java       | 267 ++++++++++++++
 .../sftp/SftpEventListenerManager.java          |  48 +++
 .../server/subsystem/sftp/SftpSubsystem.java    | 221 ++++++++++--
 .../subsystem/sftp/SftpSubsystemFactory.java    |  59 +--
 .../sftp/AbstractSftpClientTestSupport.java     |   3 +-
 .../sshd/client/subsystem/sftp/SftpTest.java    | 360 ++++++++++++++++++-
 11 files changed, 961 insertions(+), 79 deletions(-)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/mina-sshd/blob/0c443af5/pom.xml
----------------------------------------------------------------------
diff --git a/pom.xml b/pom.xml
index 311d803..8e504d7 100644
--- a/pom.xml
+++ b/pom.xml
@@ -730,7 +730,7 @@
 								</module>
 								<!-- <module name="RegexpHeader" /> -->
 								<module name="FileLength">
-									<property name="max" value="3000" />
+									<property name="max" value="3072" />
 								</module>
 								<module name="FileTabCharacter">
 									<property name="eachLine" value="true" />

http://git-wip-us.apache.org/repos/asf/mina-sshd/blob/0c443af5/sshd-core/src/main/java/org/apache/sshd/server/subsystem/sftp/AbstractSftpEventListenerManager.java
----------------------------------------------------------------------
diff --git a/sshd-core/src/main/java/org/apache/sshd/server/subsystem/sftp/AbstractSftpEventListenerManager.java b/sshd-core/src/main/java/org/apache/sshd/server/subsystem/sftp/AbstractSftpEventListenerManager.java
new file mode 100644
index 0000000..b6d9e28
--- /dev/null
+++ b/sshd-core/src/main/java/org/apache/sshd/server/subsystem/sftp/AbstractSftpEventListenerManager.java
@@ -0,0 +1,58 @@
+/*
+ * 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.util.Collection;
+import java.util.concurrent.CopyOnWriteArraySet;
+
+import org.apache.sshd.common.util.EventListenerUtils;
+import org.apache.sshd.common.util.ValidateUtils;
+
+/**
+ * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
+ */
+public abstract class AbstractSftpEventListenerManager implements SftpEventListenerManager {
+    private final Collection<SftpEventListener> sftpEventListeners = new CopyOnWriteArraySet<>();
+    private final SftpEventListener sftpEventListenerProxy;
+
+    protected AbstractSftpEventListenerManager() {
+        sftpEventListenerProxy = EventListenerUtils.proxyWrapper(SftpEventListener.class, getClass().getClassLoader(), sftpEventListeners);
+    }
+
+    public Collection<SftpEventListener> getRegisteredListeners() {
+        return sftpEventListeners;
+    }
+
+    @Override
+    public SftpEventListener getSftpEventListenerProxy() {
+        return sftpEventListenerProxy;
+    }
+
+
+    @Override
+    public boolean addSftpEventListener(SftpEventListener listener) {
+        return sftpEventListeners.add(ValidateUtils.checkNotNull(listener, "No listener"));
+    }
+
+    @Override
+    public boolean removeSftpEventListener(SftpEventListener listener) {
+        return sftpEventListeners.remove(listener);
+    }
+}

http://git-wip-us.apache.org/repos/asf/mina-sshd/blob/0c443af5/sshd-core/src/main/java/org/apache/sshd/server/subsystem/sftp/DirectoryHandle.java
----------------------------------------------------------------------
diff --git a/sshd-core/src/main/java/org/apache/sshd/server/subsystem/sftp/DirectoryHandle.java b/sshd-core/src/main/java/org/apache/sshd/server/subsystem/sftp/DirectoryHandle.java
index b64bce3..9a813ce 100644
--- a/sshd-core/src/main/java/org/apache/sshd/server/subsystem/sftp/DirectoryHandle.java
+++ b/sshd-core/src/main/java/org/apache/sshd/server/subsystem/sftp/DirectoryHandle.java
@@ -88,6 +88,7 @@ public class DirectoryHandle extends Handle implements Iterator<Path> {
 
     @Override
     public void close() throws IOException {
+        super.close();
         markDone(); // just making sure
         ds.close();
     }

http://git-wip-us.apache.org/repos/asf/mina-sshd/blob/0c443af5/sshd-core/src/main/java/org/apache/sshd/server/subsystem/sftp/FileHandle.java
----------------------------------------------------------------------
diff --git a/sshd-core/src/main/java/org/apache/sshd/server/subsystem/sftp/FileHandle.java b/sshd-core/src/main/java/org/apache/sshd/server/subsystem/sftp/FileHandle.java
index 056c513..bd54bdf 100644
--- a/sshd-core/src/main/java/org/apache/sshd/server/subsystem/sftp/FileHandle.java
+++ b/sshd-core/src/main/java/org/apache/sshd/server/subsystem/sftp/FileHandle.java
@@ -111,7 +111,7 @@ public class FileHandle extends Handle {
             channel = FileChannel.open(file, options, attributes);
         } catch (UnsupportedOperationException e) {
             channel = FileChannel.open(file, options);
-            sftpSubsystem.setAttributes(file, attrs);
+            sftpSubsystem.doSetAttributes(file, attrs);
         }
         this.fileChannel = channel;
         this.pos = 0;
@@ -170,8 +170,12 @@ public class FileHandle extends Handle {
 
     @Override
     public void close() throws IOException {
+        super.close();
+
         FileChannel channel = getFileChannel();
-        channel.close();
+        if (channel.isOpen()) {
+            channel.close();
+        }
     }
 
     public void lock(long offset, long length, int mask) throws IOException {

http://git-wip-us.apache.org/repos/asf/mina-sshd/blob/0c443af5/sshd-core/src/main/java/org/apache/sshd/server/subsystem/sftp/Handle.java
----------------------------------------------------------------------
diff --git a/sshd-core/src/main/java/org/apache/sshd/server/subsystem/sftp/Handle.java b/sshd-core/src/main/java/org/apache/sshd/server/subsystem/sftp/Handle.java
index 686cf5d..9253c4a 100644
--- a/sshd-core/src/main/java/org/apache/sshd/server/subsystem/sftp/Handle.java
+++ b/sshd-core/src/main/java/org/apache/sshd/server/subsystem/sftp/Handle.java
@@ -21,11 +21,13 @@ package org.apache.sshd.server.subsystem.sftp;
 import java.io.IOException;
 import java.nio.file.Path;
 import java.util.Objects;
+import java.util.concurrent.atomic.AtomicBoolean;
 
 /**
  * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
  */
-public abstract class Handle implements java.io.Closeable {
+public abstract class Handle implements java.nio.channels.Channel {
+    private final AtomicBoolean closed = new AtomicBoolean(false);
     private Path file;
 
     protected Handle(Path file) {
@@ -37,8 +39,15 @@ public abstract class Handle implements java.io.Closeable {
     }
 
     @Override
+    public boolean isOpen() {
+        return !closed.get();
+    }
+
+    @Override
     public void close() throws IOException {
-        // ignored
+        if (!closed.getAndSet(true)) {
+            return; // debug breakpoint
+        }
     }
 
     @Override

http://git-wip-us.apache.org/repos/asf/mina-sshd/blob/0c443af5/sshd-core/src/main/java/org/apache/sshd/server/subsystem/sftp/SftpEventListener.java
----------------------------------------------------------------------
diff --git a/sshd-core/src/main/java/org/apache/sshd/server/subsystem/sftp/SftpEventListener.java b/sshd-core/src/main/java/org/apache/sshd/server/subsystem/sftp/SftpEventListener.java
new file mode 100644
index 0000000..a52f820
--- /dev/null
+++ b/sshd-core/src/main/java/org/apache/sshd/server/subsystem/sftp/SftpEventListener.java
@@ -0,0 +1,267 @@
+/*
+ * 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.nio.file.CopyOption;
+import java.nio.file.Path;
+import java.util.Collection;
+import java.util.EventListener;
+import java.util.Map;
+
+import org.apache.sshd.server.session.ServerSession;
+
+/**
+ * Can be used register for SFTP events. <B>Note:</B> it does not expose
+ * the entire set of available SFTP commands and responses (e.g., no reports
+ * for initialization, extensions, parameters re-negotiation, etc...);
+ *
+ * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
+ */
+public interface SftpEventListener extends EventListener {
+    /**
+     * Called when the SFTP protocol has been initialized
+     *
+     * @param session The {@link ServerSession} through which the request was handled
+     * @param version The negotiated SFTP version
+     */
+    void initialized(ServerSession session, int version);
+
+    /**
+     * Called when subsystem is destroyed since it was closed
+     *
+     * @param session The associated {@link ServerSession}
+     */
+    void destroying(ServerSession session);
+
+    /**
+     * Specified file / directory has been opened
+     *
+     * @param session      The {@link ServerSession} through which the request was handled
+     * @param remoteHandle The (opaque) assigned handle for the file / directory
+     * @param localHandle  The associated file / directory {@link Handle}
+     */
+    void open(ServerSession session, String remoteHandle, Handle localHandle);
+
+    /**
+     * Result of reading entries from a directory - <B>Note:</B> it may be a
+     * <U>partial</U> result if the directory contains more entries than can
+     * be accommodated in the response
+     *
+     * @param session      The {@link ServerSession} through which the request was handled
+     * @param remoteHandle The (opaque) assigned handle for the directory
+     * @param localHandle  The associated {@link DirectoryHandle}
+     * @param entries      A {@link Map} of the listed entries - key = short name,
+     *                     value = {@link Path} of the sub-entry
+     */
+    void read(ServerSession session, String remoteHandle, DirectoryHandle localHandle, Map<String, Path> entries);
+
+    /**
+     * Result of reading from a file
+     *
+     * @param session      The {@link ServerSession} through which the request was handled
+     * @param remoteHandle The (opaque) assigned handle for the file
+     * @param localHandle  The associated {@link FileHandle}
+     * @param offset       Offset in file from which to read
+     * @param data         Buffer holding the read data
+     * @param dataOffset   Offset of read data in buffer
+     * @param dataLen      Requested read length
+     * @param readLen      Actual read length
+     */
+    void read(ServerSession session, String remoteHandle, FileHandle localHandle,
+              long offset, byte[] data, int dataOffset, int dataLen, int readLen);
+
+    /**
+     * Result of writing to a file
+     *
+     * @param session      The {@link ServerSession} through which the request was handled
+     * @param remoteHandle The (opaque) assigned handle for the file
+     * @param localHandle  The associated {@link FileHandle}
+     * @param offset       Offset in file to which to write
+     * @param data         Buffer holding the written data
+     * @param dataOffset   Offset of write data in buffer
+     * @param dataLen      Requested write length
+     */
+    void write(ServerSession session, String remoteHandle, FileHandle localHandle,
+               long offset, byte[] data, int dataOffset, int dataLen);
+
+    /**
+     * Called <U>prior</U> to blocking a file section
+     *
+     * @param session      The {@link ServerSession} through which the request was handled
+     * @param remoteHandle The (opaque) assigned handle for the file
+     * @param localHandle  The associated {@link FileHandle}
+     * @param offset       Offset in file for locking
+     * @param length       Section size for locking
+     * @param mask         Lock mask flags - see {@code SSH_FXP_BLOCK} message
+     * @see #blocked(ServerSession, String, FileHandle, long, long, int, Throwable)
+     */
+    void blocking(ServerSession session, String remoteHandle, FileHandle localHandle, long offset, long length, int mask);
+
+    /**
+     * Called <U>after</U> blocking a file section
+     *
+     * @param session      The {@link ServerSession} through which the request was handled
+     * @param remoteHandle The (opaque) assigned handle for the file
+     * @param localHandle  The associated {@link FileHandle}
+     * @param offset       Offset in file for locking
+     * @param length       Section size for locking
+     * @param mask         Lock mask flags - see {@code SSH_FXP_BLOCK} message
+     * @param thrown       If not-{@code null} then the reason for the failure to execute
+     */
+    void blocked(ServerSession session, String remoteHandle, FileHandle localHandle, long offset, long length, int mask, Throwable thrown);
+
+    /**
+     * Called <U>prior</U> to un-blocking a file section
+     *
+     * @param session      The {@link ServerSession} through which the request was handled
+     * @param remoteHandle The (opaque) assigned handle for the file
+     * @param localHandle  The associated {@link FileHandle}
+     * @param offset       Offset in file for un-locking
+     * @param length       Section size for un-locking
+     */
+    void unblocking(ServerSession session, String remoteHandle, FileHandle localHandle, long offset, long length);
+
+    /**
+     * Called <U>prior</U> to un-blocking a file section
+     *
+     * @param session      The {@link ServerSession} through which the request was handled
+     * @param remoteHandle The (opaque) assigned handle for the file
+     * @param localHandle  The associated {@link FileHandle}
+     * @param offset       Offset in file for un-locking
+     * @param length       Section size for un-locking
+     * @param result       If successful (i.e., <tt>thrown</tt> is {@code null}, then whether
+     *                     section was un-blocked
+     * @param thrown       If not-{@code null} then the reason for the failure to execute
+     */
+    void unblocked(ServerSession session, String remoteHandle, FileHandle localHandle, long offset, long length, Boolean result, Throwable thrown);
+
+    /**
+     * Specified file / directory has been closed
+     *
+     * @param session      The {@link ServerSession} through which the request was handled
+     * @param remoteHandle The (opaque) assigned handle for the file / directory
+     * @param localHandle  The associated file / directory {@link Handle}
+     */
+    void close(ServerSession session, String remoteHandle, Handle localHandle);
+
+    /**
+     * Called <U>prior</U> to creating a directory
+     *
+     * @param session The {@link ServerSession} through which the request was handled
+     * @param path    Directory {@link Path} to be created
+     * @param attrs   Requested associated attributes to set
+     * @see #created(ServerSession, Path, Map, Throwable)
+     */
+    void creating(ServerSession session, Path path, Map<String, ?> attrs);
+
+    /**
+     * Called <U>after</U> creating a directory
+     *
+     * @param session The {@link ServerSession} through which the request was handled
+     * @param path    Directory {@link Path} to be created
+     * @param attrs   Requested associated attributes to set
+     * @param thrown  If not-{@code null} then the reason for the failure to execute
+     */
+    void created(ServerSession session, Path path, Map<String, ?> attrs, Throwable thrown);
+
+    /**
+     * Called <U>prior</U> to renaming a file / directory
+     *
+     * @param session The {@link ServerSession} through which the request was handled
+     * @param srcPath The source {@link Path}
+     * @param dstPath The target {@link Path}
+     * @param opts    The resolved renaming options
+     * @see #moved(ServerSession, Path, Path, Collection, Throwable)
+     */
+    void moving(ServerSession session, Path srcPath, Path dstPath, Collection<CopyOption> opts);
+
+    /**
+     * Called <U>after</U> renaming a file / directory
+     *
+     * @param session The {@link ServerSession} through which the request was handled
+     * @param srcPath The source {@link Path}
+     * @param dstPath The target {@link Path}
+     * @param opts    The resolved renaming options
+     * @param thrown  If not-{@code null} then the reason for the failure to execute
+     */
+    void moved(ServerSession session, Path srcPath, Path dstPath, Collection<CopyOption> opts, Throwable thrown);
+
+    /**
+     * Called <U>prior</U> to removing a file / directory
+     *
+     * @param session The {@link ServerSession} through which the request was handled
+     * @param path    The {@link Path} about to be removed
+     * @see #removed(ServerSession, Path, Throwable)
+     */
+    void removing(ServerSession session, Path path);
+
+    /**
+     * Called <U>after</U> a file / directory has been removed
+     *
+     * @param session The {@link ServerSession} through which the request was handled
+     * @param path    The {@link Path} to be removed
+     * @param thrown  If not-{@code null} then the reason for the failure to execute
+     */
+    void removed(ServerSession session, Path path, Throwable thrown);
+
+    /**
+     * Called <U>prior</U> to creating a link
+     *
+     * @param session The {@link ServerSession} through which the request was handled
+     * @param source  The source {@link Path}
+     * @param target  The target {@link Path}
+     * @param symLink {@code true} = symbolic link
+     * @see #linked(ServerSession, Path, Path, boolean, Throwable)
+     */
+    void linking(ServerSession session, Path source, Path target, boolean symLink);
+
+    /**
+     * Called <U>after</U> creating a link
+     *
+     * @param session The {@link ServerSession} through which the request was handled
+     * @param source  The source {@link Path}
+     * @param target  The target {@link Path}
+     * @param symLink {@code true} = symbolic link
+     * @param thrown  If not-{@code null} then the reason for the failure to execute
+     */
+    void linked(ServerSession session, Path source, Path target, boolean symLink, Throwable thrown);
+
+    /**
+     * Called <U>prior</U> to modifying the attributes of a file / directory
+     *
+     * @param session The {@link ServerSession} through which the request was handled
+     * @param path    The file / directory {@link Path} to be modified
+     * @param attrs   The attributes {@link Map} - names and values depend on the
+     *                O/S, view, type, etc...
+     * @see #modifiedAttributes(ServerSession, Path, Map, Throwable)
+     */
+    void modifyingAttributes(ServerSession session, Path path, Map<String, ?> attrs);
+
+    /**
+     * Called <U>after</U> modifying the attributes of a file / directory
+     *
+     * @param session The {@link ServerSession} through which the request was handled
+     * @param path    The file / directory {@link Path} to be modified
+     * @param attrs   The attributes {@link Map} - names and values depend on the
+     *                O/S, view, type, etc...
+     * @param thrown  If not-{@code null} then the reason for the failure to execute
+     */
+    void modifiedAttributes(ServerSession session, Path path, Map<String, ?> attrs, Throwable thrown);
+}

http://git-wip-us.apache.org/repos/asf/mina-sshd/blob/0c443af5/sshd-core/src/main/java/org/apache/sshd/server/subsystem/sftp/SftpEventListenerManager.java
----------------------------------------------------------------------
diff --git a/sshd-core/src/main/java/org/apache/sshd/server/subsystem/sftp/SftpEventListenerManager.java b/sshd-core/src/main/java/org/apache/sshd/server/subsystem/sftp/SftpEventListenerManager.java
new file mode 100644
index 0000000..3f91033
--- /dev/null
+++ b/sshd-core/src/main/java/org/apache/sshd/server/subsystem/sftp/SftpEventListenerManager.java
@@ -0,0 +1,48 @@
+/*
+ * 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;
+
+/**
+ * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
+ */
+public interface SftpEventListenerManager {
+    /**
+     * @return An instance representing <U>all</U> the currently
+     * registered listeners. Any method invocation is <U>replicated</U>
+     * to the actually registered listeners
+     */
+    SftpEventListener getSftpEventListenerProxy();
+
+    /**
+     * Register a listener instance
+     *
+     * @param listener The {@link SftpEventListener} instance to add - never {@code null}
+     * @return {@code true} if listener is a previously un-registered one
+     */
+    boolean addSftpEventListener(SftpEventListener listener);
+
+    /**
+     * Remove a listener instance
+     *
+     * @param listener The {@link SftpEventListener} instance to remove - never {@code null}
+     * @return {@code true} if listener is a (removed) registered one
+     */
+    boolean removeSftpEventListener(SftpEventListener listener);
+}

http://git-wip-us.apache.org/repos/asf/mina-sshd/blob/0c443af5/sshd-core/src/main/java/org/apache/sshd/server/subsystem/sftp/SftpSubsystem.java
----------------------------------------------------------------------
diff --git a/sshd-core/src/main/java/org/apache/sshd/server/subsystem/sftp/SftpSubsystem.java b/sshd-core/src/main/java/org/apache/sshd/server/subsystem/sftp/SftpSubsystem.java
index eb06156..2b2e8dd 100644
--- a/sshd-core/src/main/java/org/apache/sshd/server/subsystem/sftp/SftpSubsystem.java
+++ b/sshd-core/src/main/java/org/apache/sshd/server/subsystem/sftp/SftpSubsystem.java
@@ -61,6 +61,7 @@ import java.util.Map;
 import java.util.Objects;
 import java.util.Set;
 import java.util.TreeMap;
+import java.util.concurrent.CopyOnWriteArraySet;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Future;
 
@@ -78,6 +79,7 @@ import org.apache.sshd.common.subsystem.sftp.SftpConstants;
 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.util.EventListenerUtils;
 import org.apache.sshd.common.util.GenericUtils;
 import org.apache.sshd.common.util.Int2IntFunction;
 import org.apache.sshd.common.util.OsUtils;
@@ -96,13 +98,16 @@ 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;
 
 /**
  * SFTP subsystem
  *
  * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
  */
-public class SftpSubsystem extends AbstractLoggingBean implements Command, Runnable, SessionAware, FileSystemAware {
+public class SftpSubsystem
+        extends AbstractLoggingBean
+        implements Command, Runnable, SessionAware, FileSystemAware, ServerSessionHolder, SftpEventListenerManager {
 
     /**
      * Properties key for the maximum of available open handles per session.
@@ -138,7 +143,7 @@ public class SftpSubsystem extends AbstractLoggingBean implements Command, Runna
     public static final String SFTP_VERSION = "sftp-version";
 
     public static final int LOWER_SFTP_IMPL = SftpConstants.SFTP_V3; // Working implementation from v3
-    public static final int HIGHER_SFTP_IMPL = SftpConstants.SFTP_V6; //  .. up to
+    public static final int HIGHER_SFTP_IMPL = SftpConstants.SFTP_V6; //  .. up to and including
     public static final String ALL_SFTP_IMPL;
 
     /**
@@ -245,7 +250,6 @@ public class SftpSubsystem extends AbstractLoggingBean implements Command, Runna
     protected Random randomizer;
     protected int fileHandleSize = DEFAULT_FILE_HANDLE_SIZE;
     protected int maxFileHandleRounds = DEFAULT_FILE_HANDLE_ROUNDS;
-    protected ServerSession session;
     protected boolean closed;
     protected ExecutorService executors;
     protected boolean shutdownExecutor;
@@ -257,9 +261,12 @@ public class SftpSubsystem extends AbstractLoggingBean implements Command, Runna
     protected int version;
     protected final Map<String, byte[]> extensions = new HashMap<>();
     protected final Map<String, Handle> handles = new HashMap<>();
-
     protected final UnsupportedAttributePolicy unsupportedAttributePolicy;
 
+    private ServerSession serverSession;
+    private final Collection<SftpEventListener> sftpEventListeners = new CopyOnWriteArraySet<>();
+    private final SftpEventListener sftpEventListenerProxy;
+
     /**
      * @param executorService The {@link ExecutorService} to be used by
      *                        the {@link SftpSubsystem} command when starting execution. If
@@ -280,10 +287,8 @@ public class SftpSubsystem extends AbstractLoggingBean implements Command, Runna
             shutdownExecutor = shutdownOnExit;
         }
 
-        if (policy == null) {
-            throw new IllegalArgumentException("No policy provided");
-        }
-        unsupportedAttributePolicy = policy;
+        unsupportedAttributePolicy = ValidateUtils.checkNotNull(policy, "No policy provided");
+        sftpEventListenerProxy = EventListenerUtils.proxyWrapper(SftpEventListener.class, getClass().getClassLoader(), sftpEventListeners);
     }
 
     public int getVersion() {
@@ -295,8 +300,23 @@ public class SftpSubsystem extends AbstractLoggingBean implements Command, Runna
     }
 
     @Override
+    public SftpEventListener getSftpEventListenerProxy() {
+        return sftpEventListenerProxy;
+    }
+
+    @Override
+    public boolean addSftpEventListener(SftpEventListener listener) {
+        return sftpEventListeners.add(ValidateUtils.checkNotNull(listener, "No listener"));
+    }
+
+    @Override
+    public boolean removeSftpEventListener(SftpEventListener listener) {
+        return sftpEventListeners.remove(listener);
+    }
+
+    @Override
     public void setSession(ServerSession session) {
-        this.session = session;
+        this.serverSession = session;
 
         FactoryManager manager = session.getFactoryManager();
         Factory<? extends Random> factory = manager.getRandomFactory();
@@ -316,6 +336,11 @@ public class SftpSubsystem extends AbstractLoggingBean implements Command, Runna
     }
 
     @Override
+    public ServerSession getServerSession() {
+        return serverSession;
+    }
+
+    @Override
     public void setFileSystem(FileSystem fileSystem) {
         if (fileSystem != this.fileSystem) {
             this.fileSystem = fileSystem;
@@ -948,6 +973,7 @@ public class SftpSubsystem extends AbstractLoggingBean implements Command, Runna
 
     protected void doVersionSelect(Buffer buffer, int id) throws IOException {
         String proposed = buffer.getString();
+        ServerSession session = getServerSession();
         /*
          * The 'version-select' MUST be the first request from the client to the
          * server; if it is not, the server MUST fail the request and close the
@@ -1030,6 +1056,7 @@ public class SftpSubsystem extends AbstractLoggingBean implements Command, Runna
         int hig = HIGHER_SFTP_IMPL;
         String available = ALL_SFTP_IMPL;
         // check if user wants to use a specific version
+        ServerSession session = getServerSession();
         Integer sftpVersion = PropertyResolverUtils.getInteger(session, SFTP_VERSION);
         if (sftpVersion != null) {
             int forcedValue = sftpVersion;
@@ -1043,7 +1070,7 @@ public class SftpSubsystem extends AbstractLoggingBean implements Command, Runna
 
         if (log.isTraceEnabled()) {
             log.trace("checkVersionCompatibility(id={}) - proposed={}, available={}",
-                    id, proposed, available);
+                      id, proposed, available);
         }
 
         if ((proposed < low) || (proposed > hig)) {
@@ -1078,7 +1105,16 @@ public class SftpSubsystem extends AbstractLoggingBean implements Command, Runna
         }
 
         FileHandle fileHandle = validateHandle(handle, p, FileHandle.class);
-        fileHandle.lock(offset, length, mask);
+        SftpEventListener listener = getSftpEventListenerProxy();
+        ServerSession session = getServerSession();
+        listener.blocking(session, handle, fileHandle, offset, length, mask);
+        try {
+            fileHandle.lock(offset, length, mask);
+            listener.blocked(session, handle, fileHandle, offset, length, mask, null);
+        } catch (IOException | RuntimeException e) {
+            listener.blocked(session, handle, fileHandle, offset, length, mask, e);
+            throw e;
+        }
     }
 
     protected void doUnblock(Buffer buffer, int id) throws IOException {
@@ -1104,7 +1140,17 @@ public class SftpSubsystem extends AbstractLoggingBean implements Command, Runna
         }
 
         FileHandle fileHandle = validateHandle(handle, p, FileHandle.class);
-        return fileHandle.unlock(offset, length);
+        SftpEventListener listener = getSftpEventListenerProxy();
+        ServerSession session = getServerSession();
+        listener.unblocking(session, handle, fileHandle, offset, length);
+        try {
+            boolean result = fileHandle.unlock(offset, length);
+            listener.unblocked(session, handle, fileHandle, offset, length, Boolean.valueOf(result), null);
+            return result;
+        } catch (IOException | RuntimeException e) {
+            listener.unblocked(session, handle, fileHandle, offset, length, null, e);
+            throw e;
+        }
     }
 
     protected void doLink(Buffer buffer, int id) throws IOException {
@@ -1159,10 +1205,19 @@ public class SftpSubsystem extends AbstractLoggingBean implements Command, Runna
                       id, linkPath, link, targetPath, target, symLink);
         }
 
-        if (symLink) {
-            Files.createSymbolicLink(link, target);
-        } else {
-            Files.createLink(link, target);
+        SftpEventListener listener = getSftpEventListenerProxy();
+        ServerSession session = getServerSession();
+        listener.linking(session, link, target, symLink);
+        try {
+            if (symLink) {
+                Files.createSymbolicLink(link, target);
+            } else {
+                Files.createLink(link, target);
+            }
+            listener.linked(session, link, target, symLink, null);
+        } catch (IOException | RuntimeException e) {
+            listener.linked(session, link, target, symLink, e);
+            throw e;
         }
     }
 
@@ -1231,7 +1286,17 @@ public class SftpSubsystem extends AbstractLoggingBean implements Command, Runna
     protected void doRename(int id, String oldPath, String newPath, Collection<CopyOption> opts) throws IOException {
         Path o = resolveFile(oldPath);
         Path n = resolveFile(newPath);
-        Files.move(o, n, GenericUtils.isEmpty(opts) ? IoUtils.EMPTY_COPY_OPTIONS : opts.toArray(new CopyOption[opts.size()]));
+        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()]));
+            listener.moved(session, o, n, opts, null);
+        } catch (IOException | RuntimeException e) {
+            listener.moved(session, o, n, opts, e);
+            throw e;
+        }
     }
 
     // see https://tools.ietf.org/html/draft-ietf-secsh-filexfer-extensions-00#section-7
@@ -1511,12 +1576,32 @@ public class SftpSubsystem extends AbstractLoggingBean implements Command, Runna
         Path p = resolveFile(path);
         log.debug("Received SSH_FXP_RMDIR (path={})[{}]", path, p);
         if (Files.isDirectory(p, options)) {
-            Files.delete(p);
+            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);
+            listener.removed(session, p, null);
+        } catch (IOException | RuntimeException e) {
+            listener.removed(session, p, e);
+            throw e;
+        }
+    }
+
     protected void doMakeDirectory(Buffer buffer, int id) throws IOException {
         String path = buffer.getString();
         Map<String, Object> attrs = readAttrs(buffer);
@@ -1548,8 +1633,17 @@ public class SftpSubsystem extends AbstractLoggingBean implements Command, Runna
                 throw new FileNotFoundException(p.toString() + " already exists as a file");
             }
         } else {
-            Files.createDirectory(p);
-            setAttributes(p, attrs);
+            SftpEventListener listener = getSftpEventListenerProxy();
+            ServerSession session = getServerSession();
+            listener.creating(session, p, attrs);
+            try {
+                Files.createDirectory(p);
+                doSetAttributes(p, attrs);
+                listener.created(session, p, attrs, null);
+            } catch (IOException | RuntimeException e) {
+                listener.created(session, p, attrs, e);
+                throw e;
+            }
         }
     }
 
@@ -1580,7 +1674,7 @@ public class SftpSubsystem extends AbstractLoggingBean implements Command, Runna
         } else if (Files.isDirectory(p, options)) {
             throw new FileNotFoundException(p.toString() + " is as a folder");
         } else {
-            Files.delete(p);
+            doRemove(id, p);
         }
     }
 
@@ -1622,7 +1716,7 @@ public class SftpSubsystem extends AbstractLoggingBean implements Command, Runna
                 int lenPos = reply.wpos();
                 reply.putInt(0);
 
-                int count = doReadDir(id, dh, reply, PropertyResolverUtils.getIntProperty(session, MAX_PACKET_LENGTH_PROP, DEFAULT_MAX_PACKET_LENGTH));
+                int count = doReadDir(id, handle, dh, reply, PropertyResolverUtils.getIntProperty(getServerSession(), MAX_PACKET_LENGTH_PROP, DEFAULT_MAX_PACKET_LENGTH));
                 BufferUtils.updateLengthPlaceholder(reply, lenPos, count);
                 if (log.isDebugEnabled()) {
                     log.debug("doReadDir({})[{}] - sent {} entries", handle, h, count);
@@ -1677,7 +1771,10 @@ public class SftpSubsystem extends AbstractLoggingBean implements Command, Runna
             throw new AccessDeniedException("Not readable: " + p);
         } else {
             String handle = generateFileHandle(p);
-            handles.put(handle, new DirectoryHandle(p));
+            DirectoryHandle dirHandle = new DirectoryHandle(p);
+            SftpEventListener listener = getSftpEventListenerProxy();
+            listener.open(getServerSession(), handle, dirHandle);
+            handles.put(handle, dirHandle);
             return handle;
         }
     }
@@ -1701,7 +1798,7 @@ public class SftpSubsystem extends AbstractLoggingBean implements Command, Runna
             log.debug("Received SSH_FXP_FSETSTAT (handle={}[{}], attrs={})", handle, h, attrs);
         }
 
-        setAttributes(validateHandle(handle, h, Handle.class).getFile(), attrs);
+        doSetAttributes(validateHandle(handle, h, Handle.class).getFile(), attrs);
     }
 
     protected void doSetStat(Buffer buffer, int id) throws IOException {
@@ -1720,7 +1817,7 @@ public class SftpSubsystem extends AbstractLoggingBean implements Command, Runna
     protected void doSetStat(int id, String path, Map<String, ?> attrs) throws IOException {
         log.debug("Received SSH_FXP_SETSTAT (path={}, attrs={})", path, attrs);
         Path p = resolveFile(path);
-        setAttributes(p, attrs);
+        doSetAttributes(p, attrs);
     }
 
     protected void doFStat(Buffer buffer, int id) throws IOException {
@@ -1812,13 +1909,16 @@ public class SftpSubsystem extends AbstractLoggingBean implements Command, Runna
         } else {
             fh.write(data, doff, length, offset);
         }
+
+        SftpEventListener listener = getSftpEventListenerProxy();
+        listener.write(getServerSession(), handle, fh, offset, data, doff, length);
     }
 
     protected void doRead(Buffer buffer, int id) throws IOException {
         String handle = buffer.getString();
         long offset = buffer.getLong();
         int requestedLength = buffer.getInt();
-        int maxAllowed = PropertyResolverUtils.getIntProperty(session, MAX_PACKET_LENGTH_PROP, DEFAULT_MAX_PACKET_LENGTH);
+        int maxAllowed = PropertyResolverUtils.getIntProperty(getServerSession(), MAX_PACKET_LENGTH_PROP, DEFAULT_MAX_PACKET_LENGTH);
         int readLen = Math.min(requestedLength, maxAllowed);
 
         if (log.isTraceEnabled()) {
@@ -1858,10 +1958,13 @@ public class SftpSubsystem extends AbstractLoggingBean implements Command, Runna
             log.debug("Received SSH_FXP_READ (handle={}[{}], offset={}, length={})",
                     handle, h, offset, length);
         }
-        ValidateUtils.checkTrue(length > 0, "Invalid read length: %d", length);
-        FileHandle fh = validateHandle(handle, h, FileHandle.class);
 
-        return fh.read(data, doff, length, offset);
+        ValidateUtils.checkTrue(length > 0L, "Invalid read length: %d", length);
+        FileHandle fh = validateHandle(handle, h, FileHandle.class);
+        int readLen = fh.read(data, doff, length, offset);
+        SftpEventListener listener = getSftpEventListenerProxy();
+        listener.read(getServerSession(), handle, fh, offset, data, doff, length, readLen);
+        return readLen;
     }
 
     protected void doClose(Buffer buffer, int id) throws IOException {
@@ -1880,6 +1983,9 @@ public class SftpSubsystem extends AbstractLoggingBean implements Command, Runna
         Handle h = handles.remove(handle);
         log.debug("Received SSH_FXP_CLOSE (handle={}[{}])", handle, h);
         validateHandle(handle, h, Handle.class).close();
+
+        SftpEventListener listener = getSftpEventListenerProxy();
+        listener.close(getServerSession(), handle, h);
     }
 
     protected void doOpen(Buffer buffer, int id) throws IOException {
@@ -1963,14 +2069,17 @@ public class SftpSubsystem extends AbstractLoggingBean implements Command, Runna
                     path, Integer.toHexString(access), Integer.toHexString(pflags), attrs);
         }
         int curHandleCount = handles.size();
-        int maxHandleCount = PropertyResolverUtils.getIntProperty(session, MAX_OPEN_HANDLES_PER_SESSION, DEFAULT_MAX_OPEN_HANDLES);
+        int maxHandleCount = PropertyResolverUtils.getIntProperty(getServerSession(), MAX_OPEN_HANDLES_PER_SESSION, DEFAULT_MAX_OPEN_HANDLES);
         if (curHandleCount > maxHandleCount) {
             throw new IllegalStateException("Too many open handles: current=" + curHandleCount + ", max.=" + maxHandleCount);
         }
 
         Path file = resolveFile(path);
         String handle = generateFileHandle(file);
-        handles.put(handle, new FileHandle(this, file, pflags, access, attrs));
+        FileHandle fileHandle = new FileHandle(this, file, pflags, access, attrs);
+        SftpEventListener listener = getSftpEventListenerProxy();
+        listener.open(getServerSession(), handle, fileHandle);
+        handles.put(handle, fileHandle);
         return handle;
     }
 
@@ -2005,6 +2114,7 @@ public class SftpSubsystem extends AbstractLoggingBean implements Command, Runna
         if (GenericUtils.isEmpty(all)) { // i.e. validation failed
             return;
         }
+
         version = id;
         while (buffer.available() > 0) {
             String name = buffer.getString();
@@ -2018,6 +2128,9 @@ public class SftpSubsystem extends AbstractLoggingBean implements Command, Runna
         buffer.putInt(version);
         appendExtensions(buffer, all);
 
+        SftpEventListener listener = getSftpEventListenerProxy();
+        listener.initialized(getServerSession(), version);
+
         send(buffer);
     }
 
@@ -2054,7 +2167,7 @@ public class SftpSubsystem extends AbstractLoggingBean implements Command, Runna
     }
 
     protected List<OpenSSHExtension> resolveOpenSSHExtensions() {
-        String value = PropertyResolverUtils.getString(session, OPENSSH_EXTENSIONS_PROP);
+        String value = PropertyResolverUtils.getString(getServerSession(), OPENSSH_EXTENSIONS_PROP);
         if (value == null) {    // No override
             return DEFAULT_OPEN_SSH_EXTENSIONS;
         }
@@ -2083,7 +2196,7 @@ public class SftpSubsystem extends AbstractLoggingBean implements Command, Runna
     }
 
     protected Collection<String> getSupportedClientExtensions() {
-        String value = PropertyResolverUtils.getString(session, CLIENT_EXTENSIONS_PROP);
+        String value = PropertyResolverUtils.getString(getServerSession(), CLIENT_EXTENSIONS_PROP);
         if (value == null) {
             return DEFAULT_SUPPORTED_CLIENT_EXTENSIONS;
         }
@@ -2288,36 +2401,42 @@ public class SftpSubsystem extends AbstractLoggingBean implements Command, Runna
 
     /**
      * @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
      * @return Number of written entries
      * @throws IOException If failed to generate an entry
      */
-    protected int doReadDir(int id, DirectoryHandle dir, Buffer buffer, int maxSize) throws IOException {
+    protected int doReadDir(int id, String handle, DirectoryHandle dir, Buffer buffer, int maxSize) throws IOException {
         int nb = 0;
         LinkOption[] options = IoUtils.getLinkOptions(false);
+        Map<String, Path> entries = new TreeMap<>();
         while ((dir.isSendDot() || dir.isSendDotDot() || dir.hasNext()) && (buffer.wpos() < maxSize)) {
             if (dir.isSendDot()) {
-                writeDirEntry(id, dir, buffer, nb, dir.getFile(), ".", options);
+                writeDirEntry(id, dir, entries, buffer, nb, dir.getFile(), ".", options);
                 dir.markDotSent();    // do not send it again
             } else if (dir.isSendDotDot()) {
-                writeDirEntry(id, dir, buffer, nb, dir.getFile().getParent(), "..", options);
+                writeDirEntry(id, dir, entries, buffer, nb, dir.getFile().getParent(), "..", options);
                 dir.markDotDotSent(); // do not send it again
             } else {
                 Path f = dir.next();
-                writeDirEntry(id, dir, buffer, nb, f, getShortName(f), options);
+                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}
@@ -2325,8 +2444,10 @@ public class SftpSubsystem extends AbstractLoggingBean implements Command, Runna
      * @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, Buffer buffer, int index, Path f, String shortName, LinkOption... options) throws IOException {
+    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);
         if (version == SftpConstants.SFTP_V3) {
@@ -2634,7 +2755,20 @@ public class SftpSubsystem extends AbstractLoggingBean implements Command, Runna
         return Collections.emptyMap();
     }
 
-    protected void setAttributes(Path file, Map<String, ?> attributes) throws IOException {
+    protected void doSetAttributes(Path file, Map<String, ?> attributes) throws IOException {
+        SftpEventListener listener = getSftpEventListenerProxy();
+        ServerSession session = getServerSession();
+        listener.modifyingAttributes(session, file, attributes);
+        try {
+            setFileAttributes(file, attributes);
+            listener.modifiedAttributes(session, file, attributes, null);
+        } catch (IOException | RuntimeException e) {
+            listener.modifiedAttributes(session, file, attributes, e);
+            throw e;
+        }
+    }
+
+    protected void setFileAttributes(Path file, Map<String, ?> attributes) throws IOException {
         Set<String> unsupported = new HashSet<>();
         for (String attribute : attributes.keySet()) {
             String view = null;
@@ -2765,6 +2899,8 @@ public class SftpSubsystem extends AbstractLoggingBean implements Command, Runna
     }
 
     /**
+     * Makes sure that the local handle is not null and of the specified type
+     *
      * @param <H>    The generic handle type
      * @param handle The original handle id
      * @param h      The resolved {@link Handle} instance
@@ -2824,6 +2960,13 @@ public class SftpSubsystem extends AbstractLoggingBean implements Command, Runna
 
             closed = true;
 
+            try {
+                SftpEventListener listener = getSftpEventListenerProxy();
+                listener.destroying(getServerSession());
+            } catch (Exception e) {
+                log.warn("Failed (" + e.getClass().getSimpleName() + ") to announce destruction event: " + e.getMessage(), e);
+            }
+
             // if thread has not completed, cancel it
             if ((pendingFuture != null) && (!pendingFuture.isDone())) {
                 boolean result = pendingFuture.cancel(true);

http://git-wip-us.apache.org/repos/asf/mina-sshd/blob/0c443af5/sshd-core/src/main/java/org/apache/sshd/server/subsystem/sftp/SftpSubsystemFactory.java
----------------------------------------------------------------------
diff --git a/sshd-core/src/main/java/org/apache/sshd/server/subsystem/sftp/SftpSubsystemFactory.java b/sshd-core/src/main/java/org/apache/sshd/server/subsystem/sftp/SftpSubsystemFactory.java
index ce235bf..bdc5d2e 100644
--- a/sshd-core/src/main/java/org/apache/sshd/server/subsystem/sftp/SftpSubsystemFactory.java
+++ b/sshd-core/src/main/java/org/apache/sshd/server/subsystem/sftp/SftpSubsystemFactory.java
@@ -19,10 +19,13 @@
 
 package org.apache.sshd.server.subsystem.sftp;
 
+import java.util.Collection;
 import java.util.concurrent.ExecutorService;
 
 import org.apache.sshd.common.subsystem.sftp.SftpConstants;
+import org.apache.sshd.common.util.GenericUtils;
 import org.apache.sshd.common.util.ObjectBuilder;
+import org.apache.sshd.common.util.ValidateUtils;
 import org.apache.sshd.common.util.threads.ExecutorServiceConfigurer;
 import org.apache.sshd.server.Command;
 import org.apache.sshd.server.subsystem.SubsystemFactory;
@@ -30,36 +33,49 @@ import org.apache.sshd.server.subsystem.SubsystemFactory;
 /**
  * @author <a href="mailto:dev@mina.apache.org">Apache MINA SSHD Project</a>
  */
-public class SftpSubsystemFactory implements SubsystemFactory, Cloneable, ExecutorServiceConfigurer {
+public class SftpSubsystemFactory extends AbstractSftpEventListenerManager implements SubsystemFactory, ExecutorServiceConfigurer, SftpEventListenerManager {
     public static final String NAME = SftpConstants.SFTP_SUBSYSTEM_NAME;
     public static final UnsupportedAttributePolicy DEFAULT_POLICY = UnsupportedAttributePolicy.Warn;
 
-    public static class Builder implements ObjectBuilder<SftpSubsystemFactory> {
-        private final SftpSubsystemFactory factory = new SftpSubsystemFactory();
+    public static class Builder extends AbstractSftpEventListenerManager implements ObjectBuilder<SftpSubsystemFactory> {
+        private ExecutorService executors;
+        private boolean shutdownExecutor;
+        private UnsupportedAttributePolicy policy = DEFAULT_POLICY;
 
         public Builder() {
             super();
         }
 
         public Builder withExecutorService(ExecutorService service) {
-            factory.setExecutorService(service);
+            executors = service;
             return this;
         }
 
         public Builder withShutdownOnExit(boolean shutdown) {
-            factory.setShutdownOnExit(shutdown);
+            shutdownExecutor = shutdown;
             return this;
         }
 
         public Builder withUnsupportedAttributePolicy(UnsupportedAttributePolicy p) {
-            factory.setUnsupportedAttributePolicy(p);
+            policy = ValidateUtils.checkNotNull(p, "No policy");
             return this;
         }
 
         @Override
         public SftpSubsystemFactory build() {
-            // return a clone so that each invocation returns a different instance - avoid shared instances
-            return factory.clone();
+            SftpSubsystemFactory factory = new SftpSubsystemFactory();
+            factory.setExecutorService(executors);
+            factory.setShutdownOnExit(shutdownExecutor);
+            factory.setUnsupportedAttributePolicy(policy);
+
+            Collection<? extends SftpEventListener> listeners = getRegisteredListeners();
+            if (GenericUtils.size(listeners) > 0) {
+                for (SftpEventListener l : listeners) {
+                    factory.addSftpEventListener(l);
+                }
+            }
+
+            return factory;
         }
     }
 
@@ -111,29 +127,22 @@ public class SftpSubsystemFactory implements SubsystemFactory, Cloneable, Execut
 
     /**
      * @param p The {@link UnsupportedAttributePolicy} to use if failed to access
-     *          some local file attributes
+     *          some local file attributes - never {@code null}
      */
     public void setUnsupportedAttributePolicy(UnsupportedAttributePolicy p) {
-        if (p == null) {
-            throw new IllegalArgumentException("No policy provided");
-        }
-
-        policy = p;
+        policy = ValidateUtils.checkNotNull(p, "No policy");
     }
 
     @Override
     public Command create() {
-        return new SftpSubsystem(getExecutorService(), isShutdownOnExit(), getUnsupportedAttributePolicy());
-    }
-
-    @Override
-    public SftpSubsystemFactory clone() {
-        try {
-            return getClass().cast(super.clone());  // shallow clone is good enough
-        } catch (CloneNotSupportedException e) {
-            throw new UnsupportedOperationException("Unexpected clone exception", e);   // unexpected since we implement cloneable
+        SftpSubsystem subsystem = new SftpSubsystem(getExecutorService(), isShutdownOnExit(), getUnsupportedAttributePolicy());
+        Collection<? extends SftpEventListener> listeners = getRegisteredListeners();
+        if (GenericUtils.size(listeners) > 0) {
+            for (SftpEventListener l : listeners) {
+                subsystem.addSftpEventListener(l);
+            }
         }
-    }
-
 
+        return subsystem;
+    }
 }

http://git-wip-us.apache.org/repos/asf/mina-sshd/blob/0c443af5/sshd-core/src/test/java/org/apache/sshd/client/subsystem/sftp/AbstractSftpClientTestSupport.java
----------------------------------------------------------------------
diff --git a/sshd-core/src/test/java/org/apache/sshd/client/subsystem/sftp/AbstractSftpClientTestSupport.java b/sshd-core/src/test/java/org/apache/sshd/client/subsystem/sftp/AbstractSftpClientTestSupport.java
index 2833fb9..c743cad 100644
--- a/sshd-core/src/test/java/org/apache/sshd/client/subsystem/sftp/AbstractSftpClientTestSupport.java
+++ b/sshd-core/src/test/java/org/apache/sshd/client/subsystem/sftp/AbstractSftpClientTestSupport.java
@@ -22,7 +22,6 @@ package org.apache.sshd.client.subsystem.sftp;
 import java.io.IOException;
 import java.nio.file.FileSystem;
 import java.nio.file.Path;
-import java.util.Arrays;
 import java.util.Collections;
 
 import org.apache.sshd.client.subsystem.sftp.extensions.SftpClientExtension;
@@ -59,7 +58,7 @@ public abstract class AbstractSftpClientTestSupport extends BaseTestSupport {
 
     protected void setupServer() throws Exception {
         sshd = setupTestServer();
-        sshd.setSubsystemFactories(Arrays.<NamedFactory<Command>>asList(new SftpSubsystemFactory()));
+        sshd.setSubsystemFactories(Collections.<NamedFactory<Command>>singletonList(new SftpSubsystemFactory()));
         sshd.setCommandFactory(new ScpCommandFactory());
         sshd.setFileSystemFactory(fileSystemFactory);
         sshd.start();

http://git-wip-us.apache.org/repos/asf/mina-sshd/blob/0c443af5/sshd-core/src/test/java/org/apache/sshd/client/subsystem/sftp/SftpTest.java
----------------------------------------------------------------------
diff --git a/sshd-core/src/test/java/org/apache/sshd/client/subsystem/sftp/SftpTest.java b/sshd-core/src/test/java/org/apache/sshd/client/subsystem/sftp/SftpTest.java
index 96dc836..9a46f8e 100644
--- a/sshd-core/src/test/java/org/apache/sshd/client/subsystem/sftp/SftpTest.java
+++ b/sshd-core/src/test/java/org/apache/sshd/client/subsystem/sftp/SftpTest.java
@@ -18,11 +18,6 @@
  */
 package org.apache.sshd.client.subsystem.sftp;
 
-import static org.apache.sshd.common.subsystem.sftp.SftpConstants.SSH_FX_FILE_ALREADY_EXISTS;
-import static org.apache.sshd.common.subsystem.sftp.SftpConstants.SSH_FX_NO_SUCH_FILE;
-import static org.apache.sshd.common.subsystem.sftp.SftpConstants.S_IRUSR;
-import static org.apache.sshd.common.subsystem.sftp.SftpConstants.S_IWUSR;
-
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
 import java.io.File;
@@ -31,6 +26,7 @@ import java.io.InputStream;
 import java.io.OutputStream;
 import java.net.URI;
 import java.nio.charset.StandardCharsets;
+import java.nio.file.CopyOption;
 import java.nio.file.FileSystem;
 import java.nio.file.Files;
 import java.nio.file.LinkOption;
@@ -46,6 +42,8 @@ import java.util.Set;
 import java.util.Vector;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.atomic.AtomicReference;
 
 import org.apache.sshd.client.SshClient;
 import org.apache.sshd.client.session.ClientSession;
@@ -55,6 +53,7 @@ import org.apache.sshd.client.subsystem.sftp.extensions.BuiltinSftpClientExtensi
 import org.apache.sshd.client.subsystem.sftp.extensions.SftpClientExtension;
 import org.apache.sshd.common.Factory;
 import org.apache.sshd.common.FactoryManager;
+import org.apache.sshd.common.NamedFactory;
 import org.apache.sshd.common.PropertyResolverUtils;
 import org.apache.sshd.common.file.FileSystemFactory;
 import org.apache.sshd.common.random.Random;
@@ -66,9 +65,17 @@ import org.apache.sshd.common.subsystem.sftp.extensions.SupportedParser.Supporte
 import org.apache.sshd.common.subsystem.sftp.extensions.openssh.AbstractOpenSSHExtensionParser.OpenSSHExtension;
 import org.apache.sshd.common.util.GenericUtils;
 import org.apache.sshd.common.util.OsUtils;
+import org.apache.sshd.common.util.ValidateUtils;
 import org.apache.sshd.common.util.buffer.ByteArrayBuffer;
 import org.apache.sshd.common.util.io.IoUtils;
+import org.apache.sshd.server.Command;
+import org.apache.sshd.server.session.ServerSession;
+import org.apache.sshd.server.subsystem.sftp.DirectoryHandle;
+import org.apache.sshd.server.subsystem.sftp.FileHandle;
+import org.apache.sshd.server.subsystem.sftp.Handle;
+import org.apache.sshd.server.subsystem.sftp.SftpEventListener;
 import org.apache.sshd.server.subsystem.sftp.SftpSubsystem;
+import org.apache.sshd.server.subsystem.sftp.SftpSubsystemFactory;
 import org.apache.sshd.util.test.JSchLogger;
 import org.apache.sshd.util.test.SimpleUserInfo;
 import org.apache.sshd.util.test.Utils;
@@ -79,6 +86,8 @@ import org.junit.FixMethodOrder;
 import org.junit.Ignore;
 import org.junit.Test;
 import org.junit.runners.MethodSorters;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 import com.jcraft.jsch.ChannelSftp;
 import com.jcraft.jsch.JSch;
@@ -390,7 +399,7 @@ public class SftpTest extends AbstractSftpClientTestSupport {
 
                     // NOTE: on Windows files are always readable
                     int perms = sftp.stat(file).perms;
-                    int permsMask = S_IWUSR | (isWindows ? 0 : S_IRUSR);
+                    int permsMask = SftpConstants.S_IWUSR | (isWindows ? 0 : SftpConstants.S_IRUSR);
                     assertEquals("Mismatched permissions for " + file + ": 0x" + Integer.toHexString(perms), 0, (perms & permsMask));
 
                     javaFile.setWritable(true, false);
@@ -443,6 +452,177 @@ public class SftpTest extends AbstractSftpClientTestSupport {
 
     @Test
     public void testClient() throws Exception {
+        List<NamedFactory<Command>> factories = sshd.getSubsystemFactories();
+        assertEquals("Mismatched subsystem factories count", 1, GenericUtils.size(factories));
+
+        NamedFactory<Command> f = factories.get(0);
+        assertObjectInstanceOf("Not an SFTP subsystem factory", SftpSubsystemFactory.class, f);
+
+        SftpSubsystemFactory factory = (SftpSubsystemFactory) f;
+        final AtomicInteger versionHolder = new AtomicInteger(-1);
+        final AtomicInteger openCount = new AtomicInteger(0);
+        final AtomicInteger closeCount = new AtomicInteger(0);
+        final AtomicLong readSize = new AtomicLong(0L);
+        final AtomicLong writeSize = new AtomicLong(0L);
+        final AtomicInteger entriesCount = new AtomicInteger(0);
+        final AtomicInteger creatingCount = new AtomicInteger(0);
+        final AtomicInteger createdCount = new AtomicInteger(0);
+        final AtomicInteger removingCount = new AtomicInteger(0);
+        final AtomicInteger removedCount = new AtomicInteger(0);
+        final AtomicInteger modifyingCount = new AtomicInteger(0);
+        final AtomicInteger modifiedCount = new AtomicInteger(0);
+        factory.addSftpEventListener(new SftpEventListener() {
+            private final Logger log = LoggerFactory.getLogger(SftpEventListener.class);
+
+            @Override
+            public void initialized(ServerSession session, int version) {
+                log.info("initialized(" + session + ") version: " + version);
+                assertTrue("Initialized version below minimum", version >= SftpSubsystem.LOWER_SFTP_IMPL);
+                assertTrue("Initialized version above maximum", version <= SftpSubsystem.HIGHER_SFTP_IMPL);
+                assertTrue("Initializion re-called", versionHolder.getAndSet(version) < 0);
+            }
+
+            @Override
+            public void destroying(ServerSession session) {
+                log.info("destroying(" + session + ")");
+                assertTrue("Initialization method not called", versionHolder.get() > 0);
+            }
+
+            @Override
+            public void write(ServerSession session, String remoteHandle, FileHandle localHandle, long offset, byte[] data, int dataOffset, int dataLen) {
+                writeSize.addAndGet(dataLen);
+                if (log.isDebugEnabled()) {
+                    log.debug("write(" + session + ")[" + localHandle.getFile() + "] offset=" + offset + ", requested=" + dataLen);
+                }
+            }
+
+            @Override
+            public void removing(ServerSession session, Path path) {
+                removingCount.incrementAndGet();
+                log.info("removing(" + session + ") " + path);
+            }
+
+            @Override
+            public void removed(ServerSession session, Path path, Throwable thrown) {
+                removedCount.incrementAndGet();
+                log.info("removed(" + session + ") " + path
+                       + ((thrown == null) ? "" : (": " + thrown.getClass().getSimpleName() + ": " + thrown.getMessage())));
+            }
+
+            @Override
+            public void modifyingAttributes(ServerSession session, Path path, Map<String, ?> attrs) {
+                modifyingCount.incrementAndGet();
+                log.info("modifyingAttributes(" + session + ") " + path);
+            }
+
+            @Override
+            public void modifiedAttributes(ServerSession session, Path path, Map<String, ?> attrs, Throwable thrown) {
+                modifiedCount.incrementAndGet();
+                log.info("modifiedAttributes(" + session + ") " + path
+                       + ((thrown == null) ? "" : (": " + thrown.getClass().getSimpleName() + ": " + thrown.getMessage())));
+            }
+
+            @Override
+            public void read(ServerSession session, String remoteHandle, FileHandle localHandle, long offset, byte[] data, int dataOffset, int dataLen, int readLen) {
+                readSize.addAndGet(readLen);
+                if (log.isDebugEnabled()) {
+                    log.debug("read(" + session + ")[" + localHandle.getFile() + "] offset=" + offset + ", requested=" + dataLen + ", read=" + readLen);
+                }
+            }
+
+            @Override
+            public void read(ServerSession session, String remoteHandle, DirectoryHandle localHandle, Map<String, Path> entries) {
+                int numEntries = GenericUtils.size(entries);
+                entriesCount.addAndGet(numEntries);
+
+                if (log.isDebugEnabled()) {
+                    log.debug("read(" + session + ")[" + localHandle.getFile() + "] " + numEntries + " entries");
+                }
+
+                if ((numEntries > 0) && log.isTraceEnabled()) {
+                    for (Map.Entry<String, Path> ee : entries.entrySet()) {
+                        log.trace("read(" + session + ")[" + localHandle.getFile() + "] " + ee.getKey() + " - " + ee.getValue());
+                    }
+                }
+            }
+
+            @Override
+            public void open(ServerSession session, String remoteHandle, Handle localHandle) {
+                Path path = localHandle.getFile();
+                log.info("open(" + session + ")[" + remoteHandle + "] " + (Files.isDirectory(path) ? "directory" : "file") + " " + path);
+                openCount.incrementAndGet();
+            }
+
+            @Override
+            public void moving(ServerSession session, Path srcPath, Path dstPath, Collection<CopyOption> opts) {
+                log.info("moving(" + session + ")[" + opts + "]" + srcPath + " => " + dstPath);
+            }
+
+            @Override
+            public void moved(ServerSession session, Path srcPath, Path dstPath, Collection<CopyOption> opts, Throwable thrown) {
+                log.info("moved(" + session + ")[" + opts + "]" + srcPath + " => " + dstPath
+                       + ((thrown == null) ? "" : (": " + thrown.getClass().getSimpleName() + ": " + thrown.getMessage())));
+            }
+
+            @Override
+            public void linking(ServerSession session, Path src, Path target, boolean symLink) {
+                log.info("linking(" + session + ")[" + symLink + "]" + src + " => " + target);
+            }
+
+            @Override
+            public void linked(ServerSession session, Path src, Path target, boolean symLink, Throwable thrown) {
+                log.info("linked(" + session + ")[" + symLink + "]" + src + " => " + target
+                      + ((thrown == null) ? "" : (": " + thrown.getClass().getSimpleName() + ": " + thrown.getMessage())));
+            }
+
+            @Override
+            public void creating(ServerSession session, Path path, Map<String, ?> attrs) {
+                creatingCount.incrementAndGet();
+                log.info("creating(" + session + ") " + (Files.isDirectory(path) ? "directory" : "file") + " " + path);
+            }
+
+            @Override
+            public void created(ServerSession session, Path path, Map<String, ?> attrs, Throwable thrown) {
+                createdCount.incrementAndGet();
+                log.info("created(" + session + ") " + (Files.isDirectory(path) ? "directory" : "file") + " " + path
+                       + ((thrown == null) ? "" : (": " + thrown.getClass().getSimpleName() + ": " + thrown.getMessage())));
+            }
+
+            @Override
+            public void blocking(ServerSession session, String remoteHandle, FileHandle localHandle, long offset, long length, int mask) {
+                log.info("blocking(" + session + ")[" + localHandle.getFile() + "]"
+                       + " offset=" + offset + ", length=" + length + ", mask=0x" + Integer.toHexString(mask));
+            }
+
+            @Override
+            public void blocked(ServerSession session, String remoteHandle, FileHandle localHandle,
+                                long offset, long length, int mask, Throwable thrown) {
+                log.info("blocked(" + session + ")[" + localHandle.getFile() + "]"
+                       + " offset=" + offset + ", length=" + length + ", mask=0x" + Integer.toHexString(mask)
+                       + ((thrown == null) ? "" : (": " + thrown.getClass().getSimpleName() + ": " + thrown.getMessage())));
+            }
+
+            @Override
+            public void unblocking(ServerSession session, String remoteHandle, FileHandle localHandle, long offset, long length) {
+                log.info("unblocking(" + session + ")[" + localHandle.getFile() + "] offset=" + offset + ", length=" + length);
+            }
+
+            @Override
+            public void unblocked(ServerSession session, String remoteHandle, FileHandle localHandle,
+                                  long offset, long length, Boolean result, Throwable thrown) {
+                log.info("unblocked(" + session + ")[" + localHandle.getFile() + "]"
+                        + " offset=" + offset + ", length=" + length + ", result=" + result
+                        + ((thrown == null) ? "" : (": " + thrown.getClass().getSimpleName() + ": " + thrown.getMessage())));
+            }
+
+            @Override
+            public void close(ServerSession session, String remoteHandle, Handle localHandle) {
+                Path path = localHandle.getFile();
+                log.info("close(" + session + ")[" + remoteHandle + "] " + (Files.isDirectory(path) ? "directory" : "file") + " " + path);
+                closeCount.incrementAndGet();
+            }
+        });
+
         try (SshClient client = setupTestClient()) {
             client.start();
 
@@ -451,8 +631,20 @@ public class SftpTest extends AbstractSftpClientTestSupport {
                 session.auth().verify(5L, TimeUnit.SECONDS);
 
                 try (SftpClient sftp = session.createSftpClient()) {
+                    assertEquals("Mismatched negotiated version", sftp.getVersion(), versionHolder.get());
                     testClient(client, sftp);
                 }
+
+                assertEquals("Mismatched open/close count", openCount.get(), closeCount.get());
+                assertTrue("No entries read", entriesCount.get() > 0);
+                assertTrue("No data read", readSize.get() > 0L);
+                assertTrue("No data written", writeSize.get() > 0L);
+                assertEquals("Mismatched removal counts", removingCount.get(), removedCount.get());
+                assertTrue("No removals signalled", removedCount.get() > 0);
+                assertEquals("Mismatched creation counts", creatingCount.get(), createdCount.get());
+                assertTrue("No creations signalled", creatingCount.get() > 0);
+                assertEquals("Mismatched modification counts", modifyingCount.get(), modifiedCount.get());
+                assertTrue("No modifications signalled", modifiedCount.get() > 0);
             } finally {
                 client.stop();
             }
@@ -681,7 +873,7 @@ public class SftpTest extends AbstractSftpClientTestSupport {
                         sftp.rename(file2Path, file3Path);
                         fail("Unxpected rename success of " + file2Path + " => " + file3Path);
                     } catch (org.apache.sshd.client.subsystem.sftp.SftpException e) {
-                        assertEquals("Mismatched status for failed rename of " + file2Path + " => " + file3Path, SSH_FX_NO_SUCH_FILE, e.getStatus());
+                        assertEquals("Mismatched status for failed rename of " + file2Path + " => " + file3Path, SftpConstants.SSH_FX_NO_SUCH_FILE, e.getStatus());
                     }
 
                     try (OutputStream os = sftp.write(file2Path, SftpClient.MIN_WRITE_BUFFER_SIZE)) {
@@ -692,7 +884,7 @@ public class SftpTest extends AbstractSftpClientTestSupport {
                         sftp.rename(file1Path, file2Path);
                         fail("Unxpected rename success of " + file1Path + " => " + file2Path);
                     } catch (org.apache.sshd.client.subsystem.sftp.SftpException e) {
-                        assertEquals("Mismatched status for failed rename of " + file1Path + " => " + file2Path, SSH_FX_FILE_ALREADY_EXISTS, e.getStatus());
+                        assertEquals("Mismatched status for failed rename of " + file1Path + " => " + file2Path, SftpConstants.SSH_FX_FILE_ALREADY_EXISTS, e.getStatus());
                     }
 
                     sftp.rename(file1Path, file2Path, SftpClient.CopyMode.Overwrite);
@@ -918,6 +1110,126 @@ public class SftpTest extends AbstractSftpClientTestSupport {
     public void testCreateSymbolicLink() throws Exception {
         // Do not execute on windows as the file system does not support symlinks
         Assume.assumeTrue("Skip non-Unix O/S", OsUtils.isUNIX());
+        List<NamedFactory<Command>> factories = sshd.getSubsystemFactories();
+        assertEquals("Mismatched subsystem factories count", 1, GenericUtils.size(factories));
+
+        NamedFactory<Command> f = factories.get(0);
+        assertObjectInstanceOf("Not an SFTP subsystem factory", SftpSubsystemFactory.class, f);
+
+        SftpSubsystemFactory factory = (SftpSubsystemFactory) f;
+        final AtomicReference<LinkData> linkDataHolder = new AtomicReference<>();
+        factory.addSftpEventListener(new SftpEventListener() {
+            @Override
+            public void write(ServerSession session, String remoteHandle, FileHandle localHandle, long offset, byte[] data, int dataOffset, int dataLen) {
+                // ignored
+            }
+
+            @Override
+            public void removing(ServerSession session, Path path) {
+                // ignored
+            }
+
+            @Override
+            public void removed(ServerSession session, Path path, Throwable thrown) {
+                // ignored
+            }
+
+            @Override
+            public void read(ServerSession session, String remoteHandle, FileHandle localHandle, long offset, byte[] data, int dataOffset, int dataLen, int readLen) {
+                // ignored
+            }
+
+            @Override
+            public void read(ServerSession session, String remoteHandle, DirectoryHandle localHandle, Map<String, Path> entries) {
+                // ignored
+            }
+
+            @Override
+            public void open(ServerSession session, String remoteHandle, Handle localHandle) {
+                // ignored
+            }
+
+            @Override
+            public void modifyingAttributes(ServerSession session, Path path, Map<String, ?> attrs) {
+                // ignored
+            }
+
+            @Override
+            public void modifiedAttributes(ServerSession session, Path path, Map<String, ?> attrs, Throwable thrown) {
+                // ignored
+            }
+
+            @Override
+            public void blocking(ServerSession session, String remoteHandle, FileHandle localHandle, long offset, long length, int mask) {
+                // ignored
+            }
+
+            @Override
+            public void blocked(ServerSession session, String remoteHandle, FileHandle localHandle, long offset, long length, int mask, Throwable thrown) {
+                // ignored
+            }
+
+            @Override
+            public void unblocking(ServerSession session, String remoteHandle, FileHandle localHandle, long offset, long length) {
+                // ignored
+            }
+
+            @Override
+            public void unblocked(ServerSession session, String remoteHandle, FileHandle localHandle,
+                                  long offset, long length, Boolean result, Throwable thrown) {
+                // ignored
+            }
+
+            @Override
+            public void moving(ServerSession session, Path srcPath, Path dstPath, Collection<CopyOption> opts) {
+                // ignored
+            }
+
+            @Override
+            public void moved(ServerSession session, Path srcPath, Path dstPath, Collection<CopyOption> opts, Throwable thrown) {
+                // ignored
+            }
+
+            @Override
+            public void linking(ServerSession session, Path src, Path target, boolean symLink) {
+                assertNull("Multiple linking calls", linkDataHolder.getAndSet(new LinkData(src, target, symLink)));
+            }
+
+            @Override
+            public void linked(ServerSession session, Path src, Path target, boolean symLink, Throwable thrown) {
+                LinkData data = linkDataHolder.get();
+                assertNotNull("No previous linking call", data);
+                assertSame("Mismatched source", data.getSource(), src);
+                assertSame("Mismatched target", data.getTarget(), target);
+                assertEquals("Mismatched link type", data.isSymLink(), symLink);
+                assertNull("Unexpected failure", thrown);
+            }
+
+            @Override
+            public void initialized(ServerSession session, int version) {
+                // ignored
+            }
+
+            @Override
+            public void destroying(ServerSession session) {
+                // ignored
+            }
+
+            @Override
+            public void creating(ServerSession session, Path path, Map<String, ?> attrs) {
+                // ignored
+            }
+
+            @Override
+            public void created(ServerSession session, Path path, Map<String, ?> attrs, Throwable thrown) {
+                // ignored
+            }
+
+            @Override
+            public void close(ServerSession session, String remoteHandle, Handle localHandle) {
+                // ignored
+            }
+        });
 
         Path targetPath = detectTargetFolder();
         Path lclSftp = Utils.resolve(targetPath, SftpConstants.SFTP_SUBSYSTEM_NAME, getClass().getSimpleName(), getCurrentTestName());
@@ -956,6 +1268,7 @@ public class SftpTest extends AbstractSftpClientTestSupport {
                 Files.delete(linkPath);
             }
             assertFalse("Target link exists before linking: " + linkPath, Files.exists(linkPath, options));
+
             c.symlink(remSrcPath, remLinkPath);
 
             assertTrue("Symlink not created: " + linkPath, Files.exists(linkPath, options));
@@ -967,6 +1280,8 @@ public class SftpTest extends AbstractSftpClientTestSupport {
         } finally {
             c.disconnect();
         }
+
+        assertNotNull("No symlink signalled", linkDataHolder.getAndSet(null));
     }
 
     protected String readFile(String path) throws Exception {
@@ -1005,4 +1320,33 @@ public class SftpTest extends AbstractSftpClientTestSupport {
         }
         return sb.toString();
     }
+
+    static class LinkData {
+        private final Path source;
+        private final Path target;
+        private final boolean symLink;
+
+        LinkData(Path src, Path target, boolean symLink) {
+            this.source = ValidateUtils.checkNotNull(src, "No source");
+            this.target = ValidateUtils.checkNotNull(target, "No target");
+            this.symLink = symLink;
+        }
+
+        public Path getSource() {
+            return source;
+        }
+
+        public Path getTarget() {
+            return target;
+        }
+
+        public boolean isSymLink() {
+            return symLink;
+        }
+
+        @Override
+        public String toString() {
+            return (isSymLink() ? "Symbolic" : "Hard") + " " + getSource() + " => " + getTarget();
+        }
+    }
 }