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:

  • socket is the long-lived mount session.
  • state is socket.state.
  • event is the current FUSE operation payload.
  • route params are local variables, not socket fields.

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.