# 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.
```sh
mise install
mise exec -- mix deps.get
mise exec -- mix compile
```
Portable tests run by default:
```sh
mise exec -- mix test
```
Real mount tests require a working FUSE installation and are opt-in:
```sh
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:
```sh
mise exec -- mix hex.build --unpack
mise exec -- mix hex.publish --dry-run
```
Publish with:
```sh
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`.
```elixir
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:
```elixir
{: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:
```sh
cd /tmp/docsfs
find .
# .
# ./README.md
# ./docs
# ./docs/intro.txt
# ./docs/api
# ./docs/api/mount.txt
```
Unmount with the OS tool or with:
```elixir
Exfuse.umount("/tmp/docsfs")
```
## Route Patterns
Route patterns:
```elixir
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.
```elixir
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:
```elixir
{: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.
```elixir
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:
```elixir
%Exfuse.Socket{
id: term,
mount_point: "/tmp/docsfs",
state: term,
assigns: %{}
}
```
Useful handle helpers:
```elixir
{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:
```elixir
%{
path: "/file",
uid: uid,
gid: gid,
pid: pid,
umask: umask
}
```
Extra fields by operation:
| op | fields |
| --- | --- |
| `:read` | `flags`, `handle`, `offset`, `size` |
| `:write` | `handle`, `offset`, `data` |
| `:open` | `flags` |
| `:create` | `mode`, `flags` |
| `:truncate` | `size` |
| `:rename` | `target` |
| `:mkdir`, `:chmod` | `mode` |
| `:chown` | `owner_uid`, `owner_gid` |
| `:flush`, `:release` | `flags`, `handle` |
| `:fsync` | `datasync`, `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.