Introduction
The goal is to explore the Foreign Linker functionalities of Project Panama and to create our simple filesystem. We are going to do this using Java 17 and FUSE. We will be looking at how to do upcalls, downcalls and use memory addresses to create our in-memory filesystem.
What will the file system be able to do?
It is going to do the basics of what you would expect of a filesystem. We can mount it, create/read/write to files, create directories, and unmount it. The focus is on the Foreign Linker functionalities. To keep the implementation easy to understand, we will not implement subdirectories. You would only have to create a Java class that keeps track of where files are created; if you want to add that functionality.
What are Fuse and Project Panama
FUSE (Filesystem in Userspace) lets you create your userspace filesystem if you implement their interface. The FUSE project consists of two components: the FUSE kernel module and the libfuse userspace library. Our implementation will use the high-level API from libfuse. It provides functions to mount the file system, unmount it, read requests from the kernel, and send responses back.
Project Panama is a collection of improvements for the Java language. The goal of the project is to enrich and improve the connection between Java en native (foreign) interfaces that usually are used by applications written in C.
Panama consists of the following JEPs (JDK Enhancement Proposal):
- Foreign-Memory Access API JEPs: JEP-370, JEP-383
- Foreign Linker API JEP: JEP-389
- Vector API JEP: JEP-338
We will focus on the Foreign Linker API as it offers pure Java access to native code. Another benefit of using the Foreign linker is that it should have comparable performance or be better than JNI.
Setup
Before you start, make sure you have FUSE installed on your Linux/Mac system (If you are using Windows, you can use WSL1 or
WSL2 to follow along or any Linux VM). I used libfuse 3.10.5 for the examples. There might be slight differences if you are using an older or newer version. Running ldconfig -p | grep libfuse
in a terminal will show you what version of Libfuse is installed. You will not get any output if Libfuse is not installed.
We also need the Jextract, it is a tool to generate Java files from the C header files and is only available in the Panama early access build. Go to https://jdk.java.net/panama/ and download the latest version for your system and unzip it. We only need this specific version of Java to generate the Java files. The project we are going to build can use any Java 17 GA release.
Also, download and unzip the version of the Libfuse source code that is installed on your system at (https://github.com/libfuse/libfuse/releases) we will use it as input for Jextract.
Setup to run and compile the application.
Because the foreign linker is still in incubation, we have to add some arguments for running and compiling the code. If
you are also using IntelliJ, you need to add --add-modules jdk.incubator.foreign
to the Java compiler options inside settings. and add --enable-native-access=ALL-UNNAMED --add-modules jdk.incubator.foreign
to the VM options inside the run configuration.
Let's start!
First, we will generate the Java files from the Libfuse source. We need to set up our Java version first to do that. Run this inside a terminal:
|
|
To test that jextract is working run:
|
|
This should show you all the available command-line options:
|
|
Creating the Java classes with Jextract
When everything is set up, we can create the Java files from the FUSE source. At the time of writing, I could not find a way
to let Jextract include the FUSE_USE_VERSION
macro. To fix this issue I added #define FUSE_USE_VERSION 35
to the top of fuse.h
in the libfuse-fuse-3.10.5/include/ directory.
Once that is done you can fill in “LIBFUSE_SOURCE_DOWNLOAD_LOCATION”, “LIBFUSE_SOURCE_DOWNLOAD_LOCATION” and run the command to generate the java files.
|
|
-C "-D_FILE_OFFSET_BITS=64"
passes an argument to the c language parser--source -d generated/src
The location we want Jextract to output our files.-t org.linux
the classpath that the generated java files will have.-I {LIBFUSE_SOURCE_DOWNLOAD_LOCATION}/libfuse-fuse-3.10.5/include/
the include filepath{LIBFUSE_SOURCE_DOWNLOAD_LOCATION}/libfuse-fuse-3.10.5/include/fuse.h
the header file we want to use
Implementing FUSE
We now have all the building pieces, so let get started! As we saw earlier FUSE is just an interface we need to implement.
It is not like implementing a Java interface. It is done by calling a C function and passing it a structure (something like Java Records) that holds pointers to Java methods. You can find the structure inside the fuse.h file; it looks like this:
|
|
Do not let the long list scare you. We are only implementing:
getattr
Called when you read the attributes of a filereaddir
Called when you read a directoryread
Called when you read from a filemkdir
Called when you create a directorymknod
Called when you create a filewrite
Called when you write to a file
When something happens inside the filesystems, for example creating a directory, FUSE will call the method that mkdir
points to. The same happens when you read a file. The method that read
points to will be called.
Helper methods
There is some code that we will be using more often. So, putting that in a few methods will make the rest of the code clearer.
At the class level, we add two lists and a map. We use the lists to keep track of the directories and files we have created. The map is used to retrieve the content of a file.
|
|
And these are three small methods to add a file or check if it is a known directory or a file.
|
|
Creating a fuse_operations in Java
We start with this class:
|
|
At line “A” we import all the classes that we generated in the step before. At Line “B” we load the libfuse library that we want to use.
You can use ldconfig -p | grep libfuse
to find where it is located on your system.
At “C” we create an array with parameters we want to pass to FUSE. -f
is to keep it on the foreground, so we can see any output in the console. -d
will make FUSE also print any debug information to the console. /mnt/test/
is the mount point.
A resource scope manages the lifecycle of one or more resources like memory segments. We create a SharedScope at “D” because
Fuse runs multithreaded at default. You can use -s
to make it run single-threaded if you want.
At line “E” we take a Java String and transform it into a C String that is usable by the C function. The result is an array of MemorySegment
.
Then on Line “F”, we create a SegmentAllocator
that we can use at line “G” to allocate memory for our array of MemorySegment
.
Line “H” Shows us how we allocate for the fuse operations. fuse_operations
is the name of the class that Jextract generated for us. It has a method allocate
to allocate memory for itself inside the shared scope.
Implementing getAttr
This is the signature as we know it from fuse_operations
.
|
|
This is the signature from the Jextract generated Java classes. It is part of a functional interface, so we can provide an implementation.
|
|
For our implementation, we will not add fuse_file_info fi
in the signature. Because we will not use it.
|
|
On line “A” we convert a C Strings to a Java String. We know from the Fuse signature that the first parameter is the file path we want the attributes from. The second MemoryAddress
in the parameters is the stat structure that we need to fill with the attributes of the requested file or directory. To access the stat structure, we need its MemorySegment that we get at line “B”.
At line “C” we set the last access time. To set the time, we need to call timespec.tv_sec$set
and set the seconds on a specific
part of the stat memory segment that we obtain by calling tat.st_mtim$slice(statMemorySegment)
.
The user id and group id are set at line D. To make it easy, we just set them 1000 now. In C you would call getuid()
and getgid()
to
get real values.
Inside the if
at line “E” we set st_mode
what specifies if it is a directory or normal file, and we set the permission bits.
We also do this for files. One difference is that we set set_nlink
to two for directories. You can read here why this is done.
(https://unix.stackexchange.com/questions/101515/why-does-a-new-directory-have-a-hard-link-count-of-2-before-anything-is-added-to/101536#101536).
For files, we also need to set the size. Here we Just convert the String to a byte array and use its size.
At Line “H” we return 2 what is equal to ENOENT
in C what means that there is no such file or directory. A component of a specified pathname
did not exist, or the pathname was an empty string.
We return 0 at the end to let FUSE know that we are done and that everything went fine.
why do we need to do Path.substring(1)?
Fuse will pass us a path beginning with a /
. When we later create the implementations for mkdir
and mknod, we will only
store the name and not their path. So, we do not need the /
.
Implementing readDir
The next thing we are implementing is the readDir. This method is called when you want to know what files and directories are available inside the given directory.
The FUSE signature looks like this:
|
|
Jextract created this one. Just as with getatter
it is part of a functional interface, and we have to provide the implementation.
|
|
We have five parameters here, and we will only use the first three. Filler is a little special. It is a helper method provided by FUSE to fill buffer
.
|
|
To invoke methods on filler
we need an instance of it; this is done at line “A”. As we talked about earlier, directories have two links in Unix-based file systems. At “B” we make sure every dir has these two links.
At line C we fill we loop over the two lists and add the created files and directories using filler
.
Implementing read
This method is called when we want to read the content of a file. The Fuse signature is as follows:
|
|
Jextract created this as part of a functional interface for us:
|
|
The method is passed a buffer that we need to fill with the content of the requested file.
|
|
In the first part of the method, we convert to C string to a Java String and check that we know the file. At line “A” we make a ByteBuffer
of the buffer
by first getting its memory segment. Next on line “B” we copy the part that is requested by the user. Next, we fill the Bytebuffer
with the copied range and return the length; so FUSE knows how long
it is.
Implementing doMkdir
This method is called when we create a directory.
This is the FUSE signature.
|
|
Jextract created this as part of a functional interface for us:
|
|
When FUSE calls this method, we only convert the C String to Java and add it to the list of directories.
|
|
Implementing doMknod
This is the fuse signature that we are going to implement.
|
|
Jextract generated method:
|
|
When FUSE calls this method we call the helper method addFile
to add a file to the list of files and create a key value pair
in the file content map.
|
|
Implementing doWrite
The Fuse signature:
|
|
Jextract generated method:
|
|
The do write has a buffer parameter containing the bytes we need to save in memory.
|
|
At line “A” we create a segment with the memory address of the buffer and the size. With those, we create a ByteArray
that we can convert into a String and store inside the file content map.
Filling Fuse operations and starting FUSE
We have implemented all the methods for a basic file system. Now it is time to add them to the fuse operation structure.
|
|
In the code above you see the finished main method. We added six method calls inside the resource scope to add methods to the
fuse operation structure. We also added fuse_h.fuse_main_real
to mount our filesystem.
At line “A” you see how we add the getattr
method to the fuse_operations
. What happens at this line is that we call
the fuse_operations
class and tell it we want to set the getattr
method on our the fuse operations MemorySegment (first parameter).
The second parameter creates a memory address of the lambda method that the generated Jextract code and Fuse can use. The last
parameter is the scope that is the owner of the used memory segments and memory addresses.
We need to do this for every method we want to FUSE to call. These six calls share the same pattern. We only have to point the
lambda to the correct function and call the matching functions on fuse_operations
.
At line “B” we mount our file system. We call fuse_main_real
on the fuse_h
class with the arguments from args
, the
fuse_operations of which we have implemented six methods, and the size of the structure. When the application has started, you can create files and directories inside the mount point. The program keeps running till you stop it or the file system is unmounted. You can unmount it using fusermount -u {MOUNT_LOCATION}
.
*Note If you stop the application yourself you still have to unmount it.
Conclusion
You did it! We created an in-memory file system in Java using the foreign linker API. We used Jextract to generate Java
classes from C header files. Used the Clinker convert Java string to C String and the other way around. We also
called the C function fuse_main_real
directly from Java code. Created six upcalls that FUSE can call when an event happens inside the filesystem.