Writing a FUSE filesystem in Go

- Hariharan

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)

package main

import (
	"flag"
	"fmt"
	"log"
	"os"

	"bazil.org/fuse"
	"bazil.org/fuse/fs"
	_ "bazil.org/fuse/fs/fstestutil"
	logfs "github.com/cvhariharan/logfs/fs"
)

func main() {
	flag.Usage = usage
	flag.Parse()

	if flag.NArg() != 1 {
		usage()
		os.Exit(2)
	}
	mountpoint := flag.Arg(0)

	c, err := fuse.Mount(
		mountpoint,
		fuse.FSName("logfs"),
		fuse.Subtype("logfs"),
	)
	if err != nil {
		log.Fatal(err)
	}
	defer c.Close()

	err = fs.Serve(c, logfs.NewFS())
	if err != nil {
		log.Fatal(err)
	}
}

func usage() {
	fmt.Fprintf(os.Stderr, "Usage of %s:\n", os.Args[0])
	fmt.Fprintf(os.Stderr, "  %s MOUNTPOINT\n", os.Args[0])
	flag.PrintDefaults()
}

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.

// fs.go
package fs

import (
	"bazil.org/fuse"
	"bazil.org/fuse/fs"
)

var inodeCount uint64

type EntryGetter interface {
	GetDirentType() fuse.DirentType
}

type FS struct{}

func NewFS() FS {
	return FS{}
}

func (f FS) Root() (fs.Node, error) {
	return NewDir(), nil
}

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.

// dir.go

type Dir struct {
	Type       fuse.DirentType
	Attributes fuse.Attr
	Entries    map[string]interface{}
}

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.

var _ = (fs.Node)((*Dir)(nil))
var _ = (fs.NodeMkdirer)((*Dir)(nil))
var _ = (fs.NodeCreater)((*Dir)(nil))
var _ = (fs.HandleReadDirAller)((*Dir)(nil))
var _ = (fs.NodeSetattrer)((*Dir)(nil))
var _ = (EntryGetter)((*Dir)(nil))
func NewDir() *Dir {
	log.Println("NewDir called")
	atomic.AddUint64(&inodeCount, 1)
	return &Dir{
		Type: fuse.DT_Dir,
		Attributes: fuse.Attr{
			Inode: inodeCount,
			Atime: time.Now(),
			Mtime: time.Now(),
			Ctime: time.Now(),
			Mode:  os.ModeDir | 0o777,
		},
		Entries: map[string]interface{}{},
	}
}

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.

// fs.Node contains the Attr method
func (d *Dir) Attr(ctx context.Context, a *fuse.Attr) error {
	*a = d.Attributes
	log.Println("Attr permissions: ", a.Mode)
	log.Println("Attr: Modified At", a.Mtime.String())
	return nil
}

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.

func (d *Dir) LookUp(ctx context.Context, name string) (fs.Node, error) {
	node, ok := d.Entries[name]
	if ok {
		return node.(fs.Node), nil
	}
	return nil, syscall.ENOENT
}

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.

// fs.NodeCreator contains the Create method
func (d *Dir) Create(ctx context.Context, req *fuse.CreateRequest, resp *fuse.CreateResponse) (fs.Node, fs.Handle, error) {
	log.Println("Create called with filename: ", req.Name)
	f := NewFile(nil)
	log.Println("Create: Modified at", f.Attributes.Mtime.String())
	d.Entries[req.Name] = f
	return f, f, nil
}

And, to allow creating directories within directories, we would need to implement fs.NodeMkdirer.

// fs.NodeMkdirer contains the Mkdir method
func (d *Dir) Mkdir(ctx context.Context, req *fuse.MkdirRequest) (fs.Node, error) {
	log.Println("Mkdir called with name: ", req.Name)
	dir := NewDir()
	Index.SetInDir(d.Inode, req.Name, dir)
	return dir, nil
}

To remove any entries in the directory, we will implement Remove

func (d *Dir) Remove(ctx context.Context, req *fuse.RemoveRequest) error {
	delete(d.Entries, req.Name)
	return nil
}

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.

func (d *Dir) Setattr(ctx context.Context, req *fuse.SetattrRequest, resp *fuse.SetattrResponse) error {
	if req.Valid.Atime() {
		d.Attributes.Atime = req.Atime
	}
	if req.Valid.Mtime() {
		d.Attributes.Mtime = req.Mtime
	}
	if req.Valid.Size() {
		d.Attributes.Size = req.Size
	}
	log.Println("Setattr called: Attributes ", d.Attributes)
	return nil
}

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.

func (d *Dir) ReadDirAll(ctx context.Context) ([]fuse.Dirent, error) {
	log.Println("ReadDirAll called")
	var entries []fuse.Dirent

	for k, v := range d.Entries {
		var a fuse.Attr
		v.(fs.Node).Attr(ctx, &a)
		entries = append(entries, fuse.Dirent{
			Inode: a.Inode,
			Type:  v.(EntryGetter).GetDirentType(),
			Name:  k,
		})
	}
	return entries, nil
}

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.

type File struct {
	Type       fuse.DirentType
	Content    []byte
	Attributes fuse.Attr
}

var _ = (fs.Node)((*File)(nil))
var _ = (fs.HandleWriter)((*File)(nil))
var _ = (fs.HandleReadAller)((*File)(nil))
var _ = (fs.NodeSetattrer)((*File)(nil))
var _ = (EntryGetter)((*File)(nil))

func NewFile(content []byte) *File {
	log.Println("NewFile called")
	atomic.AddUint64(&inodeCount, 1)
	return &File{
		Type:    fuse.DT_File,
		Content: content,
		Attributes: fuse.Attr{
			Inode: inodeCount,
			Atime: time.Now(),
			Mtime: time.Now(),
			Ctime: time.Now(),
			Mode:  0o777,
		},
	}
}

The only important difference being the Type which is now a DT_File.

The implementations for Attr and Setattr remain pretty much the same.

func (f *File) Attr(ctx context.Context, a *fuse.Attr) error {
	*a = f.Attributes
	log.Println("Attr: Modified At", a.Mtime.String())
	return nil
}

func (f *File) Setattr(ctx context.Context, req *fuse.SetattrRequest, resp *fuse.SetattrResponse) error {
	if req.Valid.Atime() {
		f.Attributes.Atime = req.Atime
	}
	if req.Valid.Mtime() {
		f.Attributes.Mtime = req.Mtime
	}
	if req.Valid.Size() {
		f.Attributes.Size = req.Size
	}
	log.Println("Setattr called: Attributes ", f.Attributes)
	return nil
}

To allow writing to the file, we will implement the HandleWriter interface.

func (f *File) Write(ctx context.Context, req *fuse.WriteRequest, resp *fuse.WriteResponse) error {
	log.Println("Write called: Size ", f.Attributes.Size)
	log.Println("Data to write: ", string(req.Data))
	f.Content = req.Data
	resp.Size = len(req.Data)
	f.Attributes.Size = uint64(resp.Size)
	return nil
}

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.

func (f *File) ReadAll(ctx context.Context) ([]byte, error) {
	log.Println("ReadAll called")
	return f.Content, nil
}

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.