Writing a FUSE filesystem in Go
Filesystem in Userspace or FUSE allows us to create filesystems without the need to modify the kernel. A fuse filesystem runs in the userspace while the kernel takes care of routing the filesystem calls to the fuse implementation. Say, for example, a user wants to delete a file in the fuse filesystem, they would issue a system call and the fuse kernel module would route that call to the fuse filesystem running in the userspace.
There is a C library called libfuse that can be used to develop such filesystems but since FUSE is just a protocol, there are libraries in other languages as well. Today we will look at bazil/fuse in Golang. I prefer this because you just have to implement a few interfaces to get a fully functional filesystem. It feels a lot like writing REST APIs and servers, which for me is a lot easier to relate to.
LogFS
Just a quick intro, Unix filesystem is made up of inodes that can represent files, directories etc. Inodes store all the metadata and permissions. Directories are made up of child inodes (dirents).
Now let's build a very simple filesystem that logs function calls to understand how bazil/fuse works. This idea is loosely based on the Big Brother Filesystem.
The skeleton code to handle CLI flags. (Stolen from the bazil/fuse example)
1 package main
2
3 import (
4 "flag"
5 "fmt"
6 "log"
7 "os"
8
9 "bazil.org/fuse"
10 "bazil.org/fuse/fs"
11 _ "bazil.org/fuse/fs/fstestutil"
12 logfs "github.com/cvhariharan/logfs/fs"
13 )
14
15 func main() 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
40
41 func usage() 42 43 44 45
46
Here we are parsing the cli flags to get a mount point to start the FUSE server. This is what gets called by the kernel module with FUSE requests.
fs.go provides the entry point for the filesystem. This is the root directory that gets mounted.
1 // fs.go
2 package fs
3
4 import (
5 "bazil.org/fuse"
6 "bazil.org/fuse/fs"
7 )
8
9 var inodeCount uint64
10
11 type EntryGetter interface 12 13
14
15 type FS struct
16
17 func NewFS() FS 18 19
20
21 func () (fs.Node, error) 22 23
We have an inodeCount that gets incremented everytime we add a file/directory, just an easier way to assign inode numbers. Next we define the EntryGetter interface which we will implement for files and directories and it would allow use to differentiate them.
Directories
This is just a standard Go struct which holds all the metadata reponsible for the directory and will be used to create the directory inode.
1 // dir.go
2
3 type Dir struct 4 5 6 7
We will implement a few interfaces provided by bazil/fuse to allow certain operations on the directory. You can read more about all the interfaces here.
1 var _ = (fs.Node)((*Dir)(nil))
2 var _ = (fs.NodeMkdirer)((*Dir)(nil))
3 var _ = (fs.NodeCreater)((*Dir)(nil))
4 var _ = (fs.HandleReadDirAller)((*Dir)(nil))
5 var _ = (fs.NodeSetattrer)((*Dir)(nil))
6 var _ = (EntryGetter)((*Dir)(nil))
1 func NewDir() *Dir 2 3 4 5 6 7 8 9 10 11 12 13 14 15
In the init function, we are incrementing the inodeCount and adding some default attributes to the inode. We will also initialize a map to hold the dirents. Since this is going to be an in-memory filesystem, we won't be looking at persisting this data.
Coming to the interfaces, first up is fs.Node which is the basic interface that all files and directories must implement. This allows us to get the file/directory attributes. Attributes contain the modification time, creation time, size etc.
1 // fs.Node contains the Attr method
2 func (ctx context.Context, a *fuse.Attr) error 3 4 5 6 7
Whenever we access a file, the kernel sends a LookupRequest to the FUSE server. The LookupRequest contains the directory and name of the file and the server responds with a Node (inode). To handler this request, we will implement the Lookup method.
1 func (ctx context.Context, name string) (fs.Node, error) 2 3 4 5 6 7
Since our dirents are held in a map, it makes the lookup a lot easier and efficient.
To allow creating files within the directory, we would need to implement fs.NodeCreater.
1 // fs.NodeCreator contains the Create method
2 func (ctx context.Context, req *fuse.CreateRequest, resp *fuse.CreateResponse) (fs.Node, fs.Handle, error) 3 4 5 6 7 8
And, to allow creating directories within directories, we would need to implement fs.NodeMkdirer.
1 // fs.NodeMkdirer contains the Mkdir method
2 func (ctx context.Context, req *fuse.MkdirRequest) (fs.Node, error) 3 4 5 6 7
To remove any entries in the directory, we will implement Remove
1 func (ctx context.Context, req *fuse.RemoveRequest) error 2 3 4
and to allow updating the attributes when a file/directory is modified, let us also implement SetAttr which is a part of fs.NodeSetattrer interface.
1 func (ctx context.Context, req *fuse.SetattrRequest, resp *fuse.SetattrResponse) error 2 3 4 5 6 7 8 9 10 11 12 13
An important thing to note here is, the SetattrRequest contains a field called Valid which denotes the attributes that were modified. We check and update only those attributes.
Valid can be used to check all the attributes, but we are not doing that here.
1 func (ctx context.Context) ([]fuse.Dirent, error) 2 3 4 5 6 7 8 9 10 11 12 13 14 15
ReadDirAll, as the name suggests, allows us to get all the children of the directory.
And with that, we have a very basic implementation of a directory in our filesystem.
Files
The structs and interfaces differ only slightly from before.
1 type File struct 2 3 4 5
6
7 var _ = (fs.Node)((*File)(nil))
8 var _ = (fs.HandleWriter)((*File)(nil))
9 var _ = (fs.HandleReadAller)((*File)(nil))
10 var _ = (fs.NodeSetattrer)((*File)(nil))
11 var _ = (EntryGetter)((*File)(nil))
12
13 func NewFile(content []byte) *File 14 15 16 17 18 19 20 21 22 23 24 25 26 27
The only important difference being the Type which is now a DT_File.
The implementations for Attr and Setattr remain pretty much the same.
1 func (ctx context.Context, a *fuse.Attr) error 2 3 4 5
6
7 func (ctx context.Context, req *fuse.SetattrRequest, resp *fuse.SetattrResponse) error 8 9 10 11 12 13 14 15 16 17 18 19
To allow writing to the file, we will implement the HandleWriter interface.
1 func (ctx context.Context, req *fuse.WriteRequest, resp *fuse.WriteResponse) error 2 3 4 5 6 7 8
In the WriteRequest, we also get the data size which we use to update the file attribute.
To read the contents of the file, we implement the interface HandleReadAller.
1 func (ctx context.Context) ([]byte, error) 2 3 4
There is also a more generic HandleReader interface where you can handle ReadRequest with size, offset, etc.
And that's it, you have written your own filesystem! Of course, this is just a very basic filesystem but it surprisingly supports almost all common operations.
You can find the complete source code here.
To run this, first let's build the project using go build and run the binary
./logfs <mountpoint>
The mount point should point to a directory. This is similar to how mount works.
Now you can cd into the directory and create directories and write files. Just don't store your precious backups here.