exfuse

Elixir filesystem routing over FUSE.

The native bridge is the Rust port exfuse_port under rust. Mix builds it and copies it to priv/exfuse_port. It is not a NIF.

Set EXFUSE_PORT=/path/to/exfuse_port to override the port executable.

Build

Tool versions live in .mise.toml: Elixir 1.20.1 on OTP 29.

mise install
mise exec -- mix deps.get
mise exec -- mix compile

Portable tests run by default:

mise exec -- mix test

Real mount tests require a working FUSE installation and are opt-in:

EXFUSE_RUN_FUSE_TESTS=1 mise exec -- mix test --only fuse

Hex

Package metadata lives in mix.exs. The Hex package includes the Elixir source and the Rust bridge source; generated binaries and build outputs are excluded.

Verify the package:

mise exec -- mix hex.build --unpack
mise exec -- mix hex.publish --dry-run

Publish with:

mise exec -- mix hex.publish

License

MIT.

Filesystem API

Exfuse.mount/3 mounts one filesystem process at a mount point. Everything below that mount point is served by the filesystem module through FUSE operations like readdir, getattr, open, and read.

defmodule DocsFs do
use Exfuse.Fs
init do
opts
end
readdir "/*" do
case Map.fetch(state, event.path) do
{:ok, {:dir, entries}} -> {:reply, entries, socket}
_ -> {:error, :enoent, socket}
end
end
getattr "/*" do
case Map.fetch(state, event.path) do
{:ok, {:dir, _entries}} -> {:reply, dir(), socket}
{:ok, {:file, data}} -> {:reply, file(size: byte_size(data)), socket}
:error -> {:error, :enoent, socket}
end
end
open "/*" do
case Map.fetch(state, event.path) do
{:ok, {:file, _data}} -> {:noreply, socket}
{:ok, {:dir, _entries}} -> {:error, :eisdir, socket}
_ -> {:error, :enoent, socket}
end
end
read "/*" do
case Map.fetch(state, event.path) do
{:ok, {:file, data}} ->
{:reply, slice(data, event.offset, event.size), socket}
{:ok, {:dir, _entries}} ->
{:error, :eisdir, socket}
:error ->
{:error, :enoent, socket}
end
end
defp slice(data, offset, size) do
start = min(offset, byte_size(data))
count = min(size, byte_size(data) - start)
binary_part(data, start, count)
end
end

Mount a tree:

{:ok, _pid} =
Exfuse.mount("/tmp/docsfs", DocsFs, %{
"/" => {:dir, ["README.md", "docs"]},
"/README.md" => {:file, "readme\n"},
"/docs" => {:dir, ["intro.txt", "api"]},
"/docs/intro.txt" => {:file, "intro\n"},
"/docs/api" => {:dir, ["mount.txt"]},
"/docs/api/mount.txt" => {:file, "mount\n"}
})

The mounted tree is searchable like a normal filesystem:

cd /tmp/docsfs
find .
# .
# ./README.md
# ./docs
# ./docs/intro.txt
# ./docs/api
# ./docs/api/mount.txt

Unmount with the OS tool or with:

Exfuse.umount("/tmp/docsfs")

Route Patterns

Route patterns:

read "/docs/:file" do
{:reply, state[file], socket}
end
read "/docs/*path" do
{:reply, Enum.join(path, "/"), socket}
end
plug "/docs/:file", DocsFile

:name binds one path segment as a binary. *name binds the remaining path tail as a list of segments. Bare * matches the remaining path tail without binding it.

Inside a route block:

plug/2 delegates every matching operation packet to an endpoint process. Processes are keyed by route params, so repeated packets for /docs/a reuse one process while /docs/b gets another.

defmodule DocsFile do
def init(socket) do
{:ok, socket}
end
def handle_event(:getattr, %{params: %{file: file}}, socket) do
{:reply, Exfuse.Fs.file(size: byte_size(file)), socket}
end
def handle_event(:read, %{params: %{file: file}} = event, socket) do
{:reply, read_file(file, event.offset, event.size), socket}
end
def handle_event(_op, _event, socket) do
{:error, :enoent, socket}
end
end

Plug params live in event.params; the socket is still only the long-lived session held by that endpoint process.

Return Channel-style tuples:

{:reply, reply, socket}
{:noreply, socket}
{:error, reason, socket}

Known error atoms include :enoent, :eperm, :eio, :eacces, :eexist, :enotdir, :eisdir, :einval, :enospc, :erofs, and :enosys.

Manual API

For full control, implement handle_event/3 directly.

defmodule ManualDocsFs do
use Exfuse.Fs
def exfuse_init(mount_point, docs) do
{:ok, %{mount_point: mount_point, docs: docs}}
end
def handle_event(:readdir, %{path: "/"}, socket) do
{:reply, Map.keys(socket.state.docs), socket}
end
def handle_event(:getattr, %{path: "/"}, socket) do
{:reply, dir(), socket}
end
def handle_event(:getattr, %{path: "/" <> file}, socket) do
case Map.fetch(socket.state.docs, file) do
{:ok, data} -> {:reply, file(size: byte_size(data)), socket}
:error -> {:error, :enoent, socket}
end
end
def handle_event(:open, %{path: "/" <> file}, socket) do
if Map.has_key?(socket.state.docs, file) do
{handle, socket} = Exfuse.Socket.new_handle(socket, file)
{:reply, handle, socket}
else
{:error, :enoent, socket}
end
end
def handle_event(:read, %{handle: handle, offset: offset, size: size}, socket) do
with {:ok, file} <- Exfuse.Socket.fetch_handle(socket, handle),
{:ok, data} <- Map.fetch(socket.state.docs, file) do
{:reply, slice(data, offset, size), socket}
else
:error -> {:error, :enoent, socket}
end
end
def handle_event(:release, %{handle: handle}, socket) do
{:noreply, Exfuse.Socket.delete_handle(socket, handle)}
end
def handle_event(_op, _event, socket) do
{:error, :enoent, socket}
end
defp slice(data, offset, size) do
start = min(offset, byte_size(data))
count = min(size, byte_size(data) - start)
binary_part(data, start, count)
end
end

%Exfuse.Socket{} is the long-lived mount session:

%Exfuse.Socket{
id: term,
mount_point: "/tmp/docsfs",
state: term,
assigns: %{}
}

Useful handle helpers:

{handle, socket} = Exfuse.Socket.new_handle(socket, value)
{:ok, value} = Exfuse.Socket.fetch_handle(socket, handle)
socket = Exfuse.Socket.delete_handle(socket, handle)

The event carrier is a map. Every event includes:

%{
path: "/file",
uid: uid,
gid: gid,
pid: pid,
umask: umask
}

Extra fields by operation:

opfields
:readflags, handle, offset, size
:writehandle, offset, data
:openflags
:createmode, flags
:truncatesize
:renametarget
:mkdir, :chmodmode
:chownowner_uid, owner_gid
:flush, :releaseflags, handle
:fsyncdatasync, flags, handle

handle_event/3 receives the operation as the first argument, so route and manual code usually match on op there rather than inside the event map.