jj-vfs-poc / The virtual file layer

The virtual file layer

The three traits between FUSE and jj: VirtualFilesystem (what JjFuse calls), PathMapper (which file a path names) and VirtualFile (what a file can do), plus the one adapter, PathMappedVfs, that joins them.

On this page
  1. 1. VirtualFilesystem
  2. 2. PathMapper
  3. 3. VirtualFile
  4. 4. The data types
  5. 5. PathMappedVfs
  6. Serving reads
  7. 6. Tests

In short: the FUSE adapter speaks in paths, and asks a VirtualFilesystem for attributes, bytes, directory entries or a link target. The only implementation, PathMappedVfs, turns every one of those calls into two steps: ask the PathMapper for the VirtualFile at that path, then call one method on it. A VirtualFile is created fresh for each request and dropped afterwards. Nothing in this layer caches.

How VirtualFilesystem calls map onto VirtualFile methods JjFuse calls one of four VirtualFilesystem methods on PathMappedVfs: get_attributes, read, read_directory and read_link. Each one first calls PathMapper::get_entry with the path to get a boxed VirtualFile, then calls exactly one method on it: attributes, read, list or read_link. read additionally skips offset bytes and takes size bytes from the returned AsyncRead. JjFuse VirtualFilesystem (PathMappedVfs) PathMapper::get_entry get_attributes(path) read(path, offset, size) read_directory(path) read_link(path) VirtualFile::attributes VirtualFile::read VirtualFile::list VirtualFile::read_link every method: 1. get_entry(path) → Box<dyn VirtualFile> 2. one call on it

1. VirtualFilesystem

VirtualFilesystem is described as the "middle-layer filesystem abstraction representing inode-based VFS operations", between the FUSE layer and the path mapper. It is an async_trait, and every method takes a relative &Path (the root is the empty path) and returns Result<_, JjError>.

ItemType/SignatureMeaningSource
get_attributes(&self, path: &Path) → Result<FileAttributes, JjError>Size, type and timestamps for lookup and getattr.vfs.rs
read(&self, path: &Path, offset: u64, size: u32) → Result<Box<[u8]>, JjError>Up to size bytes starting at offset. Fewer bytes means end of file.vfs.rs
read_directory(&self, path: &Path) → Result<DirectoryStream, JjError>The directory's entries as a stream.vfs.rs
read_link(&self, path: &Path) → Result<PathBuf, JjError>A symlink's target.vfs.rs

There is no write, create, rename or remove method. The trait itself makes the mount read-only, not just the mount option.

2. PathMapper

PathMapper "represents the VFS mountpoint structure by mapping a given absolute path to a VirtualFile". It has a single method, async fn get_entry(&self, path: &Path) → Result<Box<dyn VirtualFile>, JjError>, and requires Send + Sync. Despite the doc's "absolute", every caller passes a path relative to the mount root. The only implementation is AllCommitsPathMapper, described on the mount's namespace. The point of the trait is that another layout (for example one showing a single workspace) would only need a new mapper.

3. VirtualFile

VirtualFile is "a file in our virtual file system. This can either be a normal file you can read from or for example a directory". It "is not meant to be created by the user, but instead returned by a PathMapper". Three of its five methods have default implementations that fail, so each implementor only overrides what makes sense for it.

ItemType/SignatureDefaultSource
read(&self) → Result<Pin<Box<dyn AsyncRead + Send>>, JjError>Err(NotAFile)virtual_file.rs
list(&self) → Result<DirectoryStream, JjError>Err(NotADirectory)virtual_file.rs
read_link(&self) → Result<PathBuf, JjError>Err(NotASymlink)virtual_file.rs
attributes(&self) → Result<FileAttributes, JjError>requiredvirtual_file.rs
file_type(&self) → Result<FileType, JjError>requiredvirtual_file.rs

Note that read takes no offset: it hands back a stream positioned at the start of the file. Seeking is the caller's job (see serving reads).

ImplementorOverridesExplained on
StaticDirectorylist, attributes, file_typenamespace
CommitsDirectorylist, attributes, file_typenamespace
WorkspacesDirectorylist, attributes, file_typenamespace
CommitTreeFileall fivecommits as directory trees

4. The data types

ItemType/SignatureMeaningSource
FileTypeenum { File, Directory, Symlink }The three kinds the file system can show. JjFuse maps them to fuser's RegularFile, Directory and Symlink.virtual_file.rs
FileAttributes{ size: u64, file_type, created: SystemTime, modified: SystemTime }Everything a VirtualFile reports about itself. Permissions, ownership and link counts are filled in later by FileAttributes::to_fuse.virtual_file.rs
DirectoryEntry{ name: String, file_type: FileType }One listing entry. No size or inode: the FUSE layer allocates inodes itself as it emits entries.virtual_file.rs
DirectoryStreamBoxStream<'static, DirectoryEntry>A boxed, owned stream of entries, so a listing could in principle be produced lazily. Every implementor at this pin builds a Vec first and streams it.virtual_file.rs

5. PathMappedVfs

PathMappedVfs<P: PathMapper> holds the mapper in an Arc and implements each VirtualFilesystem method in two lines: resolve, then delegate. get_attributes calls attributes, read_directory calls list, read_link calls read_link, and read calls read and then cuts the requested window out of the stream. Errors from either step pass through unchanged, and I/O errors become JjError::IO, which FUSE reports as EIO.

Serving reads

FUSE asks for a byte window (offset, size), but a VirtualFile only offers a stream from the beginning. PathMappedVfs::read bridges the two with futures::io::AsyncReadExt:

  1. Open the stream with virtual_file.read().
  2. Wrap it in take(offset) and copy that into futures::io::sink(), which reads and discards the first offset bytes ("TODO: handle proper seek()").
  3. Unwrap the original reader with into_inner(), wrap it in take(size), and read_to_end into a buffer pre-sized to size.
  4. Return the buffer as Box<[u8]>.

If the file ends before offset, step 2 simply stops early and step 3 returns nothing, which the kernel reads as end of file. The cost is that every read starts from byte 0: reading a file in chunks of c bytes costs about n²/2c bytes of decoding for a file of n bytes, on top of the full file read that getattr already does to find the size.

6. Tests

vfs.rs tests PathMappedVfs against an in-memory fake, without jj (source). MockVirtualFile is a file, a directory of named children, or a symlink, and MockPathMapper walks a tree of them. The tree is file.txt, symlink.txt → file.txt and dir/nested.txt.

TestAsserts
test_read_root_fileReading 11 bytes from offset 0 returns hello world.
test_read_nested_fileThe same through a subdirectory.
test_list_directoryListing dir yields one File entry, nested.txt.
test_list_directory_not_a_directoryListing a file fails with NotADirectory.
test_list_directory_not_foundListing a missing path fails with NotFound.
test_read_linkThe symlink's target is file.txt.
test_read_link_not_a_symlinkReading a file as a link fails with NotASymlink.

Every read in the suite starts at offset 0, including the mounted integration test's 5-byte partial read (source), so no test exercises the offset-skipping step of read with a non-zero offset.