Getting Started with the NFS component
Introduction
The NFS component implements an NFS 4.1 server, providing a simple way to serve files without a kernel mode driver. The NFS component is available for all supported platforms but is particularly useful in macOS where kernel mode driver installation can be challenging
Contents
Starting the Server
To begin, call StartListening to start listening for incoming connections. The component will listen on the interface defined by LocalHost and LocalPort. For example:
nfs.LocalHost = "LocalHost";
nfs.LocalPort = 2049; // default
nfs.StartListening();
while (nfs.Listening) {
nfs.DoEvents();
}
StopListening may be called to stop listening for incoming connections. Shutdown may be called to stop listening for incoming connections and disconnect all existing connections
Handling Connections
Once listening, the component can accept (or reject) incoming connections. Incoming connection details are first available through the ConnectionRequest event. Here, the connection's originating address and port can be queried. By default, the component will accept all incoming connections, but this behavior can be overridden within this event.
Once a connection is complete, the Connected event will fire. Note that this event will fire if a connection succeeds or fails. If successful, the event will fire with a StatusCode of 0. A non-zero value indicates the connection was unsuccessful, and the Description parameter will contain relevant details.
After a successful connection, relevant connection-specific details will be available within the Connections collection. Each connection will be assigned a unique ConnectionId which may be used to access these details.
To manually disconnect a connected client call the Disconnect method and pass the ConnectionId. After a connection has disconnected, the Disconnected event will fire. In the case a connection ends and an error is encountered, the StatusCode and Description parameters will contain relevant details regarding the error. Once disconnected, the connection will be removed from the Connections collection.
Note: The component is designed for use on single-user machines only. For use in other contexts, additional security should be handled by the user. As a result, the component will only accept incoming connections originating from the the local host by default, as indicated by the AllowedClients config (127.0.0.1). This behavior may be overridden by modifying the AllowedClients config, or manually accepting a connection using the ConnectionRequest event.
Handling Events
The NFS component hides most of the complexities involved; the following sections discuss the primary considerations to take into account. Most of the events of the component must be handled for the filesystem to function properly. Many of the listed events expose a Result parameter, which communicates the operation's success (or failure) to the component and connection. This parameter is always 0 (NFS4_OK) when relevant events fire. If the event, or operation, cannot be handled successfully, this parameter should be set to a non-zero value. Possible Result codes and their descriptions are defined in RFC 7530 section 13.
Please refer to the documentation for more specific information about each event.
Create and Open Files
The Open event is fired when a client attempts to create or open a file.
When your application handles this event, it usually obtains access to some resource (be it a file, a remote resource, a reference to a memory block, etc.). It is necessary to keep a handle to that resource stored somewhere so that it can be used to handle events that fire for subsequent file operations. To assist with this, NFS provides the FileContext parameter that the application can use to store data related to a particular file. This handle is passed to any other events that fire for the file in question (Read, Write, Truncate, Rename, etc.), allowing your application to handle them more efficiently. Note that on Windows, this value is shared between handles to the same file, opened concurrently. Please see below for a detailed example.
To handle this event appropriately, the application should perform any actions needed to create or open the requested file. The application should first examine the OpenType parameter, which indicates whether the file should be created before opening. In the event the file should be created, the application should then examine the CreateMode parameter, which indicates how the file should be created and under what circumstances an error may be returned.
The application must also open the file with the share reservations specified by the ShareAccess and ShareDeny event parameters. ShareAccess specifies the client's desired access for the file (e.g., read access, write access, or both). ShareDeny specifies the access the client wishes to deny to any other clients (e.g., deny read access, write access, both, or neither).
For additional information regarding the mentioned event parameters, please refer to the documentation.
Example: Opening or creating a file with share reservations
nfs.OnOpen += (o, e) => {
string path = "C:\\NFSRootDir" + e.Path;
FileStream fs = null;
FileAccess shareAccess = (FileAccess)e.ShareAccess; // ShareAccess correlates directly with FileAccess
FileShare shareDeny = 0; // ShareDeny does not correlate directly with FileShare, so let's translate ShareDeny to FileShare
switch (e.ShareDeny)
{
case OPEN4_SHARE_DENY_BOTH:
{
shareDeny = FileShare.None; // Allow no access by other clients
break;
}
case OPEN4_SHARE_DENY_WRITE:
{
shareDeny = FileShare.Read; // Allow read access by other clients
break;
}
case OPEN4_SHARE_DENY_READ:
{
shareDeny = FileShare.Write | FileShare.Delete; // Allow write access and deletion by other clients
break;
}
default:
{
// Default OPEN4_SHARE_DENY_NONE
shareDeny = FileShare.ReadWrite | FileShare.Delete; // Allow read and write access and deletion by other clients
break;
}
}
FileMode createMode = FileMode.Open;
if (e.OpenType == OPEN4_NOCREATE) {
// The client wishes to open the file without creating it. If it doesn't exist, return NFS4ERR_NOENT, otherwise open with FileMode.Open.
if (!File.Exists(e.Path)) {
e.Result = NFS4ERR_NOENT;
return;
}
} else {
// The client wishes to create a file, with the specified CreateMode
switch (e.CreateMode)
{
case UNCHECKED4:
{
createMode = FileMode.Create; // If a duplicate exists, no error is returned
break;
}
default:
{
// Implies e.CreateMode == GUARDED4 || EXCLUSIVE4. If a duplicate exists, NFS4ERR_EXIST is returned
if (File.Exists(path)) {
e.Result = NFS4ERR_EXIST;
return;
}
// Otherwise, proceed with creating the file.
createMode = FileMode.CreateNew;
break;
}
}
}
fs = File.Open(path, createMode, shareAccess, shareDeny);
e.FileContext = (IntPtr)GCHandle.Alloc(fs);
};
Note that the creation of directories is handled through the MkDir event.
Close Files
The Close event is fired when the client requests the closure of a previously opened file.
To handle this event properly, the application should release the share reservations for this specific file created during a previous Open operation. This operation is only applicable to the Open operation performed by a given client for the specified file and does not apply to any Open operations performed by other clients for the same file.
Additionally, this event includes the FileContext parameter. If your application stored a reference to some context object or structure, such context must be disposed of by your application in the event handler.
Listing Directory Contents
The ReadDir event is fired when a client attempts to list the contents of a directory identified by the Path parameter.
To handle this event properly, the application must call the FillDir method for each existing entry within the associated directory. Doing so will buffer the relevant directory entry information to be sent to the client upon the return of this event.
Note when listing a directory, the client will specify a limit on the amount of data (or number of entries) to return in a single response. To handle this, the application should analyze the returned value of each call to FillDir within this event to ensure the limit is not exceeded. A non-zero return value indicates that the most recent call to FillDir would have caused the application to send more data than the limit specified by the client. In this case, the event should return immediately with a Result of NFS4_OK.
Afterward, the client will send subsequent requests to continue retrieving entries. In these requests, the Cookie parameter will be equal to the cookie value the application specified in the last successful entry provided by FillDir. Note that the cookie value provided in FillDir and the Cookie parameter specified by the client are only meaningful to the server. The cookie values should be interpreted and utilized as a "bookmark" of the directory entry, indicating a point for continuing the directory listing. Please see the documentation for the FillDir for further details.
This event also includes the FileContext parameter. When a directory listing begins, as indicated by a Cookie value of 0, this context may be set. If the directory listing spans multiple ReadDir operations as mentioned above, this context may be used again. Note that if all directory entries have been listed, the FileContext should be disposed of before ReadDir returns.
Example: Starting or continuing a directory listing
int baseCookie = 0x12345678;
nfs.OnReadDir += (o, e) => {
int dirOffset = 0; // Initial directory offset
// Arbitrary base cookie to start at for listing directory entries.
// On calls to FillDir, this value will be incremented, so subsequent READDIR operations can resume from a specified cookie.
long cookie = baseCookie;
// If e.Cookie == 0, we start listing from the beginning of the directory.
// Otherwise, we start listing from the offset indicated by this parameter.
if (e.Cookie != 0) {
offset = e.Cookie - baseCookie + 1;
cookie = e.Cookie + 1;
}
string path = "C:\\NFSRootDir" + e.Path;
var entries = Directory.GetFileSystemEntries(path, "*", SearchOption.TopDirectoryOnly);
// Iterate through all directory entries.
// dirOffset indicates the next entry to be listed given the client's provided Cookie.
for (int i = dirOffset; i < entries.Length; i++) {
string name = Path.GetFileName(entries[i]);
bool isDir = Directory.Exists(entries[i]);
int result = 0;
if (isDir) {
int fileMode = S_IFDIR | S_IRWXU | S_IRGRP | S_IXGRP | S_IROTH | S_IXOTH; // Indicates file type (S_IFDIR) and permissions (755)
result = nfs.FillDir(e.ConnectionId, name, cookie++, fileMode, "OWNER", "OWNER_GROUP", 1, 4096,
new DirectoryInfo(path).LastAccessTime, new DirectoryInfo(path).LastWriteTime, new DirectoryInfo(path).CreationTime);
} else {
int fileMode = S_IFREG | S_IWUSR | S_IRUSR | S_IRGRP | S_IROTH; // Indicates file type (S_IFREG) and permissions (644).
result = nfs.FillDir(e.ConnectionId, name, cookie++, fileMode, "OWNER", "OWNER_GROUP", 1, FileInfo(path).Length,
new FileInfo(path).LastAccessTime, new FileInfo(path).LastWriteTime, new FileInfo(path).CreationTime);
}
// Return if FillDir returned non-zero value (client's entry limit has been reached)
if (result != 0) {
// No entries were returned, set Result in this case to alert client
if (i == dirOffset) {
e.Result = NFS4ERR_TOOSMALL;
}
return;
}
}
};
File Read/Write Operations
When a client sends file read and write requests to the server, the NFS component's Read and Write events will fire. Both events include parameters that describe the offset into the file at which your application must begin reading/writing data, the data itself, etc.
The Commit event fires when a client attempts to flush any uncommitted file data from a previous Write operation to stable storage. Assuming that all data provided to the Write operation was committed to stable storage, this event may not fire.
The Read, Write, and Commit events also include the FileContext parameter, described above. For additional information regarding these events, please refer to the product documentation.
Example: Reading data from a file
nfs.OnRead += (o, e) => {
if (e.Count == 0) {
return;
}
try {
IntPtr p = e.FileContext;
GCHandle h = (GCHandle)p;
FileStream fs = h.Target as FileStream;
if (e.Offset >= fs.Length) {
e.Count = 0;
e.Eof = true;
return;
}
fs.Position = e.Offset;
int count = fs.Read(e.BufferB, 0, e.Count);
// If EOF was reached, notify the client
if (fs.Position == fs.Length) {
e.Eof = true;
} else {
e.Eof = false;
}
// Return number of bytes read
e.Count = c;
} catch (Exception ex) {
e.Result = NFS4ERR_IO;
}
};
Example: Writing data to a file
nfs.OnWrite += (o, e) => {
if (e.Count == 0) {
return;
}
try {
IntPtr p = e.FileContext;
GCHandle h = (GCHandle)p;
FileStream fs = h.Target as FileStream;
fs.Position = e.Offset;
fs.Write(e.BufferB, 0, e.Count);
fs.Flush();
// All data was written to disk, indicate this via Stable.
e.Stable = FILE_SYNC4;
} catch (Exception ex) {
e.Result = NFS4ERR_IO;
}
};
Create and Read Links
Link support in the NFS component is made available through the CreateLink and ReadLink events. When a client attempts to create a link, the CreateLink event will fire, indicating the specific type of link being created via the LinkType parameter (indicating a symbolic link or hard link). Please refer to the below sections for handling the creation of each specific type of link.
Symbolic Links
When a client attempts to create a symbolic link, the CreateLink event will fire with a LinkType set to 0. In this case, the application should attempt to create a symbolic link pointing to the data specified by the LinkTarget or LinkTargetB parameters. The data specified by the client will need to be returned exactly as provided within the ReadLink event, which fires when a client attempts to read a symbolic link.
In the case of symbolic links, the LinkTarget parameters should not be interpreted by the server. Typically, the link target is a path to some local file, however, this may not always be the case. It is ultimately left up to the client to interpret the data associated with the symbolic link.
In the case of symbolic links, it is also the applications responsibility to return this data to a client when ReadLink fires. Applications should return the data as provided exactly by the client within the CreateLink event. Note that this data may or may not be UTF-8 encoded. In that regard, the data should be treated like the content of a regular file. Please refer to the documentation for additional details regarding CreateLink and ReadLink. Additionally, please see below for a simple example handling symbolic links:
Example: Create and Read Symbolic Links
nfs.OnCreateLink += (o, e) => {
string linkname = GetRealPath(root, e.Path);
if (File.Exists(linkname)) {
e.Result = NFS4ERR_NOENT;
return;
}
// Symbolic link
if (e.LinkType == 0) {
try {
// CreateSymbolicLink function from kernel32.dll
CreateSymbolicLink(linkname, e.LinkTarget, 0);
} catch (Exception ex) {
e.Result = NFS4ERR_IO;
}
}
};
nfs.OnReadLink += (o, e) => {
try {
string real = GetRealPath(root, e.Path);
// NET 6 FileSystemInfo class contains a LinkTarget property
FileSystemInfo fileInfo = new FileInfo(real);
byte[] data = System.Text.Encoding.UTF8.GetBytes(fileInfo.LinkTarget);
int bytesToRead = data.Length - (int)e.Offset;
e.Eof = true;
if (bytesToRead > e.Count) {
bytesToRead = e.Count; // 1024 bytes, size of e.BufferB
e.Eof = false; // ReadLink will fire again to finish copying link content
}
System.Array.Copy(data, e.Offset, e.BufferB, 0, bytesToRead);
e.Count = bytesToRead;
} catch (Exception ex) {
e.Result = NFS4ERR_IO;
}
};
Hard Links
When a client attempts to create a symbolic link, the CreateLink event will fire with a LinkType set to 1. In this case, the application should attempt to create a hard link pointing to the file specified by the LinkTarget parameter. Unlike with symbolic links, the LinkTarget parameter should be interpreted by the server, as the path to an existing local file.
To appropriately handle this case, the application should create a hard link at the specified Path that points to the data associated with the file specified by LinkTarget.
Depending on the underlying filesystem, "." and ".." are illegal values for a new object name. In this case, Result should be set to NFS4ERR_BADNAME. Additionally, if the new object name has a length of 0, or does not obey the UTF-8 definition, Result should be set to NFS4ERR_INVAL. Please see below for a simple example of creating a hard link.
Example: Create and Read Symbolic Links
nfs.OnCreateLink += (o, e) => {
string linkname = GetRealPath(root, e.Path);
string target = GetRealPath(root, e.LinkTarget);
if (File.Exists(linkname)) {
e.Result = NFS4ERR_EXIST;
return;
}
// Hard link
if (e.LinkType == 1) {
try {
// CreateHardLink function from kernel32.dll
CreateHardLink(linkname, target, IntPtr.Zero);
} catch (Exception ex) {
e.Result = NFS4ERR_IO;
}
}
};
Renaming and Deletion
Files and directories are deleted via Unlink and RmDir events, respectively. Note that while most files contain just one link (main file name), if the file has several hard links, then only the link specified in the parameters of the Unlink must be removed, and the file itself must be deleted only when no more links are pointing to it.
File and directory renaming and moving are all atomic operations that must be handled via the Rename event. When renaming a file or directory, the OldPath parameter specifies the original object, or the object to move. The NewPath parameter will specify the target object, or the location where the original object should be moved to.
To appropriately handle the Rename event, the application should determine whether the target object identified by NewPath exists. If the target object does not exist, the application should proceed with the operation and the original object should be renamed. However, if the target object exists, the application must determine whether the original and target object are compatible (i.e., both objects are either files or directories). If the objects are incompatible, the rename operation should not succeed and an error should be returned via Result.
Assuming the objects are compatible, the behavior will differ depending on whether both objects are files or directories. If both objects are files, the target file should simply be removed and replaced by the original file. If both objects are directories, the target directory should only be removed and replaced assuming it contains no entries. If the target directory contains no entries, the rename operation should succeed. Otherwise, it should fail. Please see below for a detailed example of these cases and applicable errors.
Example: Renaming a file or directory
nfs.OnRename += (o, e) => {
string oldPath = "C:\\NFSRootDir" + e.OldPath;
string newPath = "C:\\NFSRootDir" + e.NewPath;
// Check if oldPath and newPath refer to the same file. If so, return successfully.
if (oldPath.Equals(newPath)) {
return;
}
// Check whether oldPath is a file or directory
if (Directory.Exists(oldPath)) {
// oldPath is a directory. Check for incompatible rename operation.
if (File.Exists(newPath)) {
// Incompatible, Directory -> File
e.Result = NFS4ERR_EXIST;
return;
}
// Check if target directory exists. If so, check the number of files in this directory.
int fileCount = 0;
if (Directory.Exists(newPath)) {
fileCount = Directory.GetFiles(newPath, "*", SearchOption.TopDirectoryOnly).Length;
}
// If files exist in the target directory, return error.
if (fileCount == 0) {
Directory.Move(oldPath, newPath);
} else {
e.Result = NFS4ERR_EXIST;
}
} else {
// oldPath is a file. Check for incompatible rename operation.
if (Directory.Exists(newPath)) {
// Incompatible, File -> Directory
e.Result = NFS4ERR_EXIST;
return;
}
if (File.Exists(newPath)) {
File.Delete(newPath);
}
File.Move(oldPath, newPath);
}
};
Modifying Attributes
Changes in object attributes are communicated via the Chmod, Chown, Truncate, and UTime events. These events include a Path parameter and FileContext parameter described above.
Chmod fires when a client attempts to modify the permission bits of an object as defined in the UNIX standard sys/stat.h header. Applications should note that the client will not modify the bits associated with the file type.
Chown fires when a client attempts to modify an object's owner attribute, group attribute, or both.
Truncate fires when a client attempts to modify a file's size attribute.
UTime fires when a client attempts to change a file's last access time, last modification time, or both
We appreciate your feedback. If you have any questions, comments, or suggestions about this article please contact our support team at support@callback.com.