# Serving a read-only filesystem

This tutorial walks through the smallest useful NFS server you can
build with Tahr: a static, in-memory tree of files served
read-only. By the end you'll be able to mount it with your operating
system's own NFS client.

The complete, tested code lives in the repository at
[`test/support/readonly_fs.ex`](https://harton.dev/james/tahr/src/branch/main/test/support/readonly_fs.ex) —
what follows is that file, piece by piece.

## The moving parts

An NFS server built with this library is just three things:

1. **A `Tahr.Mount.Backend`** — decides which paths clients may
   mount and hands out the root file handle for each.
2. **A `Tahr.NFSv3.Backend`** — answers the filesystem
   procedures: GETATTR, LOOKUP, READ, READDIR, and friends.
3. **A `Tahr.RPC.Server`** — the TCP listener. You hand it a
   *program registry* mapping ONC RPC program numbers to handlers,
   and the library takes care of the wire protocol: XDR, record
   marking, the portmapper, AUTH_SYS.

```
programs = %{
  100_005 => %{3 => Tahr.Mount.Handler.with_backend(MyMountBackend)},
  100_003 => %{3 => Tahr.NFSv3.Handler.with_backend(MyNFSBackend)}
}

children = [
  {Tahr.RPC.Server, port: 2049, programs: programs}
]
```

100005 is the MOUNT program, 100003 is NFS itself, both version 3.
`with_backend/1` binds your backend module into the stateless
handler. The portmapper (program 100000) is always registered for
you so clients can discover the server.

Our filesystem is a compile-time map of `fileid => node`, so every
callback is a pure lookup — no processes, no state to manage.

## Step 1: model the tree

```elixir
defmodule ReadonlyFS do
  @moduledoc """
  A tiny read-only filesystem served over NFSv3.
  """

  alias Tahr.NFSv3.Filehandle
  alias Tahr.NFSv3.Types.{Fattr3, Nfstime3}

  @vol_id "readonly-fs-v001"
  @fsid 0xFE5_0001
  @root_fileid 1

  @modification_time %Nfstime3{seconds: 1_767_225_600, nseconds: 0}

  @tree %{
    1 => %{
      type: :dir,
      mode: 0o755,
      children: %{"hello.txt" => 2, "docs" => 3}
    },
    2 => %{
      type: :reg,
      mode: 0o644,
      content: "Hello from Tahr!\n"
    },
    3 => %{
      type: :dir,
      mode: 0o755,
      children: %{"notes.txt" => 4}
    },
    4 => %{
      type: :reg,
      mode: 0o644,
      content: "Static content, served over NFSv3.\n"
    }
  }
end
```

A flat map of `fileid => node` is enough: directories carry a
`children` map from name to fileid. The root is fileid 1, and it
contains one file and one subdirectory.

### File handles

NFS file handles are opaque blobs of up to 64 bytes that the client
hands back to you on every call — you choose what goes in them.
`Tahr.NFSv3.Filehandle` packs a 16-byte volume id, a 64-bit
fileid, and a 32-bit generation counter into 29 bytes, which is all
we need:

```elixir
  def vol_id, do: @vol_id

  def root_handle, do: handle_for(@root_fileid)

  def handle_for(fileid), do: Filehandle.encode(@vol_id, fileid)

  def fetch(fhandle) do
    with {:ok, decoded} <- Filehandle.decode(fhandle),
         true <- Filehandle.same_volume?(decoded, @vol_id),
         {:ok, node} <- Map.fetch(@tree, decoded.fileid) do
      {:ok, decoded.fileid, node}
    else
      _ -> {:error, :stale}
    end
  end
```

Every callback starts with `fetch/1`. Handles that fail to decode,
belong to a different volume, or name a forgotten fileid are refused
with `NFS3ERR_STALE` — the client drops its cache and re-resolves.

### Attributes

GETATTR and most other replies carry an `fattr3` — POSIX-style
metadata. Note that `ctime` means *metadata-change time*, not
creation time, and for a static tree one fixed timestamp everywhere
is honest:

```elixir
  def attr(fileid, node) do
    size = size_of(node)

    %Fattr3{
      type: node.type,
      mode: node.mode,
      nlink: nlink(node),
      uid: 0,
      gid: 0,
      size: size,
      used: size,
      fsid: @fsid,
      fileid: fileid,
      atime: @modification_time,
      mtime: @modification_time,
      ctime: @modification_time
    }
  end
```

(`nlink/1` and `size_of/1` are two-line helpers — directories claim
two links, files one; only regular files have a size.)

## Step 2: the Mount backend

The MOUNT protocol is how a client turns a path like `/export` into
a root file handle. The behaviour has two required callbacks; the
mount-bookkeeping callbacks are optional and we skip them:

```elixir
defmodule ReadonlyFS.MountBackend do
  @behaviour Tahr.Mount.Backend

  alias Tahr.Mount.Types.ExportNode

  @export_path "/export"
  @auth_sys 1

  @impl true
  def resolve(@export_path, _ctx), do: {:ok, ReadonlyFS.root_handle(), [@auth_sys]}
  def resolve(_path, _ctx), do: {:error, :noent}

  @impl true
  def list_exports(_ctx), do: [%ExportNode{dir: @export_path, groups: []}]
end
```

`resolve/2` returns the root handle plus the list of acceptable RPC
auth flavours (`1` is AUTH_SYS). Anything else gets
`MOUNT3ERR_NOENT`. `list_exports/1` backs the EXPORT procedure —
`showmount -e`.

## Step 3: the NFSv3 backend — read procedures

The `Tahr.NFSv3.Backend` behaviour has 21 callbacks, one per
NFSv3 procedure. Take them in three groups.

### Object lookup and attributes

```elixir
  @impl true
  def getattr(fhandle, _auth, _ctx) do
    case ReadonlyFS.fetch(fhandle) do
      {:ok, fileid, node} -> {:ok, ReadonlyFS.attr(fileid, node)}
      {:error, status} -> {:error, status}
    end
  end
```

Every callback receives the file handle, the decoded credential
(`%Tahr.RPC.Auth.Sys{}` or `%Auth.None{}`), and an opaque `ctx`
map carrying the RPC envelope (including `ctx.peer`, the client's IP
address, if you want per-export host filtering).

ACCESS answers "which of these permission bits will you grant?" —
RFC 1813's flags are READ `0x01`, LOOKUP `0x02`, MODIFY `0x04`,
EXTEND `0x08`, DELETE `0x10`, EXECUTE `0x20`. A read-only server
grants only the read-ish subset:

```elixir
  @access_read 0x0001
  @access_lookup 0x0002
  @access_execute 0x0020
  @grantable bor(bor(@access_read, @access_lookup), @access_execute)

  @impl true
  def access(fhandle, requested_mask, _auth, _ctx) do
    case ReadonlyFS.fetch(fhandle) do
      {:ok, fileid, node} -> {:ok, band(requested_mask, @grantable), ReadonlyFS.attr(fileid, node)}
      {:error, status} -> {:error, status, nil}
    end
  end
```

Mind the error shapes — they differ per callback because the RFC
attaches attributes to different replies. GETATTR errors are bare
`{:error, status}`; ACCESS errors carry a `post_op_attr` (or `nil`)
as a third element. Each callback's `@doc` spells out its shape.

LOOKUP is the workhorse — name to handle within a directory:

```elixir
  @impl true
  def lookup(dir_fhandle, name, _auth, _ctx) do
    case ReadonlyFS.fetch(dir_fhandle) do
      {:ok, dir_id, %{type: :dir} = dir} -> lookup_child(dir_id, dir, name)
      {:ok, _fileid, _node} -> {:error, :notdir, nil}
      {:error, status} -> {:error, status, nil}
    end
  end

  defp lookup_child(dir_id, dir, name) do
    case ReadonlyFS.fetch_child(dir, name) do
      {:ok, fileid, node} ->
        {:ok, ReadonlyFS.handle_for(fileid), ReadonlyFS.attr(fileid, node),
         ReadonlyFS.attr(dir_id, dir)}

      {:error, status} ->
        {:error, status, ReadonlyFS.attr(dir_id, dir)}
    end
  end
```

Note the three-element success tuple: the child's handle, the
child's attributes, and the *directory's* attributes — and that even
a `NOENT` reply carries the directory's attributes so the client can
refresh its cache.

### Reading file content

READ returns a map with a lazy chunk enumerator, an EOF flag, and a
post-op attribute. The handler streams the chunks straight to the
socket, so a real backend should never materialise a whole file —
our static files are already in memory, so a one-element list is
honest here:

```elixir
  @impl true
  def read(fhandle, offset, count, _auth, _ctx) do
    case ReadonlyFS.fetch(fhandle) do
      {:ok, fileid, %{type: :reg, content: content} = node} ->
        size = byte_size(content)
        data = binary_part(content, min(offset, size), min(count, size - min(offset, size)))

        {:ok,
         %{
           data: [data],
           eof: offset + byte_size(data) >= size,
           post_op: ReadonlyFS.attr(fileid, node)
         }}

      {:ok, _fileid, _node} ->
        {:error, :isdir, nil}

      {:error, status} ->
        {:error, status, nil}
    end
  end
```

### Listing directories

READDIR and READDIRPLUS paginate with an opaque *cookie* per entry —
you pick the encoding, the client just hands it back. A *cookie
verifier* lets the server spot a directory that changed
mid-pagination: return `{:error, :bad_cookie, attr}` and the client
restarts from cookie 0. Our tree never changes, so a constant
verifier and position-index cookies are enough:

```elixir
  @cookieverf <<0::64>>

  @impl true
  def readdir(fhandle, cookie, verf, _count, _auth, _ctx) do
    with_dir(fhandle, fn dir_id, dir ->
      with :ok <- check_verf(cookie, verf, dir_id, dir) do
        {:ok, entries_after(dir, cookie), @cookieverf, true, ReadonlyFS.attr(dir_id, dir)}
      end
    end)
  end

  defp entries_after(dir, cookie) do
    dir
    |> ReadonlyFS.entries()
    |> Enum.drop_while(fn {_id, _name, c} -> c <= cookie end)
  end
```

Entries are `{fileid, name, cookie}` tuples; the handler splices in
`.` and `..` for you. We ignore the client's `count` budget and
return everything with `eof: true` — fine for tiny directories; a
real backend must stop when the next entry would blow the budget.
READDIRPLUS is identical except each entry also carries the child's
attributes and handle.

### Server introspection

FSSTAT, FSINFO, and PATHCONF describe the filesystem to the client —
capacity, transfer sizes, feature flags. Static answers are fine:

```elixir
  @impl true
  def fsinfo(fhandle, _auth, _ctx) do
    case ReadonlyFS.fetch(fhandle) do
      {:ok, fileid, node} ->
        reply = %{
          rtmax: 65_536,
          rtpref: 32_768,
          rtmult: 4_096,
          wtmax: 65_536,
          wtpref: 32_768,
          wtmult: 4_096,
          dtpref: 8_192,
          maxfilesize: 1_048_576,
          time_delta: %Nfstime3{seconds: 0, nseconds: 1_000_000},
          properties: @fsf3_homogeneous
        }

        {:ok, reply, ReadonlyFS.attr(fileid, node)}

      {:error, status} ->
        {:error, status, nil}
    end
  end
```

`properties` is an OR of RFC 1813's `FSF3_*` flags — `0x01` LINK,
`0x02` SYMLINK, `0x08` HOMOGENEOUS, `0x10` CANSETTIME. A read-only
tree advertises only HOMOGENEOUS. READLINK we refuse with
`NFS3ERR_INVAL` — there are no symlinks in this tree.

## Step 4: refuse mutations

The remaining eleven callbacks all exist, because the behaviour
requires them — they just say no. `NFS3ERR_ROFS` is exactly what a
real read-only filesystem returns:

```elixir
  @impl true
  def setattr(_fhandle, _sattr, _guard_ctime, _auth, _ctx), do: {:error, :rofs}

  @impl true
  def create(_dir, _name, _mode, _auth, _ctx), do: {:error, :rofs}

  @impl true
  def write(_fhandle, _offset, _data, _stable, _auth, _ctx), do: {:error, :rofs}

  # …and the same for mkdir, remove, rmdir, symlink, mknod, link,
  # rename, and commit.
```

## Step 5: wire the supervision tree

```elixir
defmodule MyApp.Application do
  use Application

  @impl true
  def start(_type, _args) do
    programs = %{
      100_005 => %{3 => Tahr.Mount.Handler.with_backend(ReadonlyFS.MountBackend)},
      100_003 => %{3 => Tahr.NFSv3.Handler.with_backend(ReadonlyFS.NFSBackend)}
    }

    children = [
      {Tahr.RPC.Server, port: 2049, programs: programs}
    ]

    Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor)
  end
end
```

## Step 6: mount it

The library's portmapper answers on the same port as everything
else, so tell the client where to look (rather than the rpcbind it
would normally consult on port 111):

```bash
sudo mount -t nfs -o vers=3,tcp,port=2049,mountport=2049 \
  127.0.0.1:/export /mnt/readonly

cat /mnt/readonly/hello.txt
# Hello from Tahr!

ls -la /mnt/readonly/docs
# notes.txt
```

## Where next

This tree can't change — every write bounces off `NFS3ERR_ROFS`.
The [read-write tutorial](read-write-server.html) builds a mutable
filesystem with an `Agent`, real WRITE/CREATE/RENAME support, and
the weak-cache-consistency data the kernel expects — which is also
the `Tahr.InMemory` reference backend that `mix tahr.serve`
runs.
